# 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](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) | ## 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. ## 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// ``` ### 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 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). ### 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