P42 Dashboard: erster Stand
FastAPI-Backend und React-Frontend mit Konten, Sendungsverfolgung, Projekt-Artikeln, Vorrat & Einkauf mit Rezeptabgleich, Reise-Weltkarte, Kanban-Projekten mit Priorität, Server-Monitoring und den Designs Matrix und Blackwall. Betrieb per Docker hinter nginx (siehe DEPLOY.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
dd58b3d1f8
75 files changed
+11418
No files matched your search
@@ -0,0 +1,337 @@
|
||||
"""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"
|
||||
|
||||
# Geteilte Vorrats- und Einkaufsliste; key = Name in casefold() für den Vergleich.
|
||||
# Pro Produkt, Liste 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')),
|
||||
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 (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,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
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 der Zeit vor den Einheiten: Tabelle mit Einheit und Kommazahlen neu aufbauen
|
||||
columns = {r["name"] for r in conn.execute("PRAGMA table_info(household_items)")}
|
||||
if "unit" in columns:
|
||||
return
|
||||
conn.execute("ALTER TABLE household_items RENAME TO household_items_alt")
|
||||
conn.execute("CREATE TABLE household_items " + HOUSEHOLD_TABLE)
|
||||
conn.execute(
|
||||
"""INSERT INTO household_items (id, key, name, list, quantity, unit, updated_at, updated_by)
|
||||
SELECT id, key, name, list, quantity, 'stk', updated_at, updated_by FROM household_items_alt"""
|
||||
)
|
||||
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_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_kanban_categories(conn)
|
||||
_migrate_kanban_completed(conn)
|
||||
_migrate_kanban(conn)
|
||||
_migrate_kanban_priority(conn)
|
||||
_import_legacy(conn)
|
||||
|
||||
|
||||
init()
|
||||
Reference in new issue
Block a user