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, Planungsliste, Archiv (12-Monats-Autopilot), Erinnerungen und Export (CSV/PDF).
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)
- PDF-Export: WeasyPrint (System-Bibliotheken: Pango/fontconfig)
- 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) |
SPIELE_BGG_TOKEN |
(leer) | API-Token für die BGG-XML-API2, wird als Authorization: Bearer …-Header gesendet; seit der Token-Pflicht von BGG erforderlich (siehe BGG-Thread 3602374) — ohne Token wird der Sync übersprungen |
SPIELE_DEDUP_BGG_AKTIV |
1 |
BGG-Zusatzdaten für die Dedup-Prüfung an (1) oder aus (0): Alternate-Names und Erweiterungs-Relationen |
SPIELE_ARCHIV_JOB_AKTIV |
1 |
Täglicher Archivierungs-Job an (1) oder aus (0) |
SPIELE_ARCHIV_JOB_UHRZEIT |
03:00 |
Tageszeit des täglichen Archiv-Laufs im Format HH:MM |
SPIELE_ERINNERUNG_JOB_AKTIV |
1 |
Täglicher Erinnerungs-Check an (1) oder aus (0) |
SPIELE_ERINNERUNG_JOB_UHRZEIT |
08:00 |
Uhrzeit (HH:MM) des täglichen Erinnerungs-Checks |
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). Dazu das archiv-Plugin: 12-Monats-Regel mit eingefrorener Uhr (Grenzfälle „genau 12 Monate“, Schaltjahr/29. Februar, UTC-Konsistenz bei Zeitzonen-Unterschieden), Job-Lauf mit Audit-Einträgen, Wiederherstellen inkl. BGG-Konflikt und Ersatz-Rezensent, Rollen sowie Suche/Filter in der Admin-Ansicht.
Das Plugin erinnerung ist abgedeckt mit: Ausgaben-CRUD (nur Admin, mit Audit), Erinnerungslogik mit eingefrorener Uhr (freezegun) — 4-Wochen-Grenze (28 Tage exakt), kein Versand vor der Frist und nach dem Redaktionsschluss, Empfängerregeln (offene oder fehlende Planung, Rollen-Filter), Doppelschutz pro Ausgabe+Benutzer, manuelle Prüfung, Scheduler-Lifecycle/Uhrzeit-Konfiguration — wiederum plus ein Integrationstest mit den echten Plugins. Dazu das Plugin export (CSV-Inhalt mit Semikolon/BOM/Umlauten inkl. Quoting, gültige PDF-Dateien via Kopf-/Struktur-Check, Rollen-Zugriff je Liste und Format, Audit-Protokollierung der Downloads; PDF-Tests skippen sauber, wenn die WeasyPrint-System-Bibliotheken fehlen — ein Installationsversuch läuft zuvor automatisch) plus ein Integrationstest mit den echten Plugins., Bearbeiten/Löschen, Rechte pro Rolle), das Plugin export (CSV-Inhalt mit Semikolon/BOM/Umlauten inkl. Quoting, gültige PDF-Dateien via Kopf-/Struktur-Check, Rollen-Zugriff je Liste und Format, Audit-Protokollierung der Downloads; PDF-Tests skippen sauber, wenn die WeasyPrint-System-Bibliotheken fehlen — ein Installationsversuch läuft zuvor automatisch) 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/ VOLL IMPLEMENTIERT: 12-Monats-Autopilot, Job, UI
├── erinnerung/ VOLL IMPLEMENTIERT: Ausgaben, 4-Wochen-Erinnerung, Tages-Job
├── export/ VOLL IMPLEMENTIERT: CSV/PDF-Downloads, Rollen, Audit
└── … 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/.registry (Plugin-Registry für
# Hintergrund-Jobs ohne Request)
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).
BGG-API-Token (Pflicht): BoardGameGeek verlangt seit seiner Umstellung
für die XML API2 eine Bearer-Authentifizierung (Thread
3602374). Alle Requests des
Plugins senden deshalb den Header Authorization: Bearer <Token>; der Token
kommt aus SPIELE_BGG_TOKEN. Ist kein Token gesetzt, wird der Sync gar nicht
erst gestartet und mit der Meldung „Kein BGG-API-Token konfiguriert
(SPIELE_BGG_TOKEN) — Sync übersprungen.“ abgebrochen. Lehnt BGG eine Anfrage
mit HTTP 401 ab (fehlender oder ungültiger Token), erscheint statt der
generischen Fehlermeldung der Hinweis, einen gültigen API-Token in
SPIELE_BGG_TOKEN zu hinterlegen.
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 |
offen → in_bearbeitung → abgeschlossen |
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
- Verschiebung aus der Neuheitenliste — Button „→ Zur Planung“ in der
Neuheitenliste ruft
POST /planung/uebernehmen/<id>auf; der Neuheiten- Eintrag wechselt in den Statusplanung, der Planungseintrag wird als Quelleneuheitenangelegt. - Händisches Nachtragen — Formular auf der Planungsseite
(
POST /planung/neu, Quellemanuell).
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).
Plugin „archiv“ (implementiert)
Automatische Archivierung nach dem Sicherungsdatei-Prinzip: Titel, deren Erscheinungs- bzw. Eintragsdatum länger als 12 Monate zurückliegt, werden vollständig in eigene Archiv-Tabellen verschoben und aus den aktiven Listen entfernt. Nichts wird gelöscht oder verändert — jeder Eintrag lässt sich unverändert zurück in die aktive Liste verschieben.
Datentabellen
Eigene Migration 0001_archiv_tabellen mit zwei Spiegel-Tabellen
(SQLite ↔ Postgres portabel, ohne Fremdschlüssel — archivierte Zeilen
überleben auch das Löschen eines Benutzers):
| Tabelle | Spiegel von | Besondere Spalten |
|---|---|---|
archiv_neuheiten |
neuheiten |
alle Originalspalten plus quell_id, archiviert_am, archiviert_von, grund, referenz_am |
archiv_planung |
planungsliste |
alle Originalspalten (inkl. pruefung-JSON) plus dieselben Archiv-Metadaten |
Die 12-Monats-Regel
- Stichtag: „jetzt − 12 Monate“ in UTC; archiviert wird, wer streng älter ist — genau 12 Monate gilt noch nicht als „älter als“.
- Referenzdatum:
- Neuheiten: das Erscheinungsdatum. BGG liefert nur das Jahr, daher großzügig das Jahresende (31.12., 23:59:59) — ein Titel von 2023 wird erst ab dem 01.01.2025 archiviert. Ohne Jahr zählt das Eintragsdatum.
- Planungseinträge: das Eintragsdatum, unabhängig vom Status.
- Zeitzonen: verglichen wird durchgängig in UTC; naive Datenbank- Zeitstempel gelten als UTC, zeitzonenbewusste Uhrzeiten werden nach UTC verschoben (per Tests mit eingefrorener Uhr abgedeckt, inkl. 29. Februar).
Täglicher Hintergrund-Job
Beim Start registriert das Plugin einen APScheduler-Cron-Job (Standard
täglich 03:00, konfigurierbar über SPIELE_ARCHIV_JOB_UHRZEIT,
abschaltbar über SPIELE_ARCHIV_JOB_AKTIV=0). Pro archiviertem Titel
schreibt der Job einen Audit-Log-Eintrag mit dem Akteur „System“
(Aktion verschoben, Grund und Referenzdatum in den Details). Der
Registry-Zugriff aus dem Hintergrund-Job läuft über den neuen
context.registry-Hook des Plugin-Kontexts.
Admin-Ansicht mit Wiederherstellen
Unter Archiv (/archiv, nur Rolle Admin) werden beide Tabellen als
vereinigte Liste angezeigt (neueste Archivierung zuerst):
- Suche über Titel/Verlag/Autor und Filter nach Herkunft (Neuheiten/Planung).
- Pro Eintrag: Herkunfts-Badge, Status, Rezensent, Archivierungszeitpunkt und der Grund (z. B. „älter als 12 Monate (Erscheinungsjahr 2023)“).
- Wiederherstellen verschiebt den Eintrag vollständig zurück in die aktive Liste und protokolliert dies im Audit-Log (Akteur: der Admin). Konfliktfälle werden sicher abgelehnt: existiert in der Neuheitenliste bereits ein aktiver Eintrag mit derselben BGG-ID (erneuter Sync), wird nicht wiederhergestellt; existiert der zugeordnete Rezensent nicht mehr, übernimmt der wiederherstellende Admin die Zuordnung (gemeldet und auditiert). Rezensenten und Redakteure erhalten 403.
Plugin „erinnerung“ (implementiert)
Redaktionsschluss pro Ausgabe mit automatischer 4-Wochen-Erinnerung an alle Rezensenten — mehrere Ausgaben laufen parallel.
Datentabellen
Eigene Migration 0001_ausgaben_und_protokoll, zwei Tabellen
(SQLite ↔ Postgres portabel):
| Tabelle | Spalten | Zweck |
|---|---|---|
erinnerung_ausgabe |
name (z. B. „3/2025“), redaktionsschluss (Datum), Zeitstempel |
Eine Magazin-Ausgabe; Anlegen/Bearbeiten/Löschen nur für Rolle Admin |
erinnerung_protokoll |
ausgabe_id, user_id, kanaele, erstellt_am |
Jede versendete Erinnerung; eindeutig über (Ausgabe, Benutzer) → keine Doppelerinnerung |
Erinnerungslogik
- Fenster: 28 Tage (4 Wochen) vor dem Redaktionsschluss bis zum Redaktionsschluss selbst (jeweils inklusive). Davor und danach wird nicht erinnert.
- Empfänger: alle aktiven Rezensenten, die noch offene Planungseinträge haben (Status ≠ „abgeschlossen“, also „offen“ oder „in Bearbeitung“) oder gar keine Einträge. Wer alles abgeschlossen hat, wird nicht erinnert; Admins und Redakteure nie.
- Versand: über
send_notificationdes benachrichtigung-Plugins (Kategorieerinnerung) — die Kanäle richten sich nach der Präferenz des Benutzers. Ohne Partner-Plugin wird kein Protokoll geschrieben, damit der nächste Lauf es erneut versucht. - Doppelschutz: pro (Ausgabe, Benutzer) wird genau einmal erinnert; der
Versand steht in
erinnerung_protokoll. Auch ein späteres Bearbeiten des Redaktionsschlusses löst keine zweite Erinnerung aus. - Audit: jede Erinnerung als
System/benachrichtigt(Objekttyperinnerung), jede Ausgaben-Änderung alserstellt/geaendert/geloescht(Objekttypausgabe).
Geplanter Job & manuelle Prüfung
APScheduler prüft täglich (Standard 08:00 Uhr, konfigurierbar über
SPIELE_ERINNERUNG_JOB_UHRZEIT, abschaltbar über
SPIELE_ERINNERUNG_JOB_AKTIV=0). Unter Erinnerungen → „Jetzt prüfen und
erinnern“ führt ein Admin denselben Check sofort aus; das Ergebnis
erscheint als Banner. Die Uhr liegt zentral in dienst.heute() — Tests
frieren sie mit freezegun ein.
UI
Unter Erinnerungen (/erinnerung) sehen alle angemeldeten Benutzer die
Ausgaben mit Redaktionsschluss und Restzeit-Badge. Admins verwalten zusätzlich
die Ausgaben (anlegen/bearbeiten/löschen), starten den Sofort-Check und
sehen den Erinnerungsstatus: wer wurde für welche Ausgabe wann über
welche Kanäle erinnert.
Plugin „export“ (implementiert)
Redaktionslisten als Datei: die drei Listen Neuheiten, Planung und
Archiv sind je als CSV und PDF unter Export (/export) herunterladbar.
Keine eigene Datentabelle — das Plugin liest die Tabellen der Partner-Plugins
über die gemeinsame SQLAlchemy-Metadata (ohne Plugin-Importe); fehlt ein
Partner, erscheint die Liste als „nicht verfügbar“, statt zu brechen.
Formate
| Format | Details |
|---|---|
| CSV | stdlib csv, Semikolon-getrennt (Excel mit deutschem Gebietsschema), UTF-8 mit Byte-Order-Mark — Umlaute/ß bleiben erhalten, Felder mit ; werden korrekt gequotet; Auslieferung als StreamingResponse (text/csv; charset=utf-8) |
WeasyPrint aus einem generierten HTML: A4-Tabelle mit deutscher Kopfzeile (wird auf jeder Seite wiederholt), Erstell-Datum und Anzahl Einträge im Kopf, Fußzeile „Seite X von Y“; Auslieferung als StreamingResponse (application/pdf) |
WeasyPrint wird erst beim PDF-Download importiert: Ohne installierte System-Bibliotheken (Pango & Co.) bleiben App und CSV-Export voll nutzbar, der PDF-Download liefert eine verständliche Fehlermeldung.
Spalten
- Neuheiten: Titel · Verlag · Autor · Erscheinungsjahr · Status („Neuheit“, „In Planung“, „Archiviert“) · Aktualisiert am
- Planung: Titel · Verlag · Autor · Ausgabe · Rezensent (aufgelöster Name) · Status (Offen / In Bearbeitung / Abgeschlossen) · Notizen · Aktualisiert am
- Archiv = Neuheiten-Tabelle gefiltert auf Status „archiviert“ (gefüllt durch den Autopilot des archiv-Plugins)
Rollen & Protokollierung
Admins und Redakteure exportieren alle Listen; Rezensenten nur die
Planungsliste. Die Prüfung passiert serverseitig an jedem Download-Endpunkt
(403 bei Zugriff ohne Berechtigung, 404 bei unbekannter Liste); auf der
Export-Seite sind nicht erlaubte Karten ausgegraut. Jeder Download wird best
effort ins Audit-Log geschrieben (Aktion „exportiert“, Format und Zeilenzahl
in den Details).
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 |
| 6 | Plugin planung (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) |
✅ fertig |
| 7 | Plugin archiv (12-Monats-Autopilot, Wiederherstellen, täglicher Job) |
✅ fertig |
| 8 | Plugin erinnerung (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung, Tages-Job) |
✅ fertig |
| 9 | Plugin export (CSV + PDF via WeasyPrint) |
✅ fertig |
| 10 | Integrationstests über alle Plugins | ✅ 217 Tests grün |
| 11 | Deployment auf swen.henry.insight-it.de (Compose + Traefik, Live-Check) | ✅ live |
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)
- WeasyPrint-Systemabhängigkeiten im Container prüfen
- Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets