- Eine abhängige Aufgabe bleibt gesperrt (in "Offen"), bis jede Voraussetzung in "Wartet" oder "Erledigt" steht – vorher erst bei "Erledigt" - Server (UNLOCKING), Karte, Dialog und Meldungen angepasst; Test prüft "In Arbeit" (gesperrt) und "Wartet" (frei) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
366 lines
14 KiB
Python
366 lines
14 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
|
|
|
|
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,
|
|
category_order TEXT -- JSON-Liste der Kategorien in Laden-Reihenfolge; NULL = Standard
|
|
);
|
|
|
|
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: einheitliche Schreibweise ("milch" → "Milch")
|
|
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,
|
|
category TEXT -- Laden-Kategorie (kategorien.py); NULL = noch nicht zugeordnet
|
|
);
|
|
|
|
-- Läden einer Liste (z. B. Aldi, dm): Produkte lassen sich einem Laden zuordnen, die Einkaufsliste
|
|
-- gruppiert danach; jeder Laden kann eine eigene Reihenfolge der Abteilungen haben
|
|
CREATE TABLE IF NOT EXISTS stores (
|
|
id INTEGER PRIMARY KEY,
|
|
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
position REAL NOT NULL,
|
|
category_order TEXT -- JSON wie bei shopping_lists; NULL = Reihenfolge der Liste
|
|
);
|
|
|
|
-- In welchem Laden ein Produkt (pro Liste) gekauft wird
|
|
CREATE TABLE IF NOT EXISTS store_products (
|
|
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
|
|
key TEXT NOT NULL,
|
|
store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (list_id, key)
|
|
);
|
|
|
|
-- Mindestbestand: fällt der Vorrat einer Liste darunter, kommt das Produkt auf ihre Einkaufsliste
|
|
CREATE TABLE IF NOT EXISTS stock_minimums (
|
|
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
|
|
key TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
unit TEXT NOT NULL CHECK (unit IN ('stk', 'kg', 'l')),
|
|
minimum REAL NOT NULL CHECK (minimum > 0),
|
|
PRIMARY KEY (list_id, key, unit)
|
|
);
|
|
|
|
-- Gemerkte Rezepte (gemeinsam für alle Konten): Zutatenzeilen wie im Rezept, Abgleich passiert beim Abruf
|
|
CREATE TABLE IF NOT EXISTS recipes (
|
|
id INTEGER PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
source TEXT,
|
|
servings INTEGER,
|
|
lines TEXT NOT NULL, -- JSON-Liste der Zutatenzeilen
|
|
created_at TEXT NOT NULL,
|
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL
|
|
);
|
|
|
|
-- Welche Produkte in welcher Liste vorkamen: Vorschläge sieht man nur aus Listen, die man sehen darf
|
|
CREATE TABLE IF NOT EXISTS list_products (
|
|
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
|
|
key TEXT NOT NULL,
|
|
uses INTEGER NOT NULL DEFAULT 0,
|
|
last_used TEXT NOT NULL,
|
|
PRIMARY KEY (list_id, key)
|
|
);
|
|
|
|
-- 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
|
|
wish INTEGER NOT NULL DEFAULT 0, -- 1 = Wunschliste (noch nicht besucht)
|
|
created_at TEXT NOT NULL,
|
|
UNIQUE (user_id, geoname_id)
|
|
);
|
|
|
|
-- Besuche einer Stadt (nur für das eigene Konto sichtbar): Zeitraum, Begleitung, Notizen, Fotos
|
|
CREATE TABLE IF NOT EXISTS travel_visits (
|
|
id INTEGER PRIMARY KEY,
|
|
city_id INTEGER NOT NULL REFERENCES travel_cities(id) ON DELETE CASCADE,
|
|
date_from TEXT NOT NULL, -- JJJJ-MM-TT
|
|
date_to TEXT,
|
|
companions TEXT NOT NULL DEFAULT '',
|
|
notes TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS travel_visit_photos (
|
|
visit_id INTEGER NOT NULL REFERENCES travel_visits(id) ON DELETE CASCADE,
|
|
file TEXT NOT NULL, -- Datei in data/uploads (ohne Metadaten gespeichert)
|
|
position INTEGER NOT NULL,
|
|
PRIMARY KEY (visit_id, file)
|
|
);
|
|
|
|
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};
|
|
|
|
-- Checkliste innerhalb einer Aufgabe
|
|
CREATE TABLE IF NOT EXISTS kanban_checklist (
|
|
id INTEGER PRIMARY KEY,
|
|
task_id INTEGER NOT NULL REFERENCES kanban_tasks(id) ON DELETE CASCADE,
|
|
text TEXT NOT NULL,
|
|
done INTEGER NOT NULL DEFAULT 0,
|
|
position INTEGER NOT NULL
|
|
);
|
|
|
|
-- Abhängigkeiten: task_id ist gesperrt, bis requires_id in "Wartet" oder "Erledigt" steht (nur innerhalb eines Projekts)
|
|
CREATE TABLE IF NOT EXISTS kanban_dependencies (
|
|
task_id INTEGER NOT NULL REFERENCES kanban_tasks(id) ON DELETE CASCADE,
|
|
requires_id INTEGER NOT NULL REFERENCES kanban_tasks(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (task_id, requires_id),
|
|
CHECK (task_id != requires_id)
|
|
);
|
|
|
|
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"<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 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()
|