From d28e6b2e4b0cb12ed181fc217e6ab4d4983417a6 Mon Sep 17 00:00:00 2001 From: guyg1474 <340572510+guyg1474@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:09:12 +0200 Subject: [PATCH] Migrationen versioniert: migrations.py mit PRAGMA user_version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - db.py enthält nur noch Schema (neuester Stand), Verbindungen und Start - Neue Datenbank: direkt im neuesten Schema, erste Liste "Zuhause", Version = Anzahl der Schritte - Bestehende Datenbank: nur fehlende Schritte, jeder genau einmal - Bisherige Einzel-Umstellungen als Schritt 1 "Altbestand" (prüfen weiter selbst, was nötig ist); künftige Schritte werden einfach angehängt - Tests für Versionsnummer und "Schritt läuft nur einmal" Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- backend/app/db.py | 159 ++------------------------ backend/app/migrations.py | 181 ++++++++++++++++++++++++++++++ backend/tests/test_migrationen.py | 22 ++++ 4 files changed, 214 insertions(+), 154 deletions(-) create mode 100644 backend/app/migrations.py diff --git a/README.md b/README.md index 7397463..f0edf66 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,8 @@ Quellen und Lizenzen: `docs/CREDITS.md`. Alles liegt im Ordner `data/`: die Datenbank `p42.db` (Konten, Projekte, Links, Monitoring, Vorrat, Reisen, Kanban) und die Bilder in `uploads/`. Neue Tabellen und -Spalten legt der Server beim Start selbst an (Migrationen in `backend/app/db.py`). Für ein Backup den Server kurz stoppen und +Spalten legt der Server beim Start selbst an: nummerierte Umstellungen in `backend/app/migrations.py`, +der erreichte Stand steht in der Datenbank (`PRAGMA user_version`). Für ein Backup den Server kurz stoppen und den Ordner `data/` sichern. Ältere `projects.json`/`links.json` werden beim ersten Start automatisch übernommen und danach in `*.importiert` umbenannt. @@ -232,7 +233,8 @@ backend/ requirements.txt app/main.py FastAPI: API-Routen, Uploads, Auslieferung des Frontends app/config.py liest config.json - app/db.py SQLite-Schema, Übernahme alter JSON-Daten + app/db.py SQLite-Schema (neuester Stand), Verbindungen, Übernahme alter JSON-Daten + app/migrations.py Nummerierte Umstellungen bestehender Datenbanken (Regel steht oben in der Datei) app/auth.py Konten, Anmeldung, Benutzerverwaltung app/projects.py Projekte/Artikel, Bild-Uploads, HTML-Bereinigung app/links.py Schnellzugriff-Links pro Konto diff --git a/backend/app/db.py b/backend/app/db.py index 860e011..40b7e35 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -265,163 +265,18 @@ def _import_legacy(conn) -> None: links_json.rename(links_json.with_name("links.json.importiert")) -def _migrate(conn) -> None: - """Vorrat/Einkauf aus früheren Versionen (ohne Einheiten, ohne Listen oder Vorrat ohne Liste) neu aufbauen. - - Alles, was noch keiner Liste gehört, kommt in die erste Liste bzw. in eine neue Liste "Zuhause", - die alle Konten sehen. - """ - info = {r["name"]: r for r in conn.execute("PRAGMA table_info(household_items)")} - if "list_id" in info and info["list_id"]["notnull"]: - # Neue Datenbank: einmalig eine erste Liste anlegen (wer später alle löscht, bekommt keine zurück) - if get_meta(conn, "haushalt_start") is None: - if not conn.execute("SELECT 1 FROM shopping_lists").fetchone(): - conn.execute( - "INSERT INTO shopping_lists (name, everyone, position, created_at) VALUES ('Zuhause', 1, 1, ?)", (now_iso(),) - ) - conn.execute("INSERT INTO meta (key, value) VALUES ('haushalt_start', '1')") - return - row = conn.execute("SELECT id FROM shopping_lists ORDER BY position, id LIMIT 1").fetchone() - if row: - default = row["id"] - # Die Zwischenversion nannte die gemeinsame Liste "Einkaufsliste"; jetzt enthält sie auch den Vorrat - conn.execute("UPDATE shopping_lists SET name = 'Zuhause' WHERE id = ? AND name = 'Einkaufsliste' AND created_by IS NULL", (default,)) - else: - default = conn.execute( - "INSERT INTO shopping_lists (name, everyone, position, created_at) VALUES ('Zuhause', 1, 1, ?)", (now_iso(),) - ).lastrowid - unit = "unit" if "unit" in info else "'stk'" - list_id = "COALESCE(list_id, ?)" if "list_id" in info else "?" - conn.execute("DROP INDEX IF EXISTS household_vorrat") - conn.execute("DROP INDEX IF EXISTS household_einkauf") - conn.execute("ALTER TABLE household_items RENAME TO household_items_alt") - conn.execute("CREATE TABLE household_items " + HOUSEHOLD_TABLE) - conn.execute( - f"""INSERT INTO household_items (id, key, name, list, list_id, quantity, unit, updated_at, updated_by) - SELECT id, key, name, list, {list_id}, quantity, {unit}, updated_at, updated_by FROM household_items_alt""", - (default,), - ) - conn.execute("DROP TABLE household_items_alt") - conn.execute("INSERT OR REPLACE INTO meta (key, value) VALUES ('haushalt_start', '1')") - - -def _migrate_checked(conn) -> None: - # Einkauf ohne Haken-Spalte: ergänzen, bestehende Einträge sind nicht abgehakt - columns = {r["name"] for r in conn.execute("PRAGMA table_info(household_items)")} - if "checked" not in columns: - conn.execute("ALTER TABLE household_items ADD COLUMN checked INTEGER NOT NULL DEFAULT 0") - - -def _migrate_travel(conn) -> None: - # Reise-Städte aus einer Vorversion ohne Höhe/Zeitzone/Hauptstadt: Spalten ergänzen (Hauptstadt füllt reisen.py nach) - columns = {r["name"] for r in conn.execute("PRAGMA table_info(travel_cities)")} - for column, kind in (("elevation", "REAL"), ("timezone", "TEXT"), ("capital", "INTEGER")): - if column not in columns: - conn.execute(f"ALTER TABLE travel_cities ADD COLUMN {column} {kind}") - - -def _migrate_kanban_categories(conn) -> None: - """Kategorien wieder entfernt: kanban_projects ohne category_id neu aufbauen. - - Aufgaben und Artikel-Links verweisen per ON DELETE CASCADE auf kanban_projects. Ein normales - DROP würde sie mitlöschen, deshalb das SQLite-Verfahren: Fremdschlüssel aus, neue Tabelle - anlegen, kopieren, alte löschen, umbenennen, Verweise prüfen. - """ - columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_projects)")} - if "category_id" not in columns: - conn.execute("DROP TABLE IF EXISTS kanban_categories") - return - conn.commit() - conn.execute("PRAGMA foreign_keys = OFF") # wirkt nur außerhalb einer Transaktion - try: - conn.execute("BEGIN") - conn.execute("CREATE TABLE kanban_projects_neu " + KANBAN_PROJECTS_TABLE) - conn.execute( - """INSERT INTO kanban_projects_neu (id, name, description, position, created_at, created_by, updated_at, updated_by) - SELECT id, name, description, position, created_at, created_by, updated_at, updated_by FROM kanban_projects""" - ) - conn.execute("DROP TABLE kanban_projects") - conn.execute("ALTER TABLE kanban_projects_neu RENAME TO kanban_projects") - conn.execute("DROP TABLE kanban_categories") - if conn.execute("PRAGMA foreign_key_check").fetchall(): - raise RuntimeError("Kanban-Migration: ungültige Verweise, Änderungen werden zurückgenommen.") - conn.commit() - except Exception: - conn.rollback() - raise - finally: - conn.execute("PRAGMA foreign_keys = ON") - - -def _migrate_kanban_priority(conn) -> None: - # Aufgaben ohne Priorität: Spalte ergänzen, bestehende Aufgaben werden "mittel" - columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_tasks)")} - if "priority" not in columns: - conn.execute( - "ALTER TABLE kanban_tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 2 CHECK (priority IN (1, 2, 3))" - ) - - -def _migrate_kanban_done(conn) -> None: - """Aufgaben ohne "erledigt seit": Spalte ergänzen. Schon erledigte Aufgaben zählen ab jetzt, - damit beim Update nichts sofort archiviert oder gelöscht wird.""" - columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_tasks)")} - if "done_at" not in columns: - conn.execute("ALTER TABLE kanban_tasks ADD COLUMN done_at TEXT") - conn.execute("UPDATE kanban_tasks SET done_at = ? WHERE status = 'erledigt'", (now_iso(),)) - - -def _migrate_monitor_token(conn) -> None: - # Monitore aus der Zeit vor P42-S.M.: Spalte für den Token ergänzen - columns = {r["name"] for r in conn.execute("PRAGMA table_info(monitors)")} - if "token" not in columns: - conn.execute("ALTER TABLE monitors ADD COLUMN token TEXT") - - -def _migrate_kanban_completed(conn) -> None: - # Projekte "abgeschlossen": Spalte ergänzen, falls sie fehlt - columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_projects)")} - if "completed_at" not in columns: - conn.execute("ALTER TABLE kanban_projects ADD COLUMN completed_at TEXT") - - -def _migrate_kanban(conn) -> None: - # Aufgaben aus der Zeit vor den Projekten: in "Allgemeines Board" übernehmen - columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_tasks)")} - if "project_id" in columns: - return - conn.execute("ALTER TABLE kanban_tasks RENAME TO kanban_tasks_alt") - conn.execute("CREATE TABLE kanban_tasks " + KANBAN_TASKS_TABLE) - if conn.execute("SELECT COUNT(*) FROM kanban_tasks_alt").fetchone()[0]: - now = now_iso() - project = conn.execute( - "INSERT INTO kanban_projects (name, position, created_at, updated_at) VALUES ('Allgemeines Board', 1, ?, ?)", - (now, now), - ).lastrowid - conn.execute( - """INSERT INTO kanban_tasks (id, project_id, title, description, status, assignee, position, - created_at, created_by, updated_at, updated_by) - SELECT id, ?, title, description, status, assignee, position, created_at, created_by, updated_at, updated_by - FROM kanban_tasks_alt""", - (project,), - ) - conn.execute("DROP TABLE kanban_tasks_alt") - - 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) - _migrate(conn) - _migrate_checked(conn) - _migrate_travel(conn) - _migrate_monitor_token(conn) - _migrate_kanban_categories(conn) - _migrate_kanban_completed(conn) - _migrate_kanban(conn) - _migrate_kanban_priority(conn) - _migrate_kanban_done(conn) + # 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) diff --git a/backend/app/migrations.py b/backend/app/migrations.py new file mode 100644 index 0000000..3304090 --- /dev/null +++ b/backend/app/migrations.py @@ -0,0 +1,181 @@ +"""Datenbank-Umstellungen mit Versionsnummer (PRAGMA user_version). + +SCHEMA in db.py beschreibt immer den neuesten Stand. Bei einer Änderung am Schema: + 1. SCHEMA in db.py anpassen – neue Datenbanken entstehen direkt so und bekommen die höchste Nummer. + 2. Unten an STEPS eine Funktion anhängen, die eine bestehende Datenbank vom vorigen Stand dorthin bringt. +Jeder Schritt läuft genau einmal, in einer Transaktion mit dem Hochzählen der Nummer. +Tests: tests/test_migrationen.py (alte Stände nachbauen und umstellen). +""" +from .db import HOUSEHOLD_TABLE, KANBAN_PROJECTS_TABLE, KANBAN_TASKS_TABLE, now_iso + + +def start(conn) -> None: + """Neue, leere Datenbank: erste Liste anlegen und gleich auf den neuesten Stand setzen.""" + conn.execute("INSERT INTO shopping_lists (name, everyone, position, created_at) VALUES ('Zuhause', 1, 1, ?)", (now_iso(),)) + conn.execute(f"PRAGMA user_version = {len(STEPS)}") + + +def upgrade(conn) -> None: + """Bestehende Datenbank: alle Schritte nach ihrer Nummer nacheinander ausführen.""" + version = conn.execute("PRAGMA user_version").fetchone()[0] + for number, step in enumerate(STEPS[version:], start=version + 1): + step(conn) + conn.execute(f"PRAGMA user_version = {number}") + + +# ---------- Schritt 1: Altbestand aus der Zeit vor der Versionsnummer ---------- +# Diese Umstellungen prüfen noch selbst, ob sie nötig sind – eine Datenbank ohne Nummer kann +# auf jedem früheren Stand sein. Reihenfolge wie früher beim Start. + + +def _v1_altbestand(conn) -> None: + _household(conn) + _household_checked(conn) + _travel_columns(conn) + _monitor_token(conn) + _kanban_without_categories(conn) + _kanban_completed(conn) + _kanban_projects(conn) + _kanban_priority(conn) + _kanban_done_at(conn) + + +def _household(conn) -> None: + """Vorrat/Einkauf aus früheren Versionen (ohne Einheiten, ohne Listen oder Vorrat ohne Liste) neu aufbauen. + + Alles, was noch keiner Liste gehört, kommt in die erste Liste bzw. in eine neue Liste "Zuhause", + die alle Konten sehen. + """ + info = {r["name"]: r for r in conn.execute("PRAGMA table_info(household_items)")} + if "list_id" in info and info["list_id"]["notnull"]: + return + row = conn.execute("SELECT id FROM shopping_lists ORDER BY position, id LIMIT 1").fetchone() + if row: + default = row["id"] + # Die Zwischenversion nannte die gemeinsame Liste "Einkaufsliste"; jetzt enthält sie auch den Vorrat + conn.execute("UPDATE shopping_lists SET name = 'Zuhause' WHERE id = ? AND name = 'Einkaufsliste' AND created_by IS NULL", (default,)) + else: + default = conn.execute( + "INSERT INTO shopping_lists (name, everyone, position, created_at) VALUES ('Zuhause', 1, 1, ?)", (now_iso(),) + ).lastrowid + unit = "unit" if "unit" in info else "'stk'" + list_id = "COALESCE(list_id, ?)" if "list_id" in info else "?" + conn.execute("DROP INDEX IF EXISTS household_vorrat") + conn.execute("DROP INDEX IF EXISTS household_einkauf") + conn.execute("ALTER TABLE household_items RENAME TO household_items_alt") + conn.execute("CREATE TABLE household_items " + HOUSEHOLD_TABLE) + conn.execute( + f"""INSERT INTO household_items (id, key, name, list, list_id, quantity, unit, updated_at, updated_by) + SELECT id, key, name, list, {list_id}, quantity, {unit}, updated_at, updated_by FROM household_items_alt""", + (default,), + ) + conn.execute("DROP TABLE household_items_alt") + + +def _household_checked(conn) -> None: + # Einkauf ohne Haken-Spalte: ergänzen, bestehende Einträge sind nicht abgehakt + columns = {r["name"] for r in conn.execute("PRAGMA table_info(household_items)")} + if "checked" not in columns: + conn.execute("ALTER TABLE household_items ADD COLUMN checked INTEGER NOT NULL DEFAULT 0") + + +def _travel_columns(conn) -> None: + # Reise-Städte aus einer Vorversion ohne Höhe/Zeitzone/Hauptstadt: Spalten ergänzen (Hauptstadt füllt reisen.py nach) + columns = {r["name"] for r in conn.execute("PRAGMA table_info(travel_cities)")} + for column, kind in (("elevation", "REAL"), ("timezone", "TEXT"), ("capital", "INTEGER")): + if column not in columns: + conn.execute(f"ALTER TABLE travel_cities ADD COLUMN {column} {kind}") + + +def _kanban_without_categories(conn) -> None: + """Kategorien wieder entfernt: kanban_projects ohne category_id neu aufbauen. + + Aufgaben und Artikel-Links verweisen per ON DELETE CASCADE auf kanban_projects. Ein normales + DROP würde sie mitlöschen, deshalb das SQLite-Verfahren: Fremdschlüssel aus, neue Tabelle + anlegen, kopieren, alte löschen, umbenennen, Verweise prüfen. + """ + columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_projects)")} + if "category_id" not in columns: + conn.execute("DROP TABLE IF EXISTS kanban_categories") + return + conn.commit() + conn.execute("PRAGMA foreign_keys = OFF") # wirkt nur außerhalb einer Transaktion + try: + conn.execute("BEGIN") + conn.execute("CREATE TABLE kanban_projects_neu " + KANBAN_PROJECTS_TABLE) + conn.execute( + """INSERT INTO kanban_projects_neu (id, name, description, position, created_at, created_by, updated_at, updated_by) + SELECT id, name, description, position, created_at, created_by, updated_at, updated_by FROM kanban_projects""" + ) + conn.execute("DROP TABLE kanban_projects") + conn.execute("ALTER TABLE kanban_projects_neu RENAME TO kanban_projects") + conn.execute("DROP TABLE kanban_categories") + if conn.execute("PRAGMA foreign_key_check").fetchall(): + raise RuntimeError("Kanban-Migration: ungültige Verweise, Änderungen werden zurückgenommen.") + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.execute("PRAGMA foreign_keys = ON") + + +def _kanban_priority(conn) -> None: + # Aufgaben ohne Priorität: Spalte ergänzen, bestehende Aufgaben werden "mittel" + columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_tasks)")} + if "priority" not in columns: + conn.execute( + "ALTER TABLE kanban_tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 2 CHECK (priority IN (1, 2, 3))" + ) + + +def _kanban_done_at(conn) -> None: + """Aufgaben ohne "erledigt seit": Spalte ergänzen. Schon erledigte Aufgaben zählen ab jetzt, + damit beim Update nichts sofort archiviert oder gelöscht wird.""" + columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_tasks)")} + if "done_at" not in columns: + conn.execute("ALTER TABLE kanban_tasks ADD COLUMN done_at TEXT") + conn.execute("UPDATE kanban_tasks SET done_at = ? WHERE status = 'erledigt'", (now_iso(),)) + + +def _monitor_token(conn) -> None: + # Monitore aus der Zeit vor P42-S.M.: Spalte für den Token ergänzen + columns = {r["name"] for r in conn.execute("PRAGMA table_info(monitors)")} + if "token" not in columns: + conn.execute("ALTER TABLE monitors ADD COLUMN token TEXT") + + +def _kanban_completed(conn) -> None: + # Projekte "abgeschlossen": Spalte ergänzen, falls sie fehlt + columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_projects)")} + if "completed_at" not in columns: + conn.execute("ALTER TABLE kanban_projects ADD COLUMN completed_at TEXT") + + +def _kanban_projects(conn) -> None: + # Aufgaben aus der Zeit vor den Projekten: in "Allgemeines Board" übernehmen + columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_tasks)")} + if "project_id" in columns: + return + conn.execute("ALTER TABLE kanban_tasks RENAME TO kanban_tasks_alt") + conn.execute("CREATE TABLE kanban_tasks " + KANBAN_TASKS_TABLE) + if conn.execute("SELECT COUNT(*) FROM kanban_tasks_alt").fetchone()[0]: + now = now_iso() + project = conn.execute( + "INSERT INTO kanban_projects (name, position, created_at, updated_at) VALUES ('Allgemeines Board', 1, ?, ?)", + (now, now), + ).lastrowid + conn.execute( + """INSERT INTO kanban_tasks (id, project_id, title, description, status, assignee, position, + created_at, created_by, updated_at, updated_by) + SELECT id, ?, title, description, status, assignee, position, created_at, created_by, updated_at, updated_by + FROM kanban_tasks_alt""", + (project,), + ) + conn.execute("DROP TABLE kanban_tasks_alt") + + +# Reihenfolge = Versionsnummer (Index + 1). Nur anhängen, nie umsortieren oder entfernen. +STEPS = [ + _v1_altbestand, +] diff --git a/backend/tests/test_migrationen.py b/backend/tests/test_migrationen.py index a49b772..0e9730b 100644 --- a/backend/tests/test_migrationen.py +++ b/backend/tests/test_migrationen.py @@ -136,3 +136,25 @@ def test_geloeschte_listen_kommen_nicht_zurueck(conn): conn.execute("DELETE FROM shopping_lists") db.init() assert conn.execute("SELECT COUNT(*) FROM shopping_lists").fetchone()[0] == 0 + + +def test_neue_datenbank_hat_neueste_version(conn): + from app import migrations + assert conn.execute("PRAGMA user_version").fetchone()[0] == len(migrations.STEPS) + + +def test_alte_datenbank_bekommt_neueste_version(old_db): + from app import migrations + conn = old_db(HAUSHALT_OHNE_LISTEN) + assert conn.execute("PRAGMA user_version").fetchone()[0] == len(migrations.STEPS) + + +def test_schritt_laeuft_nur_einmal(old_db, monkeypatch): + from app import migrations + calls = [] + conn = old_db(HAUSHALT_OHNE_LISTEN) + monkeypatch.setattr(migrations, "STEPS", [*migrations.STEPS, lambda c: calls.append(1)]) + db.init() + db.init() + assert calls == [1] + assert conn.execute("PRAGMA user_version").fetchone()[0] == len(migrations.STEPS)