Files
spiele-redaktion/README.md
ox-alpha 56cc5943b1 Plugin neuheiten: BGG-Sync mit APScheduler, Filtern und deutscher UI
- Datenmodell + eigene Migration 0001_neuheiten_tabelle (Tabelle neuheiten:
  Titel, Verlag, Autor, Erscheinungsjahr, BGG-ID, Status 'neuheit', Quelle,
  Zeitstempel; bgg_id eindeutig als Merge-Kriterium)
- BoardGameGeek XML API2-Client (search + thing, Batches à 20 IDs):
  Rate-Limit >= 1 s zwischen Requests, Retry mit exponentiellem Backoff bei
  5xx/429/Netzwerkfehlern, HTTP 202 gemäß Retry-After, robustes XML-Parsing;
  Transport/Uhr/Sleep injizierbar (keine echten Calls in Tests)
- Filter: Erweiterungen (boardgameexpansion) auf Request- und Elementebene
  ausgeschlossen; Prototypen per Titel-Heuristik (BGG hat keinen Marker)
- Sync-Service mit Update-statt-Duplikat-Logik über die eindeutige BGG-ID
  (Status bleibt erhalten); Fehler je Suchbegriff brechen den Lauf nicht ab
- APScheduler-Hintergrundjob (Standard 24 h) mit Überlappungsschutz,
  abschaltbar/intervallkonfigurierbar per Env; manueller
  'Jetzt synchronisieren'-Endpunkt nur für Admin/Redakteur, optional mit
  Sofort-Suchbegriff
- UI /neuheiten: sortier-/filterbare Tabelle mit Volltextsuche (HTMX-Teilladung,
  noscript-fähig), deutsche Oberfläche, BGG-Links, Ergebnis-Banner
- Plugin-Loader: idempotentes Laden (Modul-Caching), damit mehrere
  create_app()-Aufrufe dieselben Plugin-Klassen/Tabellen nutzen
- Tests: Parsing, Erweiterungs-/Prototyp-Filter, Rate-Limit/Backoff/202,
  Update-statt-Duplikat, Rollen am Sync-Endpunkt, Scheduler-Lifecycle —
  ausschließlich mit gemockten BGG-Antworten (uv run pytest: 96 grün)
- README/AGENTS: Plugin-Doku, Env-Variablen, Fortschrittstabelle aktualisiert
2026-08-21 19:10:43 +00:00

14 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 und Neuheiten (BGG-Sync).

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)

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.

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/ planung/ 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.

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) 🔄 in Arbeit
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) offen
6 Plugin planung (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) offen
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