# 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 **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](https://docs.astral.sh/uv/) + pytest ## Setup ```bash uv sync # venv + Dependencies uv run uvicorn main:app --reload # Dev-Server auf http://127.0.0.1:8000 ``` Beim ersten Start wird automatisch ein Admin-Konto angelegt: | Benutzername | Passwort | Rolle | |--------------|----------|--------| | `admin` | `admin` | admin | > ⚠️ Für alles außer der lokalen Entwicklung vor dem Start > `SPIELE_INITIAL_ADMIN_PASSWORD` setzen und das Passwort nach dem ersten > Login ändern. ### Umgebungsvariablen | Variable | Default | Bedeutung | |----------|---------|-----------| | `SPIELE_DATABASE_URL` | `sqlite:///./data/spiele-redaktion.db` | SQLAlchemy-URL (Postgres: z. B. `postgresql+psycopg://…`) | | `SPIELE_SESSION_SECRET` | Dev-Wert | Secret für signierte Session-Cookies | | `SPIELE_SESSION_DAUER` | `43200` | Session-Dauer in Sekunden (12 h) | | `SPIELE_INITIAL_ADMIN_PASSWORD` | `admin` | Passwort des initialen Admins | | `SPIELE_PLUGINS_DIR` | `/plugins` | Pfad zum lokalen Plugin-Verzeichnis | | `SPIELE_SMTP_HOST` | *(leer)* | SMTP-Server für den E-Mail-Kanal; leer = Dev-Fallback (nur Protokoll) | | `SPIELE_SMTP_PORT` | `587` | SMTP-Port | | `SPIELE_SMTP_BENUTZER` | *(leer)* | SMTP-Login (optional) | | `SPIELE_SMTP_PASSWORT` | *(leer)* | SMTP-Passwort (optional) | | `SPIELE_SMTP_ABSENDER` | Benutzer bzw. `spiele-redaktion@localhost` | From-Adresse | | `SPIELE_SMTP_TLS` | `starttls` | `starttls`, `ssl` oder `keine` | | `SPIELE_TELEGRAM_BOT_TOKEN` | *(leer)* | Bot-Token für den Telegram-Kanal; leer = Dev-Fallback (nur Protokoll) | | `SPIELE_BGG_SYNC_AKTIV` | `1` | Hintergrund-Sync des Neuheiten-Plugins an (`1`) oder aus (`0`) | | `SPIELE_BGG_SYNC_INTERVALL_STUNDEN` | `24` | Intervall des BGG-Syncs in Stunden (min. 1) | | `SPIELE_BGG_SUCHBEGRIFFE` | `brettspiel` | Komma-getrennte Suchbegriffe für den regelmäßigen Sync | | `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `25` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) | | `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG-Zusatzdaten für die Dedup-Prüfung an (`1`) oder aus (`0`): Alternate-Names und Erweiterungs-Relationen | ## 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), 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 ├── export/ VOLL IMPLEMENTIERT: CSV/PDF-Downloads, Rollen, Audit ├── archiv/ erinnerung/ (Stubs, ladbar) └── … je __init__.py + templates// ``` ### Der Plugin-Vertrag Ein Plugin ist ein Paketordner unter `plugins/` mit einem `__init__.py`, das ein Modulattribut `plugin` (Instanz einer `BasePlugin`-Unterklasse) bereitstellt. Alternativ: installiertes Paket mit Entry-Point in der Gruppe `spiele_redaktion.plugins`. ```python from fastapi import Depends, Request from redaktionskern.auth.deps import require_user from redaktionskern.contracts import BasePlugin, Migration, NavEntry class MeinPlugin(BasePlugin): name = "meinplugin" # eindeutig, = Ordnername title = "Mein Plugin" # deutscher Anzeigename description = "Was es tut." def migrations(self): # eigene Schema-Migrationen (optional) return [] def navigation(self): # Einträge in der Hauptnavigation (optional) return [NavEntry(label=self.title, url="/meinplugin")] def on_load(self, context): # Lifecycle-Hook beim Start super().on_load(context) # context.engine/.session_factory/.templates/.settings def on_unload(self): # Lifecycle-Hook beim Herunterfahren super().on_unload() plugin = MeinPlugin() ``` Der Kern stellt jedem Plugin über `context.templates` eine gemeinsame Jinja- Umgebung bereit (Plugin-Templates erben `base.html`), dazu Engine und Session-Fabrik. Migrationen laufen transaktional und werden pro Plugin in `schema_migrations` protokolliert; die `up`-Funktionen bekommen eine SQLAlchemy-Connection und können bei Dialekt-Unterschieden verzweigen. ## Plugin „Audit-Log“ (implementiert) Protokolliert, wer was wann verschoben, eingetragen oder geändert hat. ### Datentabelle Eigene Migration `0001_audit_eintraege`, Tabelle `audit_eintraege` (SQLite ↔ Postgres portabel): | Spalte | Inhalt | |--------|--------| | `actor_id` / `actor_name` | Wer (Benutzer-ID/-Name; `System` bei automatischen Ereignissen) | | `action` | Was (`erstellt`, `verschoben`, `geaendert`, `geloescht`, `benachrichtigt`) | | `objekt_typ` / `objekt_id` | Betroffenes Objekt (ID als Text) | | `alt` / `neu` | Alt-/Neustand als JSON (optional) | | `details` | Freie Zusatzinformationen als JSON (optional) | | `ip_adresse` | Herkunfts-IP (optional) | | `erstellt_am` | Zeitstempel | ### Öffentliche API für andere Plugins Andere Plugins rufen das Audit-Log über die Plugin-Registry bei jedem relevanten Ereignis auf: ```python audit = request.app.state.registry.get("audit-log") if audit is not None: await audit.log( # async-Variante user, # User-Objekt, Benutzername oder None (= System) "geaendert", # Aktion (Konstanten: AKTIONEN_ANZEIGE im Plugin) "planungseintrag", # Objekttyp eintrag.id, # Objekt-ID details={"alt": alt, "neu": neu}, # „alt“/„neu“ → JSON-Spalten, Rest → details ip_adresse=request.client.host, # optional ) ``` In synchronen Routen (`def`, FastAPI-Threadpool) steht `audit.log_sync(...)` mit denselben Argumenten bereit. ### Admin-Ansicht Unter `/audit-log` (nur Rolle **Admin**; Rezensenten/Redakteure erhalten 403, Anonyme werden zum Login umgeleitet): filterbar nach Benutzer, Aktionstyp und Zeitraum (Von/Bis), paginiert (25 Einträge pro Seite), neueste zuerst. ## Plugin „benachrichtigung“ Erstes fachlich umgesetztes Plugin (Adapter-Muster, drei Kanäle): | Kanal | Zustellung | Empfängerdaten | |-------|------------|----------------| | **E-Mail** | SMTP (`smtplib`, TLS: STARTTLS/SSL), konfigurierbar per Env | E-Mail-Adresse pro Benutzer | | **Telegram** | Bot-API (`sendMessage`) | Chat-ID pro Benutzer, Token zentral per Env | | **In-App** | Persistente Nachrichten im Portal mit Unread-Counter und „Alle als gelesen markieren“ | — | Ist ein Kanal installationsweit nicht konfiguriert (kein SMTP-Host bzw. kein Bot-Token — der Normalfall in der Entwicklung), protokolliert ein **Dev-Log-Adapter** die Nachricht auf dem Server, statt sie zu versenden; in der Entwicklung geht so keine Benachrichtigung verloren. Jeder Benutzer wählt unter **Benachrichtigungen → Kanäle & Einstellungen** seine Kanäle (mehrere gleichzeitig möglich) und hinterlegt E-Mail-Adresse bzw. Telegram-Chat-ID. Ohne gespeicherte Präferenz wird In-App zugestellt. ### Öffentliche Plugin-API für andere Plugins ```python plugin = request.app.state.registry.get("benachrichtigung") bericht = await plugin.send_notification(user, "Titel", "Nachrichtentext", "kategorie") # bericht.zugestellt -> z. B. ["email", "inapp"] # bericht.fehlgeschlagen -> Kanäle ohne Adresse/Fehler ``` `send_notification(user, titel, text, kategorie="allgemein")` wirft nicht; einzelne Kanalausfälle werden protokolliert und im Bericht gemeldet. Die In-App-Nachricht landet im Posteingang des Benutzers. ## Plugin „neuheiten“ (implementiert) Neuheitenliste auf Basis der **BoardGameGeek XML API2** (`https://boardgamegeek.com/xmlapi2`). Regelmäßiger Sync als Hintergrund-Job, manuelle Synchronisation für die Redaktion, deutsche sortier-/filterbare Liste. ### Datentabelle Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten` (SQLite ↔ Postgres portabel): | Spalte | Inhalt | |--------|--------| | `titel` | Spieltitel | | `verlag` / `autor` | Verlag bzw. Autor(en), kommagetrennt | | `erscheinungsjahr` | Erscheinungsjahr — BGG liefert nur das Jahr, kein genaues Datum | | `bgg_id` | BoardGameGeek-ID, eindeutig → Merge-Kriterium des Syncs | | `status` | `neuheit` (spätere Plugins: Planung/Archiv) | | `quelle` | Datenquelle (`boardgamegeek`) | | `erstellt_am` / `aktualisiert_am` | Zeitstempel | ### BGG-Client & Sync-Ablauf Pro Suchbegriff: `/search?type=boardgame&query=…` → IDs sammeln → `/thing?id=…&type=boardgame` in Batches à 20 IDs. - **Rate-Limit:** mindestens 1 Sekunde zwischen zwei Requests. - **Retry mit Backoff:** exponentielles Zurückhalten bei 5xx, 429 und Netzwerkfehlern; HTTP 202 (BGG-Warteschlange) wird gemäß `Retry-After` erneut versucht; kaputtes XML erzeugt eine klare Fehlermeldung. - **Filter:** - Erweiterungen (`type=boardgameexpansion`) werden auf Request-Ebene (`type=boardgame`) und zusätzlich pro Element ausgeschlossen. - Prototypen: Die XML API2 liefert keinen Prototyp-Marker; als Heuristik werden Titel mit Prototyp-Schlüsselwörtern („Prototyp“, „Prototype“, …) gefiltert. Restrisiko verbleibt — redaktionelle Prüfung bleibt wichtig. - **Update statt Duplikat:** bestehende Einträge werden über die eindeutige `bgg_id` aktualisiert (Titel/Verlag/Autor/Jahr), der `status` bleibt erhalten; neu angelegt wird nur bei unbekannter BGG-ID. ### Hintergrund-Job & manuelle Synchronisation Beim Start registriert das Plugin einen APScheduler-Job (Standard: alle 24 h, konfigurierbar über `SPIELE_BGG_SYNC_INTERVALL_STUNDEN`, abschaltbar über `SPIELE_BGG_SYNC_AKTIV=0`). Die Suchbegriffe kommen aus `SPIELE_BGG_SUCHBEGRIFFE` (komma-getrennt). Läuft ein Sync noch, wird ein überlappender Durchlauf übersprungen. Unter **Neuheiten → „Jetzt synchronisieren“** lösen Admins und Redakteure einen sofortigen Sync aus — optional mit einem Sofort-Suchbegriff für eine gezielte Recherche. Das Ergebnis (neu/aktualisiert/gefiltert) erscheint als Banner; der Endpunkt ist rollengeschützt (Rezensenten erhalten 403). ### UI Die Seite `/neuheiten` zeigt alle Einträge als Tabelle (Spieltitel, Verlag, Autor, Erscheinungsjahr, Status, aktualisiert am): - Sortierung per Spaltenkopf, Suche über Titel/Verlag/Autor und Statusfilter — serverseitig umgesetzt, per HTMX ohne Seitenreload (ohne JavaScript als normaler Formular-GET nutzbar). - Jeder Titel verlinkt direkt auf den BGG-Eintrag. - Sync-Steuerung nur für Rolle Admin/Redakteur. ## 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/` 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 „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 (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) | ⏳ offen | | 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ✅ fertig (161 Tests grün) | | 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 - Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets