- Uploads: Server entfernt EXIF/XMP/Text (GPS-Standort, Kamera, Uhrzeit) per Pillow; Drehung von Hochkant-Fotos wird vorher angewendet, Farbprofil bleibt; GIFs unverändert (neue Abhängigkeit pillow) - Produktvorschläge pro Liste (Tabelle list_products): Einträge privater Listen tauchen bei anderen weder in Vorschlägen noch beim Rezeptabgleich auf; "Vorschläge verwalten" löscht nur aus eigenen sichtbaren Listen - Artikel: Bilder nur noch aus den eigenen Uploads (kein Tracking-Pixel fremder Server); Bereinigung in htmlclean.py - /api/docs und /api/openapi.json nur noch im Entwicklungsmodus (RELOAD=1 oder P42_API_DOCS=1) - Migration Schritt 2: bestehende Vorschläge den Listen zuordnen, Artikel neu bereinigen, gespeicherte Bilder mit Metadaten säubern - Tests in test_datenschutz.py (u. a. Rezept-Zuordnung, Umstellung) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
289 lines
10 KiB
Python
289 lines
10 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
|
|
);
|
|
|
|
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
|
|
);
|
|
|
|
-- 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
|
|
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 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()
|