Vorrat & Einkauf: - Mehrere Listen (z. B. "Zuhause", "Urlaub"), jede mit eigenem Vorrat und eigener Einkaufsliste; Auswahl oben auf der Seite, gemerkt pro Browser - Beim Anlegen festlegen, wer die Liste sieht: alle Konten oder nur ausgewählte (Ersteller sieht sie immer); ändern/löschen nur Ersteller und Administratoren - Gekauft, aufgebraucht, Nachkaufen und Rezeptabgleich wirken innerhalb der gewählten Liste - Migration: bisheriger Vorrat und Einkauf werden zur Liste "Zuhause" (für alle sichtbar) Reisen: - Schalter "Städte anderer zeigen": Städte der übrigen Konten in je einer eigenen Farbe, einzelne Personen ein-/ausblendbar, nur lesend - Info-Karte zeigt, wer noch in einer Stadt bzw. einem Land war Design "Fabrik" (Industrie-Inventar à la Satisfactory): - Stahlplatten mit Schrauben, Titelleisten mit Fenstersymbol, gelb leuchtende Hauptbuttons, orange Mengen-Zähler, Warnstreifen, Riffelblech und drehende Zahnräder im Hintergrund - Schriften Roboto / Roboto Condensed (OFL), lokal eingebunden Doku: README verlinkt das zugehörige Repository P42-S.M. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
400 lines
15 KiB
Python
400 lines
15 KiB
Python
"""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, timezone
|
|
|
|
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')),
|
|
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,
|
|
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,
|
|
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 now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
|
|
|
|
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"<h3>{date}</h3>")
|
|
for para in filter(None, re.split(r"\n{2,}", post.get("text") or "")):
|
|
parts.append(f"<p>{html.escape(para).replace(chr(10), '<br>')}</p>")
|
|
parts += [f'<p><img src="/uploads/{img}" alt=""></p>' 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 _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"]:
|
|
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 _migrate_travel(conn) -> None:
|
|
# Reise-Städte aus einer Vorversion ohne Höhe/Zeitzone: Spalten ergänzen
|
|
columns = {r["name"] for r in conn.execute("PRAGMA table_info(travel_cities)")}
|
|
for column, kind in (("elevation", "REAL"), ("timezone", "TEXT")):
|
|
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_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")
|
|
conn.executescript(SCHEMA)
|
|
_migrate(conn)
|
|
_migrate_travel(conn)
|
|
_migrate_monitor_token(conn)
|
|
_migrate_kanban_categories(conn)
|
|
_migrate_kanban_completed(conn)
|
|
_migrate_kanban(conn)
|
|
_migrate_kanban_priority(conn)
|
|
_import_legacy(conn)
|
|
|
|
|
|
init()
|