# Spiele-Redaktion > **Deutsch:** [README.md](README.md) · **English:** [README.en.md](README.en.md) **KI-Assistenz für Spielemagazin-Redaktionen** — Multi-User-Webanwendung mit modularer Plugin-Architektur. Lauffähiger Kern mit Plugin-System, Authentifizierung/Rollen und Migrationen; vollständig implementiert sind bisher **Audit-Log**, **Benachrichtigung**, **Neuheiten (BGG-Sync)**, **Dedup-Prüfung**, **Planungsliste**, **Archiv (12-Monats-Autopilot)**, **Erinnerungen** und **Export (CSV/PDF)**. ## Stack - **Backend:** Python 3.11+, FastAPI, SQLAlchemy 2 (SQLite, Postgres-fähig) - **Frontend:** HTMX + Jinja2 + Tailwind (CDN) + Alpine.js — kein Build-Step - **Auth:** Session-Cookies (signiert), Passwort-Hashing mit Argon2id - **Plugins:** Entry-Points (`importlib.metadata`, Gruppe `spiele_redaktion.plugins`) plus lokales `plugins/`-Verzeichnis - **Hintergrund-Jobs:** APScheduler (BGG-Neuheiten-Sync) - **PDF-Export:** WeasyPrint (System-Bibliotheken: Pango/fontconfig) - **Dependencies/Tests:** [uv](https://docs.astral.sh/uv/) + pytest ## Setup ```bash uv sync # venv + Dependencies uv run uvicorn main:app --reload # Dev-Server auf http://127.0.0.1:8000 ``` Beim ersten Start wird automatisch ein Admin-Konto angelegt: | Benutzername | Passwort | Rolle | |--------------|----------|--------| | `admin` | `admin` | admin | > ⚠️ Für alles außer der lokalen Entwicklung vor dem Start > `SPIELE_INITIAL_ADMIN_PASSWORD` setzen und das Passwort nach dem ersten > Login ändern. ### Umgebungsvariablen | Variable | Default | Bedeutung | |----------|---------|-----------| | `SPIELE_DATABASE_URL` | `sqlite:///./data/spiele-redaktion.db` | SQLAlchemy-URL (Postgres: z. B. `postgresql+psycopg://…`) | | `SPIELE_SESSION_SECRET` | Dev-Wert | Secret für signierte Session-Cookies | | `SPIELE_SESSION_DAUER` | `43200` | Session-Dauer in Sekunden (12 h) | | `SPIELE_INITIAL_ADMIN_PASSWORD` | `admin` | Passwort des initialen Admins | | `SPIELE_PLUGINS_DIR` | `/plugins` | Pfad zum lokalen Plugin-Verzeichnis | | `SPIELE_SMTP_HOST` | *(leer)* | SMTP-Server für den E-Mail-Kanal; leer = Dev-Fallback (nur Protokoll) | | `SPIELE_SMTP_PORT` | `587` | SMTP-Port | | `SPIELE_SMTP_BENUTZER` | *(leer)* | SMTP-Login (optional) | | `SPIELE_SMTP_PASSWORT` | *(leer)* | SMTP-Passwort (optional) | | `SPIELE_SMTP_ABSENDER` | Benutzer bzw. `spiele-redaktion@localhost` | From-Adresse | | `SPIELE_SMTP_TLS` | `starttls` | `starttls`, `ssl` oder `keine` | | `SPIELE_TELEGRAM_BOT_TOKEN` | *(leer)* | Bot-Token für den Telegram-Kanal; leer = Dev-Fallback (nur Protokoll) | | `SPIELE_BGG_SYNC_AKTIV` | `1` | Hintergrund-Sync des Neuheiten-Plugins an (`1`) oder aus (`0`) | | `SPIELE_BGG_SYNC_INTERVALL_STUNDEN` | `24` | Intervall des BGG-Syncs in Stunden (min. 1) | | `SPIELE_BGG_SUCHBEGRIFFE` | `brettspiel` | Komma-getrennte Suchbegriffe für den regelmäßigen Sync | | `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `25` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) | | `SPIELE_BGG_TOKEN` | *(leer)* | API-Token für die BGG-XML-API2, wird als `Authorization: Bearer …`-Header gesendet; seit der Token-Pflicht von BGG erforderlich (siehe [BGG-Thread 3602374](https://boardgamegeek.com/thread/3602374)) — ohne Token wird der Sync übersprungen | | `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG-Zusatzdaten für die Dedup-Prüfung an (`1`) oder aus (`0`): Alternate-Names und Erweiterungs-Relationen | | `SPIELE_DEDUP_LLM_AKTIV` | `0` | LLM-Zweitprüfung der Dedup-Grenzfälle an (`1`) oder aus (`0`) | | `SPIELE_DEDUP_LLM_BASIS_URL` | *(leer)* | Basis-URL einer OpenAI-kompatiblen Chat-Completions-API, z. B. `https://api.openai.com/v1` | | `SPIELE_DEDUP_LLM_API_KEY` | *(leer)* | API-Key, wird als `Authorization: Bearer …` gesendet | | `SPIELE_DEDUP_LLM_MODELL` | *(leer)* | Modellname, z. B. `gpt-4o-mini` | | `SPIELE_DEDUP_LLM_KONFIDENZ_MIN` | `0.7` | Mindest-Konfidenz; darunter wird das LLM-Ergebnis verworfen und nur der Regelbefund angezeigt | | `SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN` | `20` | Timeout je LLM-Aufruf; Netzwerk-/Server-Fehler werden genau einmal wiederholt | | `SPIELE_ARCHIV_JOB_AKTIV` | `1` | Täglicher Archivierungs-Job an (`1`) oder aus (`0`) | | `SPIELE_ARCHIV_JOB_UHRZEIT` | `03:00` | Tageszeit des täglichen Archiv-Laufs im Format `HH:MM` | | `SPIELE_ERINNERUNG_JOB_AKTIV` | `1` | Täglicher Erinnerungs-Check an (`1`) oder aus (`0`) | | `SPIELE_ERINNERUNG_JOB_UHRZEIT` | `08:00` | Uhrzeit (HH:MM) des täglichen Erinnerungs-Checks | ## Tests ```bash uv run pytest ``` Abgedeckt: Plugin-Loader lädt alle Plugins (inkl. Lifecycle-Hooks und Entry-Point-Pfad), Migrations-Laufzeit inkl. Idempotenz, Login/Logout, Rollen-Zugriff (admin/redakteur vs. rezensent), Benutzerverwaltung sowie das Audit-Log-Plugin (Logging-Funktion, Migration, Filter, Paginierung, Nur-Admin-Zugriff) sowie das Neuheiten-Plugin: BGG-Parsing, Erweiterungs-/ Prototyp-Filter, Rate-Limit & Retry-Backoff (gemockte HTTP-Antworten, keine echten API-Calls), Update-statt-Duplikat-Logik, UI-Suche/Filter/Sortierung, Rollen am Sync-Endpunkt und Scheduler-Lifecycle. Dazu die beiden neuen Plugins: **dedup** (Titel-Heuristik, Fuzzy-Match, alle vier Prüfungen mit Fake-BGG-Client, Prüfprotokoll, Prüfseite) und **planung** (beide Eintragswege mit Stub-gemockter dedup-/Benachrichtigungs-/Audit-API, Verlags-Auswahl- Dialog, Statuswechsel, Bearbeiten/Löschen, Rechte pro Rolle) plus ein Integrationstest mit den echten Plugins (In-App-Nachricht + Audit-Eintrag). Dazu das **archiv**-Plugin: 12-Monats-Regel mit eingefrorener Uhr (Grenzfälle „genau 12 Monate“, Schaltjahr/29. Februar, UTC-Konsistenz bei Zeitzonen-Unterschieden), Job-Lauf mit Audit-Einträgen, Wiederherstellen inkl. BGG-Konflikt und Ersatz-Rezensent, Rollen sowie Suche/Filter in der Admin-Ansicht. Das Plugin **erinnerung** ist abgedeckt mit: Ausgaben-CRUD (nur Admin, mit Audit), Erinnerungslogik mit eingefrorener Uhr (freezegun) — 4-Wochen-Grenze (28 Tage exakt), kein Versand vor der Frist und nach dem Redaktionsschluss, Empfängerregeln (offene oder fehlende Planung, Rollen-Filter), Doppelschutz pro Ausgabe+Benutzer, manuelle Prüfung, Scheduler-Lifecycle/Uhrzeit-Konfiguration — wiederum plus ein Integrationstest mit den echten Plugins. Dazu das Plugin **export** (CSV-Inhalt mit Semikolon/BOM/Umlauten inkl. Quoting, gültige PDF-Dateien via Kopf-/Struktur-Check, Rollen-Zugriff je Liste und Format, Audit-Protokollierung der Downloads; PDF-Tests skippen sauber, wenn die WeasyPrint-System-Bibliotheken fehlen — ein Installationsversuch läuft zuvor automatisch) plus ein Integrationstest mit den echten Plugins., Bearbeiten/Löschen, Rechte pro Rolle), das Plugin **export** (CSV-Inhalt mit Semikolon/BOM/Umlauten inkl. Quoting, gültige PDF-Dateien via Kopf-/Struktur-Check, Rollen-Zugriff je Liste und Format, Audit-Protokollierung der Downloads; PDF-Tests skippen sauber, wenn die WeasyPrint-System-Bibliotheken fehlen — ein Installationsversuch läuft zuvor automatisch) plus ein Integrationstest mit den echten Plugins (In-App-Nachricht + Audit-Eintrag). ## Architektur ``` src/redaktionskern/ schlanker Kern — KEINE Fachlogik ├── app.py App-Fabrik: Discovery → Migrations → on_load → Routen ├── config.py Einstellungen (Umgebungsvariablen) ├── db.py Engine/Session (SQLite ↔ Postgres portabel) ├── migrationen.py Migrations-Laufzeit (Tabelle schema_migrations pro Plugin) ├── plugin_loader.py Entry-Points + plugins/-Verzeichnis, eindeutige Namen ├── contracts.py Plugin-Vertrag (BasePlugin, Migration, NavEntry, PluginContext) └── auth/ Login/Logout, Rollen, Benutzerverwaltung plugins/ ein Ordner pro Funktion, ladbar über den Plugin-Loader ├── audit-log/ VOLL IMPLEMENTIERT: Model, Migration, API, Admin-Ansicht ├── neuheiten/ VOLL IMPLEMENTIERT: BGG-Client, Sync-Job, Migration, UI ├── benachrichtigung/ VOLL IMPLEMENTIERT: E-Mail/Telegram/In-App ├── dedup/ VOLL IMPLEMENTIERT: check_titel-API, Heuristik, Protokoll ├── planung/ VOLL IMPLEMENTIERT: Planungsliste, Verschiebung, Dialoge ├── archiv/ VOLL IMPLEMENTIERT: 12-Monats-Autopilot, Job, UI ├── erinnerung/ VOLL IMPLEMENTIERT: Ausgaben, 4-Wochen-Erinnerung, Tages-Job ├── export/ VOLL IMPLEMENTIERT: CSV/PDF-Downloads, Rollen, Audit └── … je __init__.py + templates// ``` ### Der Plugin-Vertrag Ein Plugin ist ein Paketordner unter `plugins/` mit einem `__init__.py`, das ein Modulattribut `plugin` (Instanz einer `BasePlugin`-Unterklasse) bereitstellt. Alternativ: installiertes Paket mit Entry-Point in der Gruppe `spiele_redaktion.plugins`. ```python from fastapi import Depends, Request from redaktionskern.auth.deps import require_user from redaktionskern.contracts import BasePlugin, Migration, NavEntry class MeinPlugin(BasePlugin): name = "meinplugin" # eindeutig, = Ordnername title = "Mein Plugin" # deutscher Anzeigename description = "Was es tut." def migrations(self): # eigene Schema-Migrationen (optional) return [] def navigation(self): # Einträge in der Hauptnavigation (optional) return [NavEntry(label=self.title, url="/meinplugin")] def on_load(self, context): # Lifecycle-Hook beim Start super().on_load(context) # context.engine/.session_factory/.templates/ # .settings/.registry (Plugin-Registry für # Hintergrund-Jobs ohne Request) def on_unload(self): # Lifecycle-Hook beim Herunterfahren super().on_unload() plugin = MeinPlugin() ``` Der Kern stellt jedem Plugin über `context.templates` eine gemeinsame Jinja- Umgebung bereit (Plugin-Templates erben `base.html`), dazu Engine und Session-Fabrik. Migrationen laufen transaktional und werden pro Plugin in `schema_migrations` protokolliert; die `up`-Funktionen bekommen eine SQLAlchemy-Connection und können bei Dialekt-Unterschieden verzweigen. ## Plugin „Audit-Log“ (implementiert) Protokolliert, wer was wann verschoben, eingetragen oder geändert hat. ### Datentabelle Eigene Migration `0001_audit_eintraege`, Tabelle `audit_eintraege` (SQLite ↔ Postgres portabel): | Spalte | Inhalt | |--------|--------| | `actor_id` / `actor_name` | Wer (Benutzer-ID/-Name; `System` bei automatischen Ereignissen) | | `action` | Was (`erstellt`, `verschoben`, `geaendert`, `geloescht`, `benachrichtigt`) | | `objekt_typ` / `objekt_id` | Betroffenes Objekt (ID als Text) | | `alt` / `neu` | Alt-/Neustand als JSON (optional) | | `details` | Freie Zusatzinformationen als JSON (optional) | | `ip_adresse` | Herkunfts-IP (optional) | | `erstellt_am` | Zeitstempel | ### Öffentliche API für andere Plugins Andere Plugins rufen das Audit-Log über die Plugin-Registry bei jedem relevanten Ereignis auf: ```python audit = request.app.state.registry.get("audit-log") if audit is not None: await audit.log( # async-Variante user, # User-Objekt, Benutzername oder None (= System) "geaendert", # Aktion (Konstanten: AKTIONEN_ANZEIGE im Plugin) "planungseintrag", # Objekttyp eintrag.id, # Objekt-ID details={"alt": alt, "neu": neu}, # „alt“/„neu“ → JSON-Spalten, Rest → details ip_adresse=request.client.host, # optional ) ``` In synchronen Routen (`def`, FastAPI-Threadpool) steht `audit.log_sync(...)` mit denselben Argumenten bereit. ### Admin-Ansicht Unter `/audit-log` (nur Rolle **Admin**; Rezensenten/Redakteure erhalten 403, Anonyme werden zum Login umgeleitet): filterbar nach Benutzer, Aktionstyp und Zeitraum (Von/Bis), paginiert (25 Einträge pro Seite), neueste zuerst. ## Plugin „benachrichtigung“ Erstes fachlich umgesetztes Plugin (Adapter-Muster, drei Kanäle): | Kanal | Zustellung | Empfängerdaten | |-------|------------|----------------| | **E-Mail** | SMTP (`smtplib`, TLS: STARTTLS/SSL), konfigurierbar per Env | E-Mail-Adresse pro Benutzer | | **Telegram** | Bot-API (`sendMessage`) | Chat-ID pro Benutzer, Token zentral per Env | | **In-App** | Persistente Nachrichten im Portal mit Unread-Counter und „Alle als gelesen markieren“ | — | Ist ein Kanal installationsweit nicht konfiguriert (kein SMTP-Host bzw. kein Bot-Token — der Normalfall in der Entwicklung), protokolliert ein **Dev-Log-Adapter** die Nachricht auf dem Server, statt sie zu versenden; in der Entwicklung geht so keine Benachrichtigung verloren. Jeder Benutzer wählt unter **Benachrichtigungen → Kanäle & Einstellungen** seine Kanäle (mehrere gleichzeitig möglich) und hinterlegt E-Mail-Adresse bzw. Telegram-Chat-ID. Ohne gespeicherte Präferenz wird In-App zugestellt. ### Öffentliche Plugin-API für andere Plugins ```python plugin = request.app.state.registry.get("benachrichtigung") bericht = await plugin.send_notification(user, "Titel", "Nachrichtentext", "kategorie") # bericht.zugestellt -> z. B. ["email", "inapp"] # bericht.fehlgeschlagen -> Kanäle ohne Adresse/Fehler ``` `send_notification(user, titel, text, kategorie="allgemein")` wirft nicht; einzelne Kanalausfälle werden protokolliert und im Bericht gemeldet. Die In-App-Nachricht landet im Posteingang des Benutzers. ## Plugin „neuheiten“ (implementiert) Neuheitenliste auf Basis der **BoardGameGeek XML API2** (`https://boardgamegeek.com/xmlapi2`). Regelmäßiger Sync als Hintergrund-Job, manuelle Synchronisation für die Redaktion, deutsche sortier-/filterbare Liste. ### Datentabelle Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten` (SQLite ↔ Postgres portabel): | Spalte | Inhalt | |--------|--------| | `titel` | Spieltitel | | `verlag` / `autor` | Verlag bzw. Autor(en), kommagetrennt | | `erscheinungsjahr` | Erscheinungsjahr — BGG liefert nur das Jahr, kein genaues Datum | | `bgg_id` | BoardGameGeek-ID, eindeutig → Merge-Kriterium des Syncs | | `status` | `neuheit` (spätere Plugins: Planung/Archiv) | | `quelle` | Datenquelle (`boardgamegeek`) | | `erstellt_am` / `aktualisiert_am` | Zeitstempel | ### BGG-Client & Sync-Ablauf Pro Suchbegriff: `/search?type=boardgame&query=…` → IDs sammeln → `/thing?id=…&type=boardgame` in Batches à 20 IDs. - **Rate-Limit:** mindestens 1 Sekunde zwischen zwei Requests. - **Retry mit Backoff:** exponentielles Zurückhalten bei 5xx, 429 und Netzwerkfehlern; HTTP 202 (BGG-Warteschlange) wird gemäß `Retry-After` erneut versucht; kaputtes XML erzeugt eine klare Fehlermeldung. - **Filter:** - Erweiterungen (`type=boardgameexpansion`) werden auf Request-Ebene (`type=boardgame`) und zusätzlich pro Element ausgeschlossen. - Prototypen: Die XML API2 liefert keinen Prototyp-Marker; als Heuristik werden Titel mit Prototyp-Schlüsselwörtern („Prototyp“, „Prototype“, …) gefiltert. Restrisiko verbleibt — redaktionelle Prüfung bleibt wichtig. - **Update statt Duplikat:** bestehende Einträge werden über die eindeutige `bgg_id` aktualisiert (Titel/Verlag/Autor/Jahr), der `status` bleibt erhalten; neu angelegt wird nur bei unbekannter BGG-ID. ### Hintergrund-Job & manuelle Synchronisation Beim Start registriert das Plugin einen APScheduler-Job (Standard: alle 24 h, konfigurierbar über `SPIELE_BGG_SYNC_INTERVALL_STUNDEN`, abschaltbar über `SPIELE_BGG_SYNC_AKTIV=0`). Die Suchbegriffe kommen aus `SPIELE_BGG_SUCHBEGRIFFE` (komma-getrennt). Läuft ein Sync noch, wird ein überlappender Durchlauf übersprungen. Unter **Neuheiten → „Jetzt synchronisieren“** lösen Admins und Redakteure einen sofortigen Sync aus — optional mit einem Sofort-Suchbegriff für eine gezielte Recherche. Das Ergebnis (neu/aktualisiert/gefiltert) erscheint als Banner; der Endpunkt ist rollengeschützt (Rezensenten erhalten 403). **BGG-API-Token (Pflicht):** BoardGameGeek verlangt seit seiner Umstellung für die XML API2 eine Bearer-Authentifizierung ([Thread 3602374](https://boardgamegeek.com/thread/3602374)). Alle Requests des Plugins senden deshalb den Header `Authorization: Bearer `; der Token kommt aus `SPIELE_BGG_TOKEN`. Ist kein Token gesetzt, wird der Sync gar nicht erst gestartet und mit der Meldung „Kein BGG-API-Token konfiguriert (SPIELE_BGG_TOKEN) — Sync übersprungen.“ abgebrochen. Lehnt BGG eine Anfrage mit HTTP 401 ab (fehlender oder ungültiger Token), erscheint statt der generischen Fehlermeldung der Hinweis, einen gültigen API-Token in `SPIELE_BGG_TOKEN` zu hinterlegen. ### UI Die Seite `/neuheiten` zeigt alle Einträge als Tabelle (Spieltitel, Verlag, Autor, Erscheinungsjahr, Status, aktualisiert am): - Sortierung per Spaltenkopf, Suche über Titel/Verlag/Autor und Statusfilter — serverseitig umgesetzt, per HTMX ohne Seitenreload (ohne JavaScript als normaler Formular-GET nutzbar). - Jeder Titel verlinkt direkt auf den BGG-Eintrag. - Sync-Steuerung nur für Rolle Admin/Redakteur. ## Plugin „dedup“ (implementiert) Dedup-Prüfungen bei Import und händigem Eintrag. Die Prüf-Logik liegt komplett im Plugin; der Kern bleibt unberührt. ### Öffentliche Prüf-API für andere Plugins ```python dedup = request.app.state.registry.get("dedup") ergebnis = await dedup.check_titel(titel, verlag, bgg_id, user=user) # ergebnis.hat_konflikte -> bool # ergebnis.konflikte -> Liste TitelKonflikt (art, beschreibung, details) # ergebnis.verlags_optionen -> Verlage zur Auswahl bei Verlags-Konflikt # ergebnis.titel_empfehlung -> deutschester bekannter Titel # ergebnis.als_text() -> lesbare Zusammenfassung (deutsch) ``` ### Die vier Prüfungen | # | Prüfung | Mechanik | |---|---------|----------| | a | **Verlags-Konflikt** | Gleiches Spiel (BGG-ID oder Fuzzy-Match) bereits unter anderem Verlag/Vertrieb → Konflikt mit Verlagsauswahl | | b | **Titel-Varianten** | Deutsche Version wird bevorzugt — Heuristik über deutsche Titel-Indikatoren (Umlaute/ß, Funktionswörter, typische Spielbegriffe) plus BGG Alternate-Names, falls verfügbar | | c | **Bereits besprochen** | Abgeschlossene Planungseinträge bilden das Korpus; Vorgänger werden über die BGG `boardgameexpansion`-Relation erkannt und zusätzlich fuzzy auf Titel gematcht (rapidfuzz, Schwelle 85, `token_set_ratio`) | | d | **Schon in Planung** | Titel steht bereits in der Planungsliste eines anderen Rezensenten (eigene Einträge zählen nicht) | Fehlt eine Partner-Tabelle (Plugin nicht geladen), entfällt die jeweilige Teilprüfung. BGG-Störungen blockieren nie: der Hilfsclient degradiert auf die reine Heuristik und protokolliert nur. Abschaltbar über `SPIELE_DEDUP_BGG_AKTIV=0` (Standard: an, Rate-Limit 1 s). ### LLM-Zweitprüfung für Grenzfälle Regeln stoßen bei Editionen, Big Boxes, Übersetzungen und Verlags-Aliasen an Grenzen („El Grande Big Box“ vs. „El Grande: 25 Jahre“, „Catan – Das schnelle Spiel“ vs. „Catan Fast Edition“). Deshalb kann optional ein LLM als **zweite Stufe** eingeschaltet werden — die Regelprüfung bleibt maßgeblich: - **Nur Grenzfälle gehen ans LLM:** Titel mit gemeinsamen Wort-Tokens, aber Fuzzy-Score unter der Schwelle (max. 5 Paarungen je Prüfung), sowie ein ergänzender Call zum Verlags-/Titel-Hinweis bei einem regelbasierten Verlags-Konflikt. Klare Treffer und klare Non-Treffer kosten kein Token. - **Kontext:** beide Titel inkl. Alternate-Names (BGG-Sync), Verlage und ggf. BGG-Expansion-Relationen. - **Strukturierte Antwort** (JSON): `ist_gleiches_spiel`, `konfidenz`, `begruendung`, `empfohlener_verlag`, `deutscher_titel`. Bestätigte Treffer laufen in die üblichen Prüfungen a)–d) zurück; ein übernommener deutscher Titel füllt die Titel-Empfehlung, ein Verlags-Hinweis landet im Detail des Verlags-Konflikts. Unter der Mindest-Konfidenz (`SPIELE_DEDUP_LLM_KONFIDENZ_MIN`, Standard 0.7) wird das Ergebnis verworfen und nur der Regelbefund angezeigt. - **Fallback-Pflicht:** Standardmäßig aus (`SPIELE_DEDUP_LLM_AKTIV=0`). Ohne Key oder bei jedem Fehler (Timeout, Server-Störung nach genau einem Retry, kaputtes JSON) verhält sich die Prüfung exakt wie rein regelbasiert — das LLM kann die Prüfung nie blockieren. - **Audit:** Jede Prüfung mit LLM-Befunden schreibt einen Eintrag ins audit-log (`geprüft` / `dedup_ki`) mit Modell, Konfidenz und Entscheidung je bewertetem Grenzfall. Schnittstelle: OpenAI-kompatible Chat-Completions-API via httpx, konfiguriert über die `SPIELE_DEDUP_LLM_*`-Variablen (siehe Tabelle oben). ### Prüfprotokoll & Prüfseite Jede `check_titel`-Prüfung landet in der eigenen Migration `0001_pruefprotokoll` (Tabelle `dedup_pruefungen`: Titel, Verlag, BGG-ID, Befund als JSON, geprüft von, Zeitpunkt). Unter **Dedup-Prüfung** (`/dedup`) kann jeder angemeldete Benutzer einen Titel von Hand prüfen und sieht die letzten 20 Prüfungen. ## Plugin „planung“ (implementiert) Planungsliste mit Zuordnung an Rezensenten und Ausgaben. Beide Eintragswege laufen automatisch durch die dedup-Prüfung. ### Datentabelle Eigene Migration `0001_planungsliste`, Tabelle `planungsliste`: | Spalte | Inhalt | |--------|--------| | `titel` / `verlag` / `autor` | Spieldaten | | `bgg_id` | BoardGameGeek-ID (Match-Kriterium der dedup-Prüfung) | | `ausgabe` | Magazin-Ausgabe, z. B. „3/2025“ (mehrere parallel möglich) | | `rezensent_id` | Zuordnung (FK auf die Benutzer-Tabelle des Kerns) | | `status` | `offen` → `in_bearbeitung` → `abgeschlossen` | | `notizen` | Freitext | | `quelle` | `neuheiten` (verschoben) oder `manuell` (nachgetragen) | | `pruefung` | Befund der dedup-Prüfung beim Anlegen (JSON) | | `erstellt_am` / `aktualisiert_am` | Zeitstempel | ### Die beiden Eintragswege 1. **Verschiebung aus der Neuheitenliste** — Button „→ Zur Planung“ in der Neuheitenliste ruft `POST /planung/uebernehmen/` auf; der Neuheiten- Eintrag wechselt in den Status `planung`, der Planungseintrag wird als Quelle `neuheiten` angelegt. 2. **Händisches Nachtragen** — Formular auf der Planungsseite (`POST /planung/neu`, Quelle `manuell`). Bei einem Prüftreffer erhält der eintragende Rezensent eine **Benachrichtigung** (`send_notification` des benachrichtigung-Plugins, Kategorie `dedup`), und es wird ein **Audit-Log-Eintrag** geschrieben (`log` des audit-log-Plugins, Befund in `details.dedup_konflikte`). Bei einem **Verlags-Konflikt** erscheint zuerst der Auswahl-Dialog „Welcher Verlag soll geführt werden?“ — erst die bestätigte Wahl legt den Eintrag an; die Entscheidung ist auditiert. ### UI & Rechte Unter **Planung** (`/planung`) sieht jeder angemeldete Benutzer die komplette Liste mit Zuordnung (Rezensent, Ausgabe, Status, Prüfhinweis-Badge). Rezensenten können **nur ihre eigenen Einträge** bearbeiten (Statuswechsel, Bearbeiten, Löschen); Admins und Redakteure alle. Jede Änderung wird im Audit-Log protokolliert (`erstellt`/`verschoben`/`geaendert`/`geloescht`). ## Plugin „archiv“ (implementiert) Automatische Archivierung nach dem **Sicherungsdatei-Prinzip**: Titel, deren Erscheinungs- bzw. Eintragsdatum länger als 12 Monate zurückliegt, werden vollständig in eigene Archiv-Tabellen verschoben und aus den aktiven Listen entfernt. Nichts wird gelöscht oder verändert — jeder Eintrag lässt sich unverändert zurück in die aktive Liste verschieben. ### Datentabellen Eigene Migration `0001_archiv_tabellen` mit zwei Spiegel-Tabellen (SQLite ↔ Postgres portabel, ohne Fremdschlüssel — archivierte Zeilen überleben auch das Löschen eines Benutzers): | Tabelle | Spiegel von | Besondere Spalten | |---------|-------------|-------------------| | `archiv_neuheiten` | `neuheiten` | alle Originalspalten plus `quell_id`, `archiviert_am`, `archiviert_von`, `grund`, `referenz_am` | | `archiv_planung` | `planungsliste` | alle Originalspalten (inkl. `pruefung`-JSON) plus dieselben Archiv-Metadaten | ### Die 12-Monats-Regel - **Stichtag:** „jetzt − 12 Monate“ in UTC; archiviert wird, wer *streng älter* ist — genau 12 Monate gilt noch nicht als „älter als“. - **Referenzdatum:** - Neuheiten: das Erscheinungsdatum. BGG liefert nur das Jahr, daher großzügig das Jahresende (31.12., 23:59:59) — ein Titel von 2023 wird erst ab dem 01.01.2025 archiviert. Ohne Jahr zählt das Eintragsdatum. - Planungseinträge: das Eintragsdatum, unabhängig vom Status. - **Zeitzonen:** verglichen wird durchgängig in UTC; naive Datenbank- Zeitstempel gelten als UTC, zeitzonenbewusste Uhrzeiten werden nach UTC verschoben (per Tests mit eingefrorener Uhr abgedeckt, inkl. 29. Februar). ### Täglicher Hintergrund-Job Beim Start registriert das Plugin einen APScheduler-Cron-Job (Standard täglich **03:00**, konfigurierbar über `SPIELE_ARCHIV_JOB_UHRZEIT`, abschaltbar über `SPIELE_ARCHIV_JOB_AKTIV=0`). Pro archiviertem Titel schreibt der Job einen **Audit-Log-Eintrag** mit dem Akteur „System“ (Aktion `verschoben`, Grund und Referenzdatum in den Details). Der Registry-Zugriff aus dem Hintergrund-Job läuft über den neuen `context.registry`-Hook des Plugin-Kontexts. ### Admin-Ansicht mit Wiederherstellen Unter **Archiv** (`/archiv`, nur Rolle **Admin**) werden beide Tabellen als vereinigte Liste angezeigt (neueste Archivierung zuerst): - Suche über Titel/Verlag/Autor und Filter nach Herkunft (Neuheiten/Planung). - Pro Eintrag: Herkunfts-Badge, Status, Rezensent, Archivierungszeitpunkt und der Grund (z. B. „älter als 12 Monate (Erscheinungsjahr 2023)“). - **Wiederherstellen** verschiebt den Eintrag vollständig zurück in die aktive Liste und protokolliert dies im Audit-Log (Akteur: der Admin). Konfliktfälle werden sicher abgelehnt: existiert in der Neuheitenliste bereits ein aktiver Eintrag mit derselben BGG-ID (erneuter Sync), wird nicht wiederhergestellt; existiert der zugeordnete Rezensent nicht mehr, übernimmt der wiederherstellende Admin die Zuordnung (gemeldet und auditiert). Rezensenten und Redakteure erhalten 403. ## Plugin „erinnerung“ (implementiert) Redaktionsschluss pro Ausgabe mit automatischer 4-Wochen-Erinnerung an alle Rezensenten — mehrere Ausgaben laufen parallel. ### Datentabellen Eigene Migration `0001_ausgaben_und_protokoll`, zwei Tabellen (SQLite ↔ Postgres portabel): | Tabelle | Spalten | Zweck | |---------|---------|-------| | `erinnerung_ausgabe` | `name` (z. B. „3/2025“), `redaktionsschluss` (Datum), Zeitstempel | Eine Magazin-Ausgabe; Anlegen/Bearbeiten/Löschen nur für Rolle **Admin** | | `erinnerung_protokoll` | `ausgabe_id`, `user_id`, `kanaele`, `erstellt_am` | Jede versendete Erinnerung; **eindeutig** über (Ausgabe, Benutzer) → keine Doppelerinnerung | ### Erinnerungslogik - **Fenster:** 28 Tage (4 Wochen) vor dem Redaktionsschluss bis zum Redaktionsschluss selbst (jeweils inklusive). Davor und danach wird nicht erinnert. - **Empfänger:** alle aktiven Rezensenten, die noch offene Planungseinträge haben (Status ≠ „abgeschlossen“, also „offen“ oder „in Bearbeitung“) oder gar keine Einträge. Wer alles abgeschlossen hat, wird nicht erinnert; Admins und Redakteure nie. - **Versand:** über `send_notification` des benachrichtigung-Plugins (Kategorie `erinnerung`) — die Kanäle richten sich nach der Präferenz des Benutzers. Ohne Partner-Plugin wird kein Protokoll geschrieben, damit der nächste Lauf es erneut versucht. - **Doppelschutz:** pro (Ausgabe, Benutzer) wird genau einmal erinnert; der Versand steht in `erinnerung_protokoll`. Auch ein späteres Bearbeiten des Redaktionsschlusses löst keine zweite Erinnerung aus. - **Audit:** jede Erinnerung als `System`/`benachrichtigt` (Objekttyp `erinnerung`), jede Ausgaben-Änderung als `erstellt`/`geaendert`/ `geloescht` (Objekttyp `ausgabe`). ### Geplanter Job & manuelle Prüfung APScheduler prüft **täglich** (Standard 08:00 Uhr, konfigurierbar über `SPIELE_ERINNERUNG_JOB_UHRZEIT`, abschaltbar über `SPIELE_ERINNERUNG_JOB_AKTIV=0`). Unter **Erinnerungen → „Jetzt prüfen und erinnern“** führt ein Admin denselben Check sofort aus; das Ergebnis erscheint als Banner. Die Uhr liegt zentral in `dienst.heute()` — Tests frieren sie mit freezegun ein. ### UI Unter **Erinnerungen** (`/erinnerung`) sehen alle angemeldeten Benutzer die Ausgaben mit Redaktionsschluss und Restzeit-Badge. Admins verwalten zusätzlich die Ausgaben (anlegen/bearbeiten/löschen), starten den Sofort-Check und sehen den **Erinnerungsstatus**: wer wurde für welche Ausgabe wann über welche Kanäle erinnert. ## Plugin „export“ (implementiert) Redaktionslisten als Datei: die drei Listen **Neuheiten**, **Planung** und **Archiv** sind je als CSV und PDF unter **Export** (`/export`) herunterladbar. Keine eigene Datentabelle — das Plugin liest die Tabellen der Partner-Plugins über die gemeinsame SQLAlchemy-Metadata (ohne Plugin-Importe); fehlt ein Partner, erscheint die Liste als „nicht verfügbar“, statt zu brechen. ### Formate | Format | Details | |--------|---------| | **CSV** | stdlib `csv`, Semikolon-getrennt (Excel mit deutschem Gebietsschema), UTF-8 mit Byte-Order-Mark — Umlaute/ß bleiben erhalten, Felder mit `;` werden korrekt gequotet; Auslieferung als `StreamingResponse` (`text/csv; charset=utf-8`) | | **PDF** | WeasyPrint aus einem generierten HTML: A4-Tabelle mit deutscher Kopfzeile (wird auf jeder Seite wiederholt), Erstell-Datum und Anzahl Einträge im Kopf, Fußzeile „Seite X von Y“; Auslieferung als `StreamingResponse` (`application/pdf`) | WeasyPrint wird erst beim PDF-Download importiert: Ohne installierte System-Bibliotheken (Pango & Co.) bleiben App und CSV-Export voll nutzbar, der PDF-Download liefert eine verständliche Fehlermeldung. ### Spalten - **Neuheiten:** Titel · Verlag · Autor · Erscheinungsjahr · Status („Neuheit“, „In Planung“, „Archiviert“) · Aktualisiert am - **Planung:** Titel · Verlag · Autor · Ausgabe · Rezensent (aufgelöster Name) · Status (Offen / In Bearbeitung / Abgeschlossen) · Notizen · Aktualisiert am - **Archiv** = Neuheiten-Tabelle gefiltert auf Status „archiviert“ (gefüllt durch den Autopilot des archiv-Plugins) ### Rollen & Protokollierung Admins und Redakteure exportieren alle Listen; **Rezensenten nur die Planungsliste**. Die Prüfung passiert serverseitig an jedem Download-Endpunkt (`403` bei Zugriff ohne Berechtigung, `404` bei unbekannter Liste); auf der Export-Seite sind nicht erlaubte Karten ausgegraut. Jeder Download wird best effort ins Audit-Log geschrieben (Aktion „exportiert“, Format und Zeilenzahl in den Details). ## Deployment (später) Docker/Podman Compose ist vorgesehen (Henry-Lab, danach Kundenhardware). Für Postgres genügt `SPIELE_DATABASE_URL`; die Migrationen sind portabel geschrieben. ## Projekt-Fortschritt > Diese Tabelle ist der Live-Status. Sie wird bei jedem Push aktualisiert. | # | Baustein | Status | |---|----------|--------| | 1 | Kern: FastAPI, Plugin-System, DB/Migrationen, Auth/Rollen, HTMX-Layout | ✅ fertig (28 Tests grün) | | 2 | Plugin `benachrichtigung` (E-Mail/Telegram/In-App, User-Präferenzen) | ✅ fertig | | 3 | Plugin `audit-log` (Wer/Was/Wann, Admin-Ansicht, öffentliche API) | ✅ fertig | | 4 | Plugin `neuheiten` (BGG-Sync, APScheduler, Filter) | ✅ fertig | | 5 | Plugin `dedup` (Verlags-Konflikt, deutsche Version, Vorgänger-/Planungs-Check) | ✅ fertig | | 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ✅ fertig | | 7 | Plugin `archiv` (12-Monats-Autopilot, Wiederherstellen, täglicher Job) | ✅ fertig | | 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung, Tages-Job) | ✅ fertig | | 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ✅ fertig | | 10 | Integrationstests über alle Plugins | ✅ 217 Tests grün | | 11 | Deployment auf swen.henry.insight-it.de (Compose + Traefik, Live-Check) | ✅ live | Legende: ✅ fertig · 🔄 in Arbeit · ⏳ offen · ⚠️ fertig mit offenen Punkten ## Offene Punkte (nicht Teil von Phase 1) - CSRF-Schutz für Formulare (aktuell SameSite=Lax-Cookie als Basisschutz) - WeasyPrint-Systemabhängigkeiten im Container prüfen - Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets ## Lizenz Dieses Projekt steht unter der [MIT-Lizenz](LICENSE).