"""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 ); 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 ); 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 für die Vorschläge 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 ); -- 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 created_at TEXT NOT NULL, UNIQUE (user_id, geoname_id) ); 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}; -- 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}; 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"

{date}

") for para in filter(None, re.split(r"\n{2,}", post.get("text") or "")): parts.append(f"

{html.escape(para).replace(chr(10), '
')}

") parts += [f'

' for img in post.get("images", [])] return "".join(parts) def _import_legacy(conn) -> None: projects_json = DATA_DIR / "projects.json" if projects_json.exists(): for p in json.loads(projects_json.read_text(encoding="utf-8")).get("projects", []): conn.execute( """INSERT OR IGNORE INTO projects (id, title, description, status, cover, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", ( p["id"], p["title"], p.get("description", ""), p.get("status", "aktiv"), p.get("cover"), p.get("content") or _posts_to_html(p.get("posts", [])), p["createdAt"], p["updatedAt"], ), ) projects_json.rename(projects_json.with_name("projects.json.importiert")) # Links aus der Zeit vor den Konten bekommt das erste Konto links_json = DATA_DIR / "links.json" if links_json.exists(): conn.execute("INSERT OR REPLACE INTO meta VALUES ('legacy_links', ?)", (links_json.read_text(encoding="utf-8"),)) links_json.rename(links_json.with_name("links.json.importiert")) def init() -> None: DATA_DIR.mkdir(parents=True, exist_ok=True) with transaction() as conn: conn.execute("PRAGMA journal_mode = WAL") new = conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'").fetchone()[0] == 0 conn.executescript(SCHEMA) # Erst hier importieren: migrations.py nutzt die Tabellen-Definitionen aus diesem Modul from . import migrations if new: migrations.start(conn) else: migrations.upgrade(conn) _import_legacy(conn) init()