Migrationen versioniert: migrations.py mit PRAGMA user_version

- 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 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 18:09:12 +02:00
1 parent 79cbaab092
commit d28e6b2e4b
4 files changed
+214 -154

No files matched your search

+4 -2
View File
@@ -215,7 +215,8 @@ Quellen und Lizenzen: `docs/CREDITS.md`.
Alles liegt im Ordner `data/`: die Datenbank `p42.db` (Konten, Projekte, Links, Alles liegt im Ordner `data/`: die Datenbank `p42.db` (Konten, Projekte, Links,
Monitoring, Vorrat, Reisen, Kanban) und die Bilder in `uploads/`. Neue Tabellen und 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 den Ordner `data/` sichern. Ältere `projects.json`/`links.json` werden beim ersten Start
automatisch übernommen und danach in `*.importiert` umbenannt. automatisch übernommen und danach in `*.importiert` umbenannt.
@@ -232,7 +233,8 @@ backend/
requirements.txt requirements.txt
app/main.py FastAPI: API-Routen, Uploads, Auslieferung des Frontends app/main.py FastAPI: API-Routen, Uploads, Auslieferung des Frontends
app/config.py liest config.json 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/auth.py Konten, Anmeldung, Benutzerverwaltung
app/projects.py Projekte/Artikel, Bild-Uploads, HTML-Bereinigung app/projects.py Projekte/Artikel, Bild-Uploads, HTML-Bereinigung
app/links.py Schnellzugriff-Links pro Konto app/links.py Schnellzugriff-Links pro Konto
+7 -152
View File
@@ -265,163 +265,18 @@ def _import_legacy(conn) -> None:
links_json.rename(links_json.with_name("links.json.importiert")) 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: def init() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
with transaction() as conn: with transaction() as conn:
conn.execute("PRAGMA journal_mode = WAL") conn.execute("PRAGMA journal_mode = WAL")
new = conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'").fetchone()[0] == 0
conn.executescript(SCHEMA) conn.executescript(SCHEMA)
_migrate(conn) # Erst hier importieren: migrations.py nutzt die Tabellen-Definitionen aus diesem Modul
_migrate_checked(conn) from . import migrations
_migrate_travel(conn) if new:
_migrate_monitor_token(conn) migrations.start(conn)
_migrate_kanban_categories(conn) else:
_migrate_kanban_completed(conn) migrations.upgrade(conn)
_migrate_kanban(conn)
_migrate_kanban_priority(conn)
_migrate_kanban_done(conn)
_import_legacy(conn) _import_legacy(conn)
+181
View File
@@ -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,
]
+22
View File
@@ -136,3 +136,25 @@ def test_geloeschte_listen_kommen_nicht_zurueck(conn):
conn.execute("DELETE FROM shopping_lists") conn.execute("DELETE FROM shopping_lists")
db.init() db.init()
assert conn.execute("SELECT COUNT(*) FROM shopping_lists").fetchone()[0] == 0 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)