- 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
304 lines
14 KiB
Markdown
304 lines
14 KiB
Markdown
# 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` | `<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
|
|
|
|
```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/<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`.
|
|
|
|
```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
|