- 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
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, Gruppespiele_redaktion.plugins) plus lokalesplugins/-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_PASSWORDsetzen 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 |
|---|---|---|
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-Aftererneut 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.
- Erweiterungen (
- Update statt Duplikat: bestehende Einträge werden über die eindeutige
bgg_idaktualisiert (Titel/Verlag/Autor/Jahr), derstatusbleibt 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