Files
spiele-redaktion/README.md
Flo Hartmann 469cf8826e Plugin erinnerung: Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung
- Ausgaben (Name + Redaktionsschluss-Datum) verwalten, mehrere parallel,
  Anlegen/Bearbeiten/Löschen nur für Admin, mit Audit-Log-Einträgen
- Erinnerung 28 Tage vor dem Redaktionsschluss an alle aktiven Rezensenten
  mit offener (oder fehlender) Planung, Versand über das benachrichtigung-
  Plugin je Kanal-Präferenz, Audit als System/benachrichtigt
- Täglicher APScheduler-Job (SPIELE_ERINNERUNG_JOB_UHRZEIT, abschaltbar)
  plus manuelle Sofort-Prüfung in der Admin-Ansicht
- Doppelschutz: Protokolltabelle mit Unique (Ausgabe, Benutzer)
- Admin-UI: Ausgabenliste mit Restzeit-Badges, Erinnerungsstatus
  (wer wurde wann über welche Kanäle erinnert), deutsche Templates
- Tests mit eingefrorener Uhr (freezegun): Fenster-Grenzen, kein Versand
  vor Frist/nach Schluss, Empfänger-/Rollenregeln, keine Duplikate,
  Scheduler-Konfiguration; Integrationstest mit den echten Plugins
  (167 Tests grün)
2026-08-21 20:31:13 +00:00

458 lines
22 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**, **Neuheiten (BGG-Sync)**,
**Dedup-Prüfung**, **Planungsliste** und **Erinnerungen**.
## 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) |
| `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG-Zusatzdaten für die Dedup-Prüfung an (`1`) oder aus (`0`): Alternate-Names und Erweiterungs-Relationen |
| `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
```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. 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).
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.
## 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
├── erinnerung/ VOLL IMPLEMENTIERT: Ausgaben, 4-Wochen-Erinnerung, Tages-Job
├── archiv/ 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 &amp; 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.
## 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
```python
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
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 „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.
## 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 (141 Tests grün) |
| 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ✅ fertig (141 Tests grün) |
| 7 | Plugin `archiv` (12-Monats-Autopilot) | ⏳ offen |
| 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung, Tages-Job) | ✅ fertig (167 Tests grün) |
| 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)
- WeasyPrint für PDF-Export (Systemabhängigkeiten im Container)
- Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets