Files
spiele-redaktion/README.md

30 KiB
Raw Blame History

Spiele-Redaktion

Deutsch: README.md · English: README.en.md

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, Gruppe spiele_redaktion.plugins) plus lokales plugins/-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_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)
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
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).

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 offenin_bearbeitungabgeschlossen
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

  1. Verschiebung aus der Neuheitenliste — Button „→ Zur Planung“ in der Neuheitenliste ruft POST /planung/uebernehmen/<id> auf; der Neuheiten- Eintrag wechselt in den Status planung, der Planungseintrag wird als Quelle neuheiten angelegt.
  2. Händisches Nachtragen — Formular auf der Planungsseite (POST /planung/neu, Quelle manuell).

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_notification des benachrichtigung-Plugins (Kategorie erinnerung) — 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 (Objekttyp erinnerung), jede Ausgaben-Änderung als erstellt/geaendert/ geloescht (Objekttyp ausgabe).

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)
PDF 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

Lizenz

Dieses Projekt steht unter der MIT-Lizenz.