Files
spiele-redaktion/README.md
Flo Hartmann 0f12cd952b Plugins dedup + planung: Prüf-API, Verzahnung mit Benachrichtigung und Audit-Log
Plugin dedup:
- Öffentliche Prüf-API check_titel(titel, verlag, bgg_id, user) mit
  strukturiertem PruefErgebnis (Konflikte, Verlagsauswahl, Titel-Empfehlung)
- Vier Prüfungen: Verlags-Konflikt, Titel-Varianten (deutsche Version
  bevorzugt: Umlaut-/Wort-Heuristik + BGG Alternate-Names), Spiel oder
  Vorgänger bereits besprochen (BGG boardgameexpansion-Relation +
  rapidfuzz-Fuzzy-Match), Titel in fremder Planungsliste
- BGG-Hilfsclient mit Rate-Limit, netzwerkfrei testbar, degradiert defensiv
- Eigene Migration (Prüfprotokoll dedup_pruefungen) + Prüfseite /dedup
- Fremde Plugin-Tabellen werden nur lesend über die gemeinsame Metadata
  gelesen — keine Import-Abhängigkeiten zwischen Plugins

Plugin planung:
- Migration planungsliste: Titel, Verlag, Ausgabe, Rezensent, Status
  (offen/in_bearbeitung/abgeschlossen), Quelle, Prüf-Befund (JSON)
- Beide Eintragswege durch die dedup-Prüfung: Verschiebung aus den
  Neuheiten (Button „→ Zur Planung“, Neuheit wechselt auf Status planung)
  und händisches Nachtragen im Formular
- Bei Treffern: send_notification an den eintragenden Rezensenten
  (benachrichtigung-Plugin) + Audit-Log-Eintrag (audit-log-Plugin)
- Verlags-Konflikt → Auswahl-Dialog „Welcher Verlag wird geführt?“,
  Entscheidung wird auditiert
- Planungsliste mit Zuordnung, Statuswechsel, Bearbeiten/Löschen;
  Rezensenten nur eigene Einträge, Admin/Redakteur alle

Tests: 45 neue Tests (Heuristik, alle vier Prüfungen mit Fake-BGG-Client,
Protokoll, beide Eintragswege mit Stub-gemockten Abhängigkeiten, Rollen,
Integration mit echten Plugins) — 141 Tests grün.
2026-08-21 19:51:53 +00:00

19 KiB

Spiele-Redaktion

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 und Planungsliste.

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)
  • Dependencies/Tests: uv + pytest

Setup

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 <Projekt>/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_DEDUP_BGG_AKTIV 1 BGG-Zusatzdaten für die Dedup-Prüfung an (1) oder aus (0): Alternate-Names und Erweiterungs-Relationen

Tests

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).

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/ erinnerung/ export/   (Stubs, ladbar)
└── …                       je __init__.py + templates/<name>/

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.

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

    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:

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

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).

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

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).

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 offenin_bearbeitungabgeschlossen
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/<id> 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).

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 (141 Tests grün)
6 Plugin planung (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) fertig (141 Tests grün)
7 Plugin archiv (12-Monats-Autopilot) offen
8 Plugin erinnerung (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung) offen
9 Plugin export (CSV + PDF via WeasyPrint) offen
10 Integrationstests über alle Plugins offen
11 Deployment auf swen.henry.insight-it.de (Compose + Traefik, Live-Check) offen

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)
  • Hintergrund-Jobs (APScheduler) für Erinnerungen
  • WeasyPrint für PDF-Export (Systemabhängigkeiten im Container)
  • Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets