Files
p42.f-w/backend/app/db.py
T
guyg1474andClaude Opus 5.5 1d7497bead Reisen: Wohnorte mit Datum, Anreise je Besuch, Umzüge in der Kilometer-Rechnung
- Wohnorte als Liste mit "seit"-Datum (Tabelle travel_homes, ersetzt den
  einzelnen Wohnort; Migration Schritt 5 übernimmt ihn): Reisen starten
  beim damals gültigen Wohnort, Wechsel zählen als Umzug, Tage am
  Wohnort nicht als Reisetage
- "In dieser Zeit hier gewohnt" im Besuch: legt Wohnort ab Beginn und den
  vorherigen wieder ab Ende + 1 Tag an
- Anreise je Besuch (automatisch / von zuhause / vom vorherigen Ort):
  Rundreisen auch mit mehreren Tagen zwischen den Orten
- Fix: Besuche bleiben bearbeitbar, auch wenn eine Fotodatei fehlt;
  Speicherfehler erscheinen im Dialog statt verdeckt dahinter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:40:40 +02:00

381 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 '',
continued INTEGER, -- Anreise: 1 = vom vorherigen Ort weitergereist, 0 = von zuhause, NULL = automatisch
created_at TEXT NOT NULL
);
-- Wohnorte pro Konto mit "seit"-Datum (NULL = von Anfang an): Start und Ziel jeder Reise in der
-- Kilometer-Statistik; ein Wechsel (auch ein längerer Auslandsaufenthalt) zählt als Umzug
CREATE TABLE IF NOT EXISTS travel_homes (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
geoname_id INTEGER NOT NULL,
name TEXT NOT NULL,
country TEXT NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
since TEXT, -- JJJJ-MM-TT
UNIQUE (user_id, since)
);
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()