"""SQLite-Datenbank (data/p42.db): Schema, Verbindungen und Übernahme der alten JSON-Daten.""" import html import json import re import sqlite3 from contextlib import contextmanager from datetime import datetime from .config import DATA_DIR DB_FILE = DATA_DIR / "p42.db" # Vorrat und Einkauf; key = Name in casefold() für den Vergleich. # Jeder Eintrag gehört zu einer Liste (shopping_lists, z. B. "Zuhause", "Urlaub") mit eigenem Vorrat und Einkauf. # Pro Liste, Produkt, Bereich und Einheit eine Zeile ("Mehl 1 kg" und "Mehl 2 Stück" dürfen nebeneinander stehen). HOUSEHOLD_TABLE = """( id INTEGER PRIMARY KEY, key TEXT NOT NULL, name TEXT NOT NULL, list TEXT NOT NULL CHECK (list IN ('vorrat', 'einkauf')), list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, quantity REAL NOT NULL CHECK (quantity > 0), unit TEXT NOT NULL DEFAULT 'stk' CHECK (unit IN ('stk', 'kg', 'l')), checked INTEGER NOT NULL DEFAULT 0, -- Einkauf: im Wagen, wandert erst mit "Einkauf beenden" in den Vorrat updated_at TEXT NOT NULL, updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, UNIQUE (list_id, key, list, unit) )""" # Kanban-Projekte (je ein Board), gemeinsam für alle Konten KANBAN_PROJECTS_TABLE = """( id INTEGER PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', position REAL NOT NULL, completed_at TEXT, created_at TEXT NOT NULL, created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, updated_at TEXT NOT NULL, updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL )""" # Aufgaben eines Kanban-Projekts; position sortiert innerhalb einer Spalte KANBAN_TASKS_TABLE = """( id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES kanban_projects(id) ON DELETE CASCADE, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL CHECK (status IN ('offen', 'arbeit', 'wartet', 'erledigt')), assignee INTEGER REFERENCES users(id) ON DELETE SET NULL, priority INTEGER NOT NULL DEFAULT 2 CHECK (priority IN (1, 2, 3)), -- 1 hoch, 2 mittel, 3 niedrig position REAL NOT NULL, done_at TEXT, -- seit wann "erledigt": nach 2 Tagen im Archiv, nach 14 Tagen gelöscht created_at TEXT NOT NULL, created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, updated_at TEXT NOT NULL, updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL )""" SCHEMA = f""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, display_name TEXT NOT NULL, password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at REAL NOT NULL ); -- Freundschaften: user_a < user_b; erst nach Annahme (accepted = 1) wirksam. -- Teilen (Listen, Kanban) und fremde Reise-Punkte gibt es nur unter Freunden (Admins: alle). CREATE TABLE IF NOT EXISTS friendships ( user_a INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_b INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, requested_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, accepted INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, PRIMARY KEY (user_a, user_b), CHECK (user_a < user_b) ); CREATE TABLE IF NOT EXISTS user_links ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, key TEXT NOT NULL, url TEXT NOT NULL, PRIMARY KEY (user_id, key) ); CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, cover TEXT, content TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, updated_at TEXT NOT NULL, updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS monitors ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, type TEXT NOT NULL, target TEXT NOT NULL, token TEXT, -- nur Typ "agent" (P42-S.M.); wird nie an den Browser gegeben created_at TEXT NOT NULL ); -- Verlauf der Server-Auslastung (Typ "agent"): ein Mittelwert pro Minute, 2 Tage lang CREATE TABLE IF NOT EXISTS monitor_samples ( monitor_id INTEGER NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, ts INTEGER NOT NULL, -- Unix-Zeit, auf die Minute gerundet cpu REAL, mem REAL, swap REAL, load1 REAL, rx REAL, tx REAL, rd REAL, wr REAL, temp REAL, PRIMARY KEY (monitor_id, ts) ) WITHOUT ROWID; -- Listen mit je eigenem Vorrat und Einkauf (z. B. "Zuhause", "Urlaub"); -- everyone = 1: alle Konten sehen sie, sonst nur Ersteller und Mitglieder CREATE TABLE IF NOT EXISTS shopping_lists ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, everyone INTEGER NOT NULL DEFAULT 1, position REAL NOT NULL, created_at TEXT NOT NULL, created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, category_order TEXT -- JSON-Liste der Kategorien in Laden-Reihenfolge; NULL = Standard ); CREATE TABLE IF NOT EXISTS shopping_list_members ( list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY (list_id, user_id) ); CREATE TABLE IF NOT EXISTS household_items {HOUSEHOLD_TABLE}; -- Produktkatalog: einheitliche Schreibweise ("milch" → "Milch") CREATE TABLE IF NOT EXISTS products ( key TEXT PRIMARY KEY, name TEXT NOT NULL, uses INTEGER NOT NULL DEFAULT 0, last_used TEXT NOT NULL, category TEXT -- Laden-Kategorie (kategorien.py); NULL = noch nicht zugeordnet ); -- Läden einer Liste (z. B. Aldi, dm): Produkte lassen sich einem Laden zuordnen, die Einkaufsliste -- gruppiert danach; jeder Laden kann eine eigene Reihenfolge der Abteilungen haben CREATE TABLE IF NOT EXISTS stores ( id INTEGER PRIMARY KEY, list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, name TEXT NOT NULL, position REAL NOT NULL, category_order TEXT -- JSON wie bei shopping_lists; NULL = Reihenfolge der Liste ); -- In welchem Laden ein Produkt (pro Liste) gekauft wird CREATE TABLE IF NOT EXISTS store_products ( list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, key TEXT NOT NULL, store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, PRIMARY KEY (list_id, key) ); -- Mindestbestand: fällt der Vorrat einer Liste darunter, kommt das Produkt auf ihre Einkaufsliste CREATE TABLE IF NOT EXISTS stock_minimums ( list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, key TEXT NOT NULL, name TEXT NOT NULL, unit TEXT NOT NULL CHECK (unit IN ('stk', 'kg', 'l')), minimum REAL NOT NULL CHECK (minimum > 0), PRIMARY KEY (list_id, key, unit) ); -- Gemerkte Rezepte (gemeinsam für alle Konten): Zutatenzeilen wie im Rezept, Abgleich passiert beim Abruf CREATE TABLE IF NOT EXISTS recipes ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, source TEXT, servings INTEGER, lines TEXT NOT NULL, -- JSON-Liste der Zutatenzeilen created_at TEXT NOT NULL, created_by INTEGER REFERENCES users(id) ON DELETE SET NULL ); -- Welche Produkte in welcher Liste vorkamen: Vorschläge sieht man nur aus Listen, die man sehen darf CREATE TABLE IF NOT EXISTS list_products ( list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, key TEXT NOT NULL, uses INTEGER NOT NULL DEFAULT 0, last_used TEXT NOT NULL, PRIMARY KEY (list_id, key) ); -- Gelernte Zuordnungen Rezeptzutat → Produkt (z. B. "margarine" → "Butter") CREATE TABLE IF NOT EXISTS ingredient_aliases ( key TEXT PRIMARY KEY, product TEXT NOT NULL ); -- Reise-Karte pro Konto: besuchte Städte (GeoNames-ID) und von Hand markierte Länder (ISO-Code) CREATE TABLE IF NOT EXISTS travel_cities ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, geoname_id INTEGER NOT NULL, name TEXT NOT NULL, admin1 TEXT NOT NULL DEFAULT '', country TEXT NOT NULL, lat REAL NOT NULL, lon REAL NOT NULL, population INTEGER, elevation REAL, timezone TEXT, capital INTEGER, -- Hauptstadt (GeoNames PPLC); NULL = noch nicht abgefragt wish INTEGER NOT NULL DEFAULT 0, -- 1 = Wunschliste (noch nicht besucht) created_at TEXT NOT NULL, UNIQUE (user_id, geoname_id) ); -- Besuche einer Stadt (nur für das eigene Konto sichtbar): Zeitraum, Begleitung, Notizen, Fotos CREATE TABLE IF NOT EXISTS travel_visits ( id INTEGER PRIMARY KEY, city_id INTEGER NOT NULL REFERENCES travel_cities(id) ON DELETE CASCADE, date_from TEXT NOT NULL, -- JJJJ-MM-TT date_to TEXT, companions TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL DEFAULT '', continued INTEGER, -- Anreise: 1 = vom vorherigen Ort weitergereist, 0 = von zuhause, NULL = automatisch created_at TEXT NOT NULL ); -- Wohnorte pro Konto mit "seit"-Datum (NULL = von Anfang an): Start und Ziel jeder Reise in der -- Kilometer-Statistik; ein Wechsel (auch ein längerer Auslandsaufenthalt) zählt als Umzug CREATE TABLE IF NOT EXISTS travel_homes ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, geoname_id INTEGER NOT NULL, name TEXT NOT NULL, country TEXT NOT NULL, lat REAL NOT NULL, lon REAL NOT NULL, since TEXT, -- JJJJ-MM-TT UNIQUE (user_id, since) ); CREATE TABLE IF NOT EXISTS travel_visit_photos ( visit_id INTEGER NOT NULL REFERENCES travel_visits(id) ON DELETE CASCADE, file TEXT NOT NULL, -- Datei in data/uploads (ohne Metadaten gespeichert) position INTEGER NOT NULL, PRIMARY KEY (visit_id, file) ); CREATE TABLE IF NOT EXISTS travel_countries ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, code TEXT NOT NULL, PRIMARY KEY (user_id, code) ); -- Zwischenspeicher für Infos von Wikipedia/Wikidata CREATE TABLE IF NOT EXISTS info_cache ( key TEXT PRIMARY KEY, value TEXT NOT NULL, fetched_at REAL NOT NULL ); -- Kanban: Projekte (je ein Board) → Aufgaben; gemeinsam für alle Konten CREATE TABLE IF NOT EXISTS kanban_projects {KANBAN_PROJECTS_TABLE}; -- Mitglieder eines Kanban-Projekts (zusätzlich zum Ersteller); Projekte sind privat CREATE TABLE IF NOT EXISTS kanban_project_members ( project_id INTEGER NOT NULL REFERENCES kanban_projects(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY (project_id, user_id) ); -- Verknüpfung Kanban-Projekt ↔ Artikel aus "P42 Projekte" CREATE TABLE IF NOT EXISTS kanban_project_links ( project_id INTEGER NOT NULL REFERENCES kanban_projects(id) ON DELETE CASCADE, article_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, PRIMARY KEY (project_id, article_id) ); CREATE TABLE IF NOT EXISTS kanban_tasks {KANBAN_TASKS_TABLE}; -- Checkliste innerhalb einer Aufgabe CREATE TABLE IF NOT EXISTS kanban_checklist ( id INTEGER PRIMARY KEY, task_id INTEGER NOT NULL REFERENCES kanban_tasks(id) ON DELETE CASCADE, text TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL ); -- Abhängigkeiten: task_id ist gesperrt, bis requires_id in "Wartet" oder "Erledigt" steht (nur innerhalb eines Projekts) CREATE TABLE IF NOT EXISTS kanban_dependencies ( task_id INTEGER NOT NULL REFERENCES kanban_tasks(id) ON DELETE CASCADE, requires_id INTEGER NOT NULL REFERENCES kanban_tasks(id) ON DELETE CASCADE, PRIMARY KEY (task_id, requires_id), CHECK (task_id != requires_id) ); CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); """ def connect() -> sqlite3.Connection: conn = sqlite3.connect(DB_FILE, timeout=10) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") return conn @contextmanager def transaction(): """Verbindung für einen Arbeitsschritt; bei Erfolg wird gespeichert, bei Fehlern zurückgerollt.""" conn = connect() try: with conn: yield conn finally: conn.close() def get_meta(conn, key: str): row = conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone() return row["value"] if row else None # ---------- Übernahme der alten JSON-Dateien ---------- def _posts_to_html(posts: list) -> str: """Ganz alte Daten mit Update-Feed in Artikeltext umwandeln.""" parts = [] for post in reversed(posts): date = datetime.fromisoformat(post["createdAt"].replace("Z", "+00:00")).strftime("%d.%m.%Y") parts.append(f"
{html.escape(para).replace(chr(10), '
')}