Compare commits

..

5 Commits

Author SHA1 Message Date
Flo Hartmann
6b2e04d782 Merge wt-export: Export-Plugin (Konflikte aufgelöst) 2026-08-21 20:38:34 +00:00
Flo Hartmann
1d4cd400a3 Merge wt-erinnerung: Erinnerungs-Plugin (Konflikte aufgelöst) 2026-08-21 20:37:20 +00:00
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
Flo Hartmann
d024f42f3c Plugin export: Listen als CSV (Excel-DE) und PDF (WeasyPrint)
- CSV über stdlib csv: Semikolon-getrennt, UTF-8 mit BOM, korrektes
  Quoting; PDF über WeasyPrint mit deutscher Kopfzeile (je Seite
  wiederholt), Erstell-Datum und Seitenzahlen (Seite X von Y)
- Drei Listen: Neuheiten, Planung, Archiv (Neuheiten gefiltert auf
  Status „archiviert“); Partner-Tabellen über gemeinsame Metadata,
  ohne Plugin-Importe — fehlende Plugins degradieren sauber
- Rollen: Admin/Redakteur alles, Rezensent nur Planungsliste;
  serverseitige Prüfung an den Download-Endpunkten (StreamingResponse),
  UI-Karten für nicht erlaubte Listen ausgegraut
- Jeder Download best effort im Audit-Log (Aktion „exportiert“)
- WeasyPrint lazy importiert: ohne System-Bibliotheken bleiben App und
  CSV nutzbar; Tests skippen PDF-Fälle nach Installationsversuch
- 20 neue Tests (CSV-Inhalt/BOM/Umlaute/Quoting, PDF-Gültigkeit,
  Rollen, Audit, Degradierung); 161 gesamt grün
- README: Plugin-Dokumentation, Fortschritt export auf 
2026-08-21 20:30:19 +00:00
Flo Hartmann
72fe3b7eda Plugin archiv: 12-Monats-Autopilot mit Sicherungsdatei-Prinzip
- Eigene Migration 0001_archiv_tabellen: Spiegel-Tabellen archiv_neuheiten
  und archiv_planung (ohne FK, damit Archiv-Zeilen Benutzerlöschung überleben)
- Service mit eingefrierbarer Uhr: Stichtag „jetzt minus 12 Monate“ in UTC,
  strenger Vergleich (genau 12 Monate bleibt aktiv); Neuheiten nach
  Erscheinungsjahr (großzügig Jahresende) sonst Eintragsdatum, Planung nach
  Eintragsdatum; Monatsarithmetik mit Klemmung (29. Februar)
- Täglicher APScheduler-Cron-Job (Standard 03:00, konfigurierbar über
  SPIELE_ARCHIV_JOB_UHRZEIT / SPIELE_ARCHIV_JOB_AKTIV); je archiviertem
  Titel ein Audit-Log-Eintrag als System
- Admin-Ansicht /archiv: vereinigte Liste beider Tabellen, Suche über
  Titel/Verlag/Autor, Herkunftsfilter, Wiederherstellen in die aktive Liste
  (BGG-Konflikt wird abgelehnt, verwaiste Rezensentenzuordnung übernimmt der
  Admin — gemeldet und auditiert); Rollen: nur Admin
- Kern minimal erweitert: PluginContext.registry stellt Hintergrund-Jobs die
  Plugin-Registry bereit (keine Fachlogik im Kern)
- 30 neue Tests mit eingefrorener Uhr (Grenzfälle, Zeitzonen, Schaltjahr,
  Job-Lauf, Wiederherstellen, Rollen, Suche/Filter); 171 Tests grün
- README aktualisiert: neues Plugin-Kapitel, Env-Variablen, Fortschritt #7 
2026-08-21 20:26:00 +00:00
22 changed files with 4486 additions and 65 deletions

194
README.md
View File

@@ -4,7 +4,8 @@
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** und **Planungsliste**.
**Dedup-Prüfung**, **Planungsliste**, **Archiv (12-Monats-Autopilot)**,
**Erinnerungen** und **Export (CSV/PDF)**.
## Stack
@@ -14,6 +15,7 @@ bisher **Audit-Log**, **Benachrichtigung**, **Neuheiten (BGG-Sync)**,
- **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
@@ -54,6 +56,10 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt:
| `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_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
@@ -75,6 +81,29 @@ 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
@@ -94,7 +123,9 @@ plugins/ ein Ordner pro Funktion, ladbar über den Plugin-Load
├── benachrichtigung/ VOLL IMPLEMENTIERT: E-Mail/Telegram/In-App
├── dedup/ VOLL IMPLEMENTIERT: check_titel-API, Heuristik, Protokoll
├── planung/ VOLL IMPLEMENTIERT: Planungsliste, Verschiebung, Dialoge
├── archiv/ erinnerung/ export/ (Stubs, ladbar)
├── 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>/
```
@@ -122,7 +153,9 @@ class MeinPlugin(BasePlugin):
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
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()
@@ -363,6 +396,148 @@ 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).
@@ -379,11 +554,11 @@ geschrieben.
| 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) | ⏳ offen |
| 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 | ⏳ offen |
| 11 | Deployment auf swen.henry.insight-it.de (Compose + Traefik, Live-Check) | ⏳ offen |
@@ -392,6 +567,5 @@ Legende: ✅ fertig · 🔄 in Arbeit · ⏳ offen · ⚠️ fertig mit offenen
## Offene Punkte (nicht Teil von Phase 1)
- CSRF-Schutz für Formulare (aktuell SameSite=Lax-Cookie als Basisschutz)
- Hintergrund-Jobs (APScheduler) für Erinnerungen
- WeasyPrint für PDF-Export (Systemabhängigkeiten im Container)
- WeasyPrint-Systemabhängigkeiten im Container prüfen
- Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets

View File

@@ -1,41 +1,278 @@
"""Plugin „archiv“ — Platzhalter gemäß Plugin-Vertrag.
"""Plugin „archiv“ — automatische Archivierung nach dem Sicherungsdatei-
Prinzip.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
Titel der Neuheitenliste und der Planungsliste, deren Erscheinungsdatum
bzw. Eintragsdatum länger als 12 Monate zurückliegt, werden vollständig in
die Archiv-Tabellen (`archiv_neuheiten`, `archiv_planung`) verschoben und
aus den aktiven Listen entfernt. Nichts wird gelöscht oder verändert —
Admins können jeden Eintrag über die Archiv-Ansicht jederzeit wieder in die
aktive Liste zurückverschieben.
Umfang:
- Eigene Migration mit beiden Spiegel-Tabellen.
- Täglicher Hintergrund-Job (APScheduler, Cron um „03:00“ lokal).
- Admin-Ansicht `/archiv` mit Suche, Herkunftsfilter und Wiederherstellen.
- Je ein Audit-Log-Eintrag pro Archivierung (Akteur „System“) und pro
Wiederherstellung (Akteur der Aktion).
Konfiguration über Umgebungsvariablen (gelesen beim Plugin-Start):
- SPIELE_ARCHIV_JOB_AKTIV „1“ (Standard) = täglicher Job an, „0“ = aus
- SPIELE_ARCHIV_JOB_UHRZEIT Tageszeit des Laufs im Format HH:MM (Standard 03:00)
Die Fachlogik liegt im Service (`service.py`) mit eingefrierbarer Uhr;
Partner-Tabellen werden wie in dedup/planung nur über die gemeinsame
SQLAlchemy-Metadata angesprochen. Der Kern bleibt unberührt.
"""
from __future__ import annotations
from fastapi import Depends, Request
import logging
import os
from urllib.parse import quote
from redaktionskern.auth.deps import require_user
from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, NavEntry
from fastapi import Depends, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_roles
from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry
from .models import STATUS_ANZEIGE, ArchivNeuheit, ArchivPlanung
from .service import (
GUELTIGE_QUELLEN,
QUELLE_NEUHEITEN,
OBJEKT_TYPEN,
ArchivService,
)
_logger = logging.getLogger("plugins.archiv")
JOB_ID = "archiv-taeglich"
STANDARD_UHRZEIT = "03:00"
def _uhrzeit_parsen(roh: str | None) -> tuple[int, int]:
"""Parst „HH:MM“; bei Unsinn gilt der Standard 03:00."""
teile = (roh or "").strip().split(":")
try:
stunde = int(teile[0])
minute = int(teile[1]) if len(teile) > 1 else 0
except (ValueError, IndexError):
return 3, 0
if not (0 <= stunde <= 23 and 0 <= minute <= 59):
return 3, 0
return stunde, minute
def _tabelle_anlegen(conn) -> None:
"""Migration 0001: legt beide Spiegel-Tabellen an (portabel, idempotent)."""
ArchivNeuheit.__table__.create(conn, checkfirst=True)
ArchivPlanung.__table__.create(conn, checkfirst=True)
class ArchivPlugin(BasePlugin):
name = "archiv"
title = "Archiv"
description = "Automatische Archivierung von Titeln älter als 12 Monate (Platzhalter)."
description = (
"Verschiebt Titel älter als 12 Monate automatisch aus den aktiven "
"Listen in das Archiv — vollständig wiederherstellbar."
)
def __init__(self) -> None:
super().__init__()
self._scheduler = None # BackgroundScheduler, falls aktiviert
#: Einfrierbare Uhr für Tests (Callable → aware datetime); None = echte Zeit.
self._jetzt = None
self._routen_registrieren()
# ---------- Plugin-Vertrag ----------
def migrations(self) -> list[Migration]:
return [Migration(version="0001_archiv_tabellen", up=_tabelle_anlegen)]
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/archiv")]
def on_load(self, context) -> None:
super().on_load(context)
if os.environ.get("SPIELE_ARCHIV_JOB_AKTIV", "1").strip() == "1":
self._scheduler_starten(
os.environ.get("SPIELE_ARCHIV_JOB_UHRZEIT", STANDARD_UHRZEIT)
)
def on_unload(self) -> None:
if self._scheduler is not None:
self._scheduler.shutdown(wait=False)
self._scheduler = None
super().on_unload()
# ---------- Internas ----------
def _neuer_service(self) -> ArchivService:
"""Fabrik für den Service; die Uhr ist von Tests überschreibbar."""
return ArchivService(self.context.session_factory, jetzt=self._jetzt)
def _audit_plugin(self):
registry = getattr(self.context, "registry", None)
return registry.get("audit-log") if registry is not None else None
def _auditiere_archivierung(self, ereignisse) -> int:
"""Schreibt je archiviertem Titel einen Audit-Eintrag (Akteur: System)."""
audit = self._audit_plugin()
if audit is None:
return 0
geschrieben = 0
for ereignis in ereignisse:
try:
audit.log_sync(
None,
"verschoben",
ereignis.objekt_typ,
ereignis.objekt_id,
ereignis.details,
)
geschrieben += 1
except Exception: # noqa: BLE001 - Audit darf den Lauf nicht brechen
_logger.exception("Audit-Log-Eintrag für Archivierung fehlgeschlagen.")
return geschrieben
def _archiv_job(self) -> None:
"""Hintergrund-Job: läuft täglich und archiviert fällige Titel."""
service = self._neuer_service()
lauf, ereignisse = service.lauf()
self._auditiere_archivierung(ereignisse)
if lauf.gesamt:
_logger.info("Täglicher Archiv-Lauf abgeschlossen: %s", lauf.als_text())
def _scheduler_starten(self, uhrzeit_roh: str | None) -> None:
from apscheduler.schedulers.background import BackgroundScheduler
stunde, minute = _uhrzeit_parsen(uhrzeit_roh)
scheduler = BackgroundScheduler()
scheduler.add_job(
self._archiv_job,
trigger="cron",
hour=stunde,
minute=minute,
id=JOB_ID,
replace_existing=True,
)
scheduler.start()
self._scheduler = scheduler
_logger.info(
"Automatische Archivierung aktiv: täglich um %02d:%02d Uhr.",
stunde,
minute,
)
# ---------- Routen ----------
def _routen_registrieren(self) -> None:
@self.router.get("/archiv")
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
def ansicht(
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_roles(Role.ADMIN.value)),
q: str = "",
quelle: str = "",
meldung: str = "",
fehler: str = "",
):
"""Archiv-Ansicht (nur Admin): Suche, Filter, Wiederherstellen."""
service = self._neuer_service()
eintraege = service.ansicht(db)
alle_neuheiten = sum(1 for e in eintraege if e["quelle"] == QUELLE_NEUHEITEN)
alle_planung = len(eintraege) - alle_neuheiten
such = q.strip().lower()
if such:
eintraege = [
e
for e in eintraege
if such in (e["titel"] or "").lower()
or such in (e["verlag"] or "").lower()
or such in (e["autor"] or "").lower()
]
if quelle in GUELTIGE_QUELLEN:
eintraege = [e for e in eintraege if e["quelle"] == quelle]
namen = {
benutzer.id: (benutzer.display_name or benutzer.username)
for benutzer in db.scalars(select(User)).all()
}
return self.context.templates.TemplateResponse(
request=request,
name="archiv/index.html",
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
"eintraege": eintraege,
"namen": namen,
"status_anzeige": STATUS_ANZEIGE,
"q": q,
"quelle_filter": quelle if quelle in GUELTIGE_QUELLEN else "",
"anzahl": len(eintraege),
"anzahl_neuheiten": alle_neuheiten,
"anzahl_planung": alle_planung,
"meldung": meldung[:300],
"fehler": fehler[:300],
"job_aktiv": self._scheduler is not None and self._scheduler.running,
},
)
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/archiv")]
@self.router.post("/archiv/{quelle}/{archiv_id}/wiederherstellen")
async def wiederherstellen(
request: Request,
quelle: str,
archiv_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_roles(Role.ADMIN.value)),
):
"""Stellt einen archivierten Eintrag in die aktive Liste zurück."""
ziel_url = "/archiv"
if quelle not in GUELTIGE_QUELLEN:
return RedirectResponse(
f"{ziel_url}?fehler={quote('Unbekannte Herkunft.')}",
status_code=303,
)
service = self._neuer_service()
ergebnis = service.wiederherstellen(
db, quelle, archiv_id, ersatz_rezensent_id=user.id
)
if not ergebnis.ok:
return RedirectResponse(
f"{ziel_url}?fehler={quote(ergebnis.meldung)}", status_code=303
)
details = {
"titel": ergebnis.titel,
"von": "archiv",
"ziel": ergebnis.ziel,
"archiv_id": archiv_id,
}
if ergebnis.rezensent_ersetzt:
details["rezensent_ersetzt"] = True
audit = request.app.state.registry.get("audit-log")
if audit is not None:
try:
await audit.log(
user,
"verschoben",
OBJEKT_TYPEN[quelle],
ergebnis.neuer_id,
details,
)
except Exception: # noqa: BLE001 - Audit blockiert nicht
_logger.exception(
"Audit-Log-Eintrag für Wiederherstellung fehlgeschlagen."
)
return RedirectResponse(
f"{ziel_url}?meldung={quote(ergebnis.meldung)}", status_code=303
)
plugin = ArchivPlugin()

108
plugins/archiv/models.py Normal file
View File

@@ -0,0 +1,108 @@
"""Datenmodelle des Plugins „archiv“.
Das Archiv folgt dem Sicherungsdatei-Prinzip: Beim Archivieren wird jede
Zeile vollständig in eine eigene Archiv-Tabelle kopiert und aus der aktiven
Liste entfernt — nichts wird verändert oder gelöscht, ein Eintrag lässt sich
jederzeit unverändert zurück in die aktive Liste verschieben.
Es gibt zwei Spiegel-Tabellen (eine pro aktiver Liste):
- `archiv_neuheiten` ← Spiegel von `neuheiten`
- `archiv_planung` ← Spiegel von `planungsliste`
Bewusst ohne Fremdschlüssel: Archivierte Zeilen müssen auch dann erhalten
bleiben, wenn z. B. der zugeordnete Rezensent gelöscht wird. Bei der
Wiederherstellung wird die Gültigkeit geprüft und notfalls ein Ersatz-
Rezensent zugeordnet.
Bewusst keine zusätzlichen Indizes: Bei doppelt geladenem Modul (direkter
Import im Test + Plugin-Loader-Namensraum) würden sie doppelt angelegt; die
Nutzerzahl ist klein.
Die Archiv-Metadaten (wer/wann/warum) stehen in jeder Zeile: `quell_id`
(ursprüngliche ID in der aktiven Liste), `archiviert_am`, `archiviert_von`,
`grund` und `referenz_am` (Zeitpunkt, an dem die 12-Monats-Regel angesetzt
hat — Erscheinungstermin bzw. Eintragsdatum).
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, JSON, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from redaktionskern.db import Base
class _ArchivMeta:
"""Gemeinsame Archiv-Metadaten beider Spiegel-Tabellen (Mixin)."""
#: ID der Zeile in der ursprünglichen aktiven Liste.
quell_id: Mapped[int] = mapped_column(Integer)
#: Zeitpunkt der Archivierung (naive UTC, wie überall in der App).
archiviert_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
#: „System“ beim automatischen Lauf, sonst Benutzername der Wiederherstellung… # noqa
archiviert_von: Mapped[str] = mapped_column(String(100), default="System")
#: Menschlich lesbarer Grund, z. B. „älter als 12 Monate (Erscheinungsjahr 2023)“.
grund: Mapped[str] = mapped_column(String(200), default="")
#: Datum, an dem die 12-Monats-Frist angesetzt hat (Erscheinung bzw. Eintrag).
referenz_am: Mapped[datetime | None] = mapped_column(DateTime)
class ArchivNeuheit(_ArchivMeta, Base):
"""Archivierter Neuheiten-Eintrag (Spiegel der Tabelle `neuheiten`)."""
__tablename__ = "archiv_neuheiten"
# extend_existing: Das Plugin kann doppelt geladen werden (direkter Import
# im Test + Plugin-Loader-Namensraum) und teilt sich dieselbe Metadata.
__table_args__ = {"extend_existing": True}
id: Mapped[int] = mapped_column(primary_key=True)
titel: Mapped[str] = mapped_column(String(300))
verlag: Mapped[str | None] = mapped_column(String(300))
autor: Mapped[str | None] = mapped_column(String(300))
erscheinungsjahr: Mapped[int | None] = mapped_column(Integer)
bgg_id: Mapped[int | None] = mapped_column(Integer)
status: Mapped[str] = mapped_column(String(50), default="neuheit")
quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek")
erstellt_am: Mapped[datetime | None] = mapped_column(DateTime)
aktualisiert_am: Mapped[datetime | None] = mapped_column(DateTime)
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
return f"<ArchivNeuheit {self.titel!r}>"
class ArchivPlanung(_ArchivMeta, Base):
"""Archivierter Planungseintrag (Spiegel der Tabelle `planungsliste`)."""
__tablename__ = "archiv_planung"
__table_args__ = {"extend_existing": True}
id: Mapped[int] = mapped_column(primary_key=True)
titel: Mapped[str] = mapped_column(String(300))
verlag: Mapped[str | None] = mapped_column(String(300))
autor: Mapped[str | None] = mapped_column(String(300))
bgg_id: Mapped[int | None] = mapped_column(Integer)
ausgabe: Mapped[str] = mapped_column(String(100), default="")
#: Kein FK — Archiv-Zeilen überleben das Löschen des Benutzers.
rezensent_id: Mapped[int | None] = mapped_column(Integer)
status: Mapped[str] = mapped_column(String(50), default="offen")
notizen: Mapped[str | None] = mapped_column(Text)
quelle: Mapped[str] = mapped_column(String(50), default="manuell")
pruefung: Mapped[list | None] = mapped_column(JSON)
erstellt_am: Mapped[datetime | None] = mapped_column(DateTime)
aktualisiert_am: Mapped[datetime | None] = mapped_column(DateTime)
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
return f"<ArchivPlanung {self.titel!r}>"
#: Deutsche Anzeigenamen der Statuswerte (bewusst als Kopie ohne
#: Import-Abhängigkeit zu den Partner-Plugins, siehe planung/KONFLIKT_ANZEIGE).
STATUS_ANZEIGE: dict[str, str] = {
"neuheit": "Neuheit",
"planung": "In Planung",
"offen": "Offen",
"in_bearbeitung": "In Bearbeitung",
"abgeschlossen": "Abgeschlossen",
}

494
plugins/archiv/service.py Normal file
View File

@@ -0,0 +1,494 @@
"""Archivierungs-Logik des Plugins „archiv“.
Regeln (dokumentiert, von Tests mit eingefrorener Uhr abgedeckt):
- **Stichtag:** „jetzt minus 12 Monate“ in UTC. Archiviert wird, wer
*streng älter* als der Stichtag ist — genau 12 Monate gilt noch nicht als
„älter als 12 Monate“.
- **Referenzdatum:**
- Neuheiten: das Erscheinungsdatum. BoardGameGeek liefert nur das Jahr,
daher großzügig das Ende des Erscheinungsjahres (31.12., 23:59:59) — ein
Titel des Jahres J wird also erst nach dem 31.12. von J+1 archiviert.
Fehlt das Jahr, zählt das Eintragsdatum (`erstellt_am`).
- Planungseinträge: das Eintragsdatum (`erstellt_am`), unabhängig vom Status.
- **Zeitzonen:** Verglichen wird durchgängig in UTC. Zeitstempel ohne
Zeitzone aus der Datenbank gelten als UTC (SQLite CURRENT_TIMESTAMP ist
UTC; für Postgres werden naive Werte equally behandelt). Bewohnte
Zeitzonen werden vor dem Vergleich nach UTC verschoben.
- **Sicherungsdatei-Prinzip:** Zeilen werden vollständig in die Spiegel-
Tabellen kopiert und aus den aktiven Listen entfernt — nichts geht verloren.
"""
from __future__ import annotations
import calendar
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from sqlalchemy import delete, insert, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session, sessionmaker
from redaktionskern.db import Base
from .models import ArchivNeuheit, ArchivPlanung
_logger = logging.getLogger("plugins.archiv")
QUELLE_NEUHEITEN = "neuheiten"
QUELLE_PLANUNG = "planung"
GUELTIGE_QUELLEN: tuple[str, ...] = (QUELLE_NEUHEITEN, QUELLE_PLANUNG)
#: Objekttypen für Audit-Log-Einträge (deckungsgleich mit den Partner-Plugins).
OBJEKT_TYPEN: dict[str, str] = {
QUELLE_NEUHEITEN: "neuheit",
QUELLE_PLANUNG: "planungseintrag",
}
ARCHIV_ZIEL = "archiv"
GRUND_TEXT = "älter als 12 Monate"
STANDARD_MONATE = 12
def vor_monaten(moment: datetime, monate: int) -> datetime:
"""Zieht `monate` Kalendermonate ab; Monatstage werden geklemmt
(z. B. 29. Februar 12 Monate → 28. Februar des Vorjahres)."""
monat = moment.month - monate
jahr = moment.year
while monat <= 0:
monat += 12
jahr -= 1
tag = min(moment.day, calendar.monthrange(jahr, monat)[1])
return moment.replace(year=jahr, month=monat, day=tag)
def als_utc(moment: datetime | None) -> datetime | None:
"""Naive Zeitstempel gelten als UTC; zeitzonenbewusste → UTC."""
if moment is None:
return None
if moment.tzinfo is None:
return moment.replace(tzinfo=timezone.utc)
return moment.astimezone(timezone.utc)
def naiv_utc(moment: datetime | None) -> datetime | None:
"""Nach UTC normalisiert und ohne Zeitzone (Speicherformat der App)."""
utc = als_utc(moment)
return None if utc is None else utc.replace(tzinfo=None)
@dataclass
class ArchivLauf:
"""Zusammenfassung eines automatischen Archivierungs-Laufs."""
neuheiten_archiviert: int = 0
planung_archiviert: int = 0
@property
def gesamt(self) -> int:
return self.neuheiten_archiviert + self.planung_archiviert
def als_text(self) -> str:
return (
f"{self.neuheiten_archiviert} Neuheiten und "
f"{self.planung_archiviert} Planungseinträge archiviert"
)
@dataclass
class ArchivEreignis:
"""Ein archivierter Datensatz — Grundlage für je einen Audit-Log-Eintrag."""
objekt_typ: str
objekt_id: int
details: dict = field(default_factory=dict)
@dataclass
class Wiederherstellung:
"""Ergebnis eines Wiederherstellen-Versuchs."""
ok: bool
meldung: str
ziel: str = ""
neuer_id: int | None = None
rezensent_ersetzt: bool = False
titel: str = ""
class ArchivService:
"""Verschiebt veraltete Titel in die Archiv-Tabellen und zurück."""
def __init__(
self,
session_factory: sessionmaker,
jetzt: Callable[[], datetime] | None = None,
monate: int = STANDARD_MONATE,
) -> None:
self.session_factory = session_factory
# Einfrierbare Uhr (Tests); Standard: aktuelle UTC-Zeit.
self._jetzt_fabrik = jetzt or (lambda: datetime.now(timezone.utc))
self.monate = max(int(monate), 0)
# ---------- Zeitlogik ----------
def jetzt(self) -> datetime:
return als_utc(self._jetzt_fabrik()) # type: ignore[arg-type]
def stichtag(self) -> datetime:
"""„jetzt 12 Monate“; streng älter heißt archiviert."""
return vor_monaten(self.jetzt(), self.monate)
def referenz_neuheit(self, zeile) -> datetime:
"""Erscheinungsdatum (Ende des bekannten Jahres) oder Eintragsdatum."""
jahr = zeile["erscheinungsjahr"]
if jahr is not None:
return datetime(int(jahr), 12, 31, 23, 59, 59, tzinfo=timezone.utc)
erstellt = als_utc(zeile.get("erstellt_am"))
return erstellt or self.jetzt()
def referenz_planung(self, zeile) -> datetime:
"""Eintragsdatum des Planungseintrags."""
erstellt = als_utc(zeile.get("erstellt_am"))
return erstellt or self.jetzt()
def _grund_neuheit(self, zeile, referenz: datetime) -> str:
if zeile["erscheinungsjahr"] is not None:
return f"{GRUND_TEXT} (Erscheinungsjahr {zeile['erscheinungsjahr']})"
return f"{GRUND_TEXT} (eingetragen am {referenz:%d.%m.%Y})"
def _grund_planung(self, zeile, referenz: datetime) -> str:
return f"{GRUND_TEXT} (eingetragen am {referenz:%d.%m.%Y})"
# ---------- Automatischer Lauf ----------
def lauf(self) -> tuple[ArchivLauf, list[ArchivEreignis]]:
"""Verschiebt alle fälligen Titel in die Archiv-Tabellen.
Die Audit-Log-Einträge schreibt der Aufrufer (Route oder Hintergrund-
Job) über die öffentliche API des audit-log-Plugins — ein Ereignis
pro archiviertem Titel.
"""
stichtag = self.stichtag()
zeitpunkt = naiv_utc(self.jetzt())
lauf = ArchivLauf()
ereignisse: list[ArchivEreignis] = []
with self.session_factory() as db:
db: Session
neuheiten = Base.metadata.tables.get("neuheiten")
if neuheiten is not None:
try:
zeilen = db.execute(select(neuheiten)).mappings().all()
except SQLAlchemyError as exc:
_logger.warning(
"archiv: Tabelle 'neuheiten' nicht lesbar (%s) — "
"Quelle übersprungen.",
exc,
)
zeilen = []
for zeile in zeilen:
referenz = self.referenz_neuheit(zeile)
if referenz >= stichtag:
continue # noch keine 12 Monate alt (oder jünger)
db.execute(
insert(ArchivNeuheit).values(
quell_id=zeile["id"],
titel=zeile["titel"],
verlag=zeile["verlag"],
autor=zeile["autor"],
erscheinungsjahr=zeile["erscheinungsjahr"],
bgg_id=zeile["bgg_id"],
status=zeile["status"],
quelle=zeile["quelle"],
erstellt_am=zeile["erstellt_am"],
aktualisiert_am=zeile["aktualisiert_am"],
archiviert_am=zeitpunkt,
referenz_am=naiv_utc(referenz),
grund=self._grund_neuheit(zeile, referenz),
)
)
db.execute(
delete(neuheiten).where(neuheiten.c.id == zeile["id"])
)
lauf.neuheiten_archiviert += 1
ereignisse.append(
ArchivEreignis(
objekt_typ=OBJEKT_TYPEN[QUELLE_NEUHEITEN],
objekt_id=zeile["id"],
details={
"titel": zeile["titel"],
"herkunft": QUELLE_NEUHEITEN,
"ziel": ARCHIV_ZIEL,
"grund": self._grund_neuheit(zeile, referenz),
"referenz": f"{naiv_utc(referenz):%Y-%m-%d}",
},
)
)
planungsliste = Base.metadata.tables.get("planungsliste")
if planungsliste is not None:
try:
zeilen = db.execute(select(planungsliste)).mappings().all()
except SQLAlchemyError as exc:
_logger.warning(
"archiv: Tabelle 'planungsliste' nicht lesbar (%s) — "
"Quelle übersprungen.",
exc,
)
zeilen = []
for zeile in zeilen:
referenz = self.referenz_planung(zeile)
if referenz >= stichtag:
continue
db.execute(
insert(ArchivPlanung).values(
quell_id=zeile["id"],
titel=zeile["titel"],
verlag=zeile["verlag"],
autor=zeile["autor"],
bgg_id=zeile["bgg_id"],
ausgabe=zeile["ausgabe"],
rezensent_id=zeile["rezensent_id"],
status=zeile["status"],
notizen=zeile["notizen"],
quelle=zeile["quelle"],
pruefung=zeile["pruefung"],
erstellt_am=zeile["erstellt_am"],
aktualisiert_am=zeile["aktualisiert_am"],
archiviert_am=zeitpunkt,
referenz_am=naiv_utc(referenz),
grund=self._grund_planung(zeile, referenz),
)
)
db.execute(
delete(planungsliste).where(planungsliste.c.id == zeile["id"])
)
lauf.planung_archiviert += 1
ereignisse.append(
ArchivEreignis(
objekt_typ=OBJEKT_TYPEN[QUELLE_PLANUNG],
objekt_id=zeile["id"],
details={
"titel": zeile["titel"],
"herkunft": QUELLE_PLANUNG,
"ziel": ARCHIV_ZIEL,
"grund": self._grund_planung(zeile, referenz),
"referenz": f"{naiv_utc(referenz):%Y-%m-%d}",
},
)
)
db.commit()
if lauf.gesamt:
_logger.info("Automatische Archivierung: %s", lauf.als_text())
return lauf, ereignisse
# ---------- Ansicht ----------
def ansicht(self, db: Session) -> list[dict]:
"""Vereinigte Archiv-Ansicht beider Tabellen, neueste zuerst."""
eintraege: list[dict] = []
for a in db.scalars(
select(ArchivNeuheit).order_by(
ArchivNeuheit.archiviert_am.desc(), ArchivNeuheit.id.desc()
)
):
eintraege.append(
{
"quelle": QUELLE_NEUHEITEN,
"id": a.id,
"titel": a.titel,
"verlag": a.verlag,
"autor": a.autor,
"bgg_id": a.bgg_id,
"jahr": a.erscheinungsjahr,
"ausgabe": "",
"status": a.status,
"rezensent_id": None,
"erstellt_am": a.erstellt_am,
"archiviert_am": a.archiviert_am,
"referenz_am": a.referenz_am,
"grund": a.grund,
}
)
for a in db.scalars(
select(ArchivPlanung).order_by(
ArchivPlanung.archiviert_am.desc(), ArchivPlanung.id.desc()
)
):
eintraege.append(
{
"quelle": QUELLE_PLANUNG,
"id": a.id,
"titel": a.titel,
"verlag": a.verlag,
"autor": a.autor,
"bgg_id": a.bgg_id,
"jahr": None,
"ausgabe": a.ausgabe,
"status": a.status,
"rezensent_id": a.rezensent_id,
"erstellt_am": a.erstellt_am,
"archiviert_am": a.archiviert_am,
"referenz_am": a.referenz_am,
"grund": a.grund,
}
)
# Neueste Archivierung zuerst; bei Gleichstand die höhere ID zuerst.
return sorted(
eintraege,
key=lambda e: (e["archiviert_am"] or datetime.min, e["id"]),
reverse=True,
)
# ---------- Wiederherstellen ----------
def wiederherstellen(
self,
db: Session,
quelle: str,
archiv_id: int,
*,
ersatz_rezensent_id: int | None = None,
jetzt: datetime | None = None,
) -> Wiederherstellung:
"""Schiebt einen archivierten Eintrag zurück in die aktive Liste.
Konflikte werden abgelehnt (keine Datenänderung im Archiv): existiert
in der Neuheitenliste bereits ein aktiver Eintrag mit derselben BGG-ID,
wird nicht wiederhergestellt. Existiert der zugeordnete Rezensent
eines Planungseintrags nicht mehr, übernimmt `ersatz_rezensent_id`
die Zuordnung (wird gemeldet und auditiert).
"""
zeitpunkt = naiv_utc(jetzt or self.jetzt())
if quelle == QUELLE_NEUHEITEN:
return self._wiederherstelle_neuheit(db, archiv_id, zeitpunkt)
if quelle == QUELLE_PLANUNG:
return self._wiederherstelle_planung(
db, archiv_id, zeitpunkt, ersatz_rezensent_id
)
return Wiederherstellung(False, f"Unbekannte Herkunft „{quelle}“.")
def _wiederherstelle_neuheit(
self, db: Session, archiv_id: int, zeitpunkt: datetime
) -> Wiederherstellung:
archiv = db.get(ArchivNeuheit, archiv_id)
if archiv is None:
return Wiederherstellung(
False, "Der archivierte Eintrag wurde nicht gefunden."
)
aktiv = Base.metadata.tables.get("neuheiten")
if aktiv is None:
return Wiederherstellung(
False,
"Die Neuheitenliste ist nicht verfügbar — "
"Wiederherstellen nicht möglich.",
)
if archiv.bgg_id is not None:
vorhanden = db.execute(
select(aktiv.c.id, aktiv.c.titel).where(
aktiv.c.bgg_id == archiv.bgg_id
)
).first()
if vorhanden is not None:
return Wiederherstellung(
False,
f"Es gibt bereits einen aktiven Neuheiten-Eintrag mit der "
f"BGG-ID {archiv.bgg_id} („{vorhanden.titel}“) — "
"Wiederherstellen abgebrochen.",
)
ergebnis = db.execute(
insert(aktiv).values(
titel=archiv.titel,
verlag=archiv.verlag,
autor=archiv.autor,
erscheinungsjahr=archiv.erscheinungsjahr,
bgg_id=archiv.bgg_id,
status=archiv.status,
quelle=archiv.quelle,
erstellt_am=archiv.erstellt_am,
aktualisiert_am=zeitpunkt,
)
)
neuer_id = ergebnis.inserted_primary_key[0]
titel = archiv.titel
db.delete(archiv)
db.commit()
return Wiederherstellung(
True,
f"{titel}“ wurde zurück in die Neuheitenliste verschoben.",
ziel=QUELLE_NEUHEITEN,
neuer_id=int(neuer_id),
titel=titel,
)
def _wiederherstelle_planung(
self,
db: Session,
archiv_id: int,
zeitpunkt: datetime,
ersatz_rezensent_id: int | None,
) -> Wiederherstellung:
archiv = db.get(ArchivPlanung, archiv_id)
if archiv is None:
return Wiederherstellung(
False, "Der archivierte Eintrag wurde nicht gefunden."
)
aktiv = Base.metadata.tables.get("planungsliste")
if aktiv is None:
return Wiederherstellung(
False,
"Die Planungsliste ist nicht verfügbar — "
"Wiederherstellen nicht möglich.",
)
rezensent_id = archiv.rezensent_id
rezensent_ersetzt = False
benutzer = Base.metadata.tables.get("users")
if rezensent_id is not None and benutzer is not None:
existiert = db.execute(
select(benutzer.c.id).where(benutzer.c.id == rezensent_id)
).first()
if existiert is None and ersatz_rezensent_id is not None:
rezensent_id = ersatz_rezensent_id
rezensent_ersetzt = True
ergebnis = db.execute(
insert(aktiv).values(
titel=archiv.titel,
verlag=archiv.verlag,
autor=archiv.autor,
bgg_id=archiv.bgg_id,
ausgabe=archiv.ausgabe,
rezensent_id=rezensent_id,
status=archiv.status,
notizen=archiv.notizen,
quelle=archiv.quelle,
pruefung=archiv.pruefung,
erstellt_am=archiv.erstellt_am,
aktualisiert_am=zeitpunkt,
)
)
neuer_id = ergebnis.inserted_primary_key[0]
titel = archiv.titel
db.delete(archiv)
db.commit()
meldung = f"{titel}“ wurde zurück in die Planungsliste verschoben."
if rezensent_ersetzt:
meldung += (
" Der ursprüngliche Rezensent existierte nicht mehr — "
"der Eintrag wurde dir zugeordnet."
)
return Wiederherstellung(
True,
meldung,
ziel=QUELLE_PLANUNG,
neuer_id=int(neuer_id),
rezensent_ersetzt=rezensent_ersetzt,
titel=titel,
)

View File

@@ -2,9 +2,120 @@
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-2xl">
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
in Version {{ version }} ist geladen.
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
<p class="text-slate-600 max-w-3xl mb-4">
Sicherungsdatei der Redaktion: Titel, deren Erscheinungs- bzw. Eintragsdatum
mehr als 12 Monate zur&uuml;ckliegt, werden t&auml;glich automatisch aus den
aktiven Listen hierher verschoben. Es geht nichts verloren &mdash; jeder
Eintrag l&auml;sst sich unver&auml;ndert zur&uuml;ck in die aktive Liste verschieben.
</p>
<div class="flex items-center gap-4 mb-4 text-xs text-slate-500">
<span class="inline-flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full {{ 'bg-emerald-500' if job_aktiv else 'bg-slate-300' }}"></span>
T&auml;glicher Archiv-Job: {{ 'aktiv' if job_aktiv else 'aus' }}
</span>
<span>{{ anzahl_neuheiten }} archivierte Neuheiten</span>
<span>{{ anzahl_planung }} archivierte Planungseintr&auml;ge</span>
</div>
{% if meldung %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div>
{% endif %}
{% if fehler %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div>
{% endif %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<form method="get" action="/archiv" class="flex flex-wrap items-end gap-3">
<div>
<label for="q" class="block text-xs font-medium text-slate-500 mb-1">Suche (Titel, Verlag, Autor)</label>
<input type="text" id="q" name="q" value="{{ q }}"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-72 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label for="quelle" class="block text-xs font-medium text-slate-500 mb-1">Herkunft</label>
<select id="quelle" name="quelle"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
onchange="this.form.submit()">
<option value="">Alle Listen</option>
<option value="neuheiten" {% if quelle_filter == 'neuheiten' %}selected{% endif %}>Neuheiten</option>
<option value="planung" {% if quelle_filter == 'planung' %}selected{% endif %}>Planung</option>
</select>
</div>
<button type="submit"
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
Suchen
</button>
{% if q or quelle_filter %}
<a href="/archiv" class="text-sm text-slate-500 hover:text-emerald-700 px-2 py-2">Zur&uuml;cksetzen</a>
{% endif %}
</form>
</div>
<p class="text-xs text-slate-400 mb-3">{{ anzahl }} Eintr&auml;ge im Archiv</p>
<div class="overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Spieltitel</th>
<th class="px-4 py-3 font-medium">Herkunft</th>
<th class="px-4 py-3 font-medium">Verlag</th>
<th class="px-4 py-3 font-medium">Jahr / Ausgabe</th>
<th class="px-4 py-3 font-medium">Status</th>
<th class="px-4 py-3 font-medium">Rezensent</th>
<th class="px-4 py-3 font-medium">Archiviert am</th>
<th class="px-4 py-3 font-medium">Grund</th>
<th class="px-4 py-3 font-medium">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for eintrag in eintraege %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-medium">
{{ eintrag.titel }}
{% if eintrag.bgg_id %}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap">
{% if eintrag.quelle == 'neuheiten' %}
<span class="inline-block px-2 py-0.5 rounded-full bg-sky-100 text-sky-800 text-xs">Neuheiten</span>
{% else %}
<span class="inline-block px-2 py-0.5 rounded-full bg-violet-100 text-violet-800 text-xs">Planung</span>
{% endif %}
</td>
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ eintrag.jahr if eintrag.jahr else (eintrag.ausgabe or '—') }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ status_anzeige.get(eintrag.status, eintrag.status) }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ namen.get(eintrag.rezensent_id, '—') if eintrag.rezensent_id else '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap text-slate-500">
{{ eintrag.archiviert_am.strftime('%d.%m.%Y %H:%M') if eintrag.archiviert_am else '—' }}
</td>
<td class="px-4 py-3 text-slate-500">{{ eintrag.grund }}</td>
<td class="px-4 py-3">
<form method="post" action="/archiv/{{ eintrag.quelle }}/{{ eintrag.id }}/wiederherstellen">
<button type="submit"
class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2.5 py-1.5 rounded-lg whitespace-nowrap"
title="Eintrag unverändert zurück in die aktive Liste verschieben">
&#8635; Wiederherstellen
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="9" class="px-4 py-8 text-center text-slate-400">
{% if q or quelle_filter %}
Keine Treffer f&uuml;r diese Suche.
{% else %}
Das Archiv ist leer — nichts zu sehen.
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -1,41 +1,538 @@
"""Plugin „erinnerung“ — Platzhalter gemäß Plugin-Vertrag.
"""Plugin „erinnerung“ — Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
Umfang:
- **Ausgaben verwalten** (nur Rolle Admin): Name/Titel + Redaktionsschluss-Datum,
mehrere Ausgaben parallel, anlegen/bearbeiten/löschen.
- **Automatische Erinnerung:** vier Wochen vor dem Redaktionsschluss erhält
jeder aktive Rezensent eine Benachrichtigung, der noch offene
Planungseinträge hat (Status ≠ „abgeschlossen“) oder gar keine — über das
Plugin „benachrichtigung“ (`send_notification`), Kanäle je Benutzer-Präferenz.
- **Geplanter Job:** APScheduler, täglich einmal (Uhrzeit per Env
`SPIELE_ERINNERUNG_JOB_UHRZEIT`, abschaltbar per `SPIELE_ERINNERUNG_JOB_AKTIV`).
- **Keine Doppelerinnerung:** pro (Ausgabe, Benutzer) wird genau einmal
erinnert; der Versand wird in `erinnerung_protokoll` protokolliert.
- **Audit-Log:** Ausgaben-CRUD und jede Erinnerung werden über das
Plugin „audit-log“ protokolliert (Erinnerung als „System“/`benachrichtigt`).
Geschäftsregeln liegen in `dienst.py` (einfrierbare Uhr: `heute()`),
Partner-Plugins werden wie überall über die Registry geholt; fehlen sie,
wird der Durchlauf ohne Versand/Protokoll übersprungen bzw. ohne Audit
fortgesetzt — der Vorgang bricht nie.
"""
from __future__ import annotations
from fastapi import Depends, Request
import asyncio
import logging
import sys
import threading
from dataclasses import dataclass, field
from datetime import date
from urllib.parse import quote
from redaktionskern.auth.deps import require_user
from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, NavEntry
from fastapi import Depends, Form, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_roles, require_user
from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext
# Bewusst `from .dienst import …` (wie `from .models import …` überall sonst):
# `from . import dienst` funktioniert unter dem dateibasierten Plugin-Loader
# nicht zuverlässig (Python 3.14 versucht dort einen absoluten Import der
# Namensraum-Oberpakete).
from .dienst import (
ERINNERUNGSFENSTER_TAGE,
alle_ausgaben,
bereits_erinnerte_ids,
empfaenger,
faellige_ausgaben,
heute,
im_erinnerungsfenster,
nachricht_text,
parse_datum,
tage_bis,
)
from .models import Ausgabe, ErinnerungsProtokoll
_logger = logging.getLogger("plugins.erinnerung")
MODUL_PRAEFIX = "spiele_redaktion_plugins"
JOB_ID = "erinnerungs-check"
MAX_MELDUNGS_LAENGE = 300
@dataclass
class Pruefbericht:
"""Ergebnis eines `pruefe_und_erinnere`-Durchlaufs."""
gepruefte_ausgaben: int = 0
versendet: list[dict] = field(default_factory=list)
#: (Ausgabe, Benutzer)-Paare im Fenster, die wegen Protokolleintrag
#: übersprungen wurden (Doppelschutz gegriffen).
uebersprungen: int = 0
@property
def anzahl_versendet(self) -> int:
return len(self.versendet)
def als_text(self) -> str:
if self.gepruefte_ausgaben == 0:
return "Keine Ausgaben mit offenem Erinnerungsfenster."
return (
f"{self.gepruefte_ausgaben} Ausgabe(n) im Erinnerungsfenster geprüft, "
f"{self.anzahl_versendet} Erinnerung(en) versendet, "
f"{self.uebersprungen} bereits erinnert."
)
def _tabellen_erstellen(conn) -> None:
"""Eigene Migration des Plugins: legt beide Tabellen an (idempotent)."""
Ausgabe.__table__.create(conn, checkfirst=True)
ErinnerungsProtokoll.__table__.create(conn, checkfirst=True)
class ErinnerungPlugin(BasePlugin):
name = "erinnerung"
title = "Erinnerungen"
description = "Erinnerungen vor Redaktionsschluss pro Ausgabe (Platzhalter)."
description = (
"Vier Wochen vor dem Redaktionsschluss pro Ausgabe Erinnerung an alle "
"Rezensenten mit offener (oder fehlender) Planung."
)
def __init__(self) -> None:
super().__init__()
self._scheduler = None # BackgroundScheduler, falls aktiviert
self._lauf_sperre = threading.Lock()
self._registry = None # wird bei Requests gemerkt (Partner-Auflösung)
self._routen_registrieren()
# ---------- Plugin-Vertrag ----------
def migrations(self) -> list[Migration]:
return [Migration(version="0001_ausgaben_und_protokoll", up=_tabellen_erstellen)]
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/erinnerung")]
def on_load(self, context: PluginContext) -> None:
super().on_load(context)
import os
if os.environ.get("SPIELE_ERINNERUNG_JOB_AKTIV", "1").strip() == "1":
self._scheduler_starten(os.environ.get("SPIELE_ERINNERUNG_JOB_UHRZEIT"))
def on_unload(self) -> None:
if self._scheduler is not None:
self._scheduler.shutdown(wait=False)
self._scheduler = None
super().on_unload()
# ---------- Partner-Plugins (benachrichtigung, audit-log) ----------
def _merke_registry(self, registry) -> None:
"""Merk sich die Registry, damit auch der Hintergrund-Job Partner
auflösen kann (auch ohne vorherigen Request via sys.modules-Fallback)."""
self._registry = registry
def _partner(self, name: str):
"""Löst ein Partner-Plugin auf: Registry zuerst, sonst Modul-Lookup.
Der Fallback über `sys.modules` deckt den Fall „Job läuft, bevor der
erste Request die Registry gemerkt hat“ ab (Verzeichnis-Plugins).
"""
if self._registry is not None:
partner = self._registry.get(name)
if partner is not None:
return partner
modul = sys.modules.get(f"{MODUL_PRAEFIX}.{name}")
return getattr(modul, "plugin", None) if modul else None
# ---------- Öffentliche API für andere Plugins/Tests ----------
async def pruefe_und_erinnere(
self,
jetzt: date | None = None,
*,
benachrichtigung=None,
audit=None,
) -> Pruefbericht:
"""Prüft alle Ausgaben gegen die eingefrierbare Uhr und erinnert.
`jetzt` überschreibt die Uhr (Tests); ohne Angabe gilt `heute()`.
Partner-Plugins können übergeben werden, sonst werden sie über die
Registry aufgelöst. Der Durchlauf wirft nicht für einzelne Empfänger:
Fehlschläge werden protokolliert und im Bericht gezählt.
"""
assert self.context is not None
if benachrichtigung is None:
benachrichtigung = self._partner("benachrichtigung")
if audit is None:
audit = self._partner("audit-log")
pruef_tag = jetzt if jetzt is not None else heute()
bericht = Pruefbericht()
with self.context.session_factory() as db:
for ausgabe in faellige_ausgaben(db, pruef_tag):
bericht.gepruefte_ausgaben += 1
tage_rest = tage_bis(ausgabe.redaktionsschluss, pruef_tag)
schon_erinnert = bereits_erinnerte_ids(db, ausgabe.id)
text = nachricht_text(ausgabe, tage_rest)
for empfaenger_user in empfaenger(db):
if empfaenger_user.id in schon_erinnert:
bericht.uebersprungen += 1
continue
kanaele: list[str] = []
if benachrichtigung is not None:
try:
zustellbericht = await benachrichtigung.send_notification(
empfaenger_user,
f"Redaktionsschluss: {ausgabe.name}",
text,
"erinnerung",
)
kanaele = list(getattr(zustellbericht, "zugestellt", []))
except Exception:
# Ein Kanalfehler darf den Durchlauf nicht abbrechen;
# ohne Protokolleintrag würde der nächste Lauf es
# erneut (und hoffentlich erfolgreich) versuchen.
_logger.exception(
"Erinnerung an %s (Ausgabe %s) fehlgeschlagen.",
empfaenger_user.username,
ausgabe.name,
)
continue
db.add(
ErinnerungsProtokoll(
ausgabe_id=ausgabe.id,
user_id=empfaenger_user.id,
kanaele=",".join(kanaele),
)
)
db.commit()
bericht.versendet.append(
{
"ausgabe": ausgabe.name,
"ausgabe_id": ausgabe.id,
"benutzer": empfaenger_user.username,
"user_id": empfaenger_user.id,
"kanaele": kanaele,
"tage_rest": tage_rest,
}
)
if audit is not None:
try:
await audit.log(
None,
"benachrichtigt",
"erinnerung",
empfaenger_user.id,
{
"ausgabe": ausgabe.name,
"ausgabe_id": ausgabe.id,
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
"tage_rest": tage_rest,
"kanaele": kanaele,
},
)
except Exception:
_logger.exception(
"Audit-Log-Eintrag für Erinnerung fehlgeschlagen."
)
return bericht
def pruefe_und_erinnere_sync(
self,
jetzt: date | None = None,
*,
benachrichtigung=None,
audit=None,
) -> Pruefbericht:
"""Synchrone Variante von `pruefe_und_erinnere` (wie audit.log_sync).
Für Kontexte ohne laufende Event-Loop (APScheduler-Thread, Tests).
Darf nicht aus einer async-Funktion heraus aufgerufen werden.
"""
return asyncio.run(
self.pruefe_und_erinnere(
jetzt, benachrichtigung=benachrichtigung, audit=audit
)
)
# ---------- Hintergrund-Job ----------
def _scheduler_starten(self, uhrzeit_roh: str | None) -> None:
from apscheduler.schedulers.background import BackgroundScheduler
stunde, minute = 8, 0
roh = (uhrzeit_roh or "08:00").strip()
try:
teile = roh.split(":")
stunde = max(0, min(23, int(teile[0])))
minute = max(0, min(59, int(teile[1]))) if len(teile) > 1 else 0
except (ValueError, IndexError):
stunde, minute = 8, 0
scheduler = BackgroundScheduler()
scheduler.add_job(
self._job,
trigger="cron",
hour=stunde,
minute=minute,
id=JOB_ID,
replace_existing=True,
)
scheduler.start()
self._scheduler = scheduler
_logger.info(
"Erinnerungs-Check aktiv: täglich um %02d:%02d Uhr.", stunde, minute
)
def _job(self) -> None:
"""Hintergrund-Job: tägliche Prüfung (synchroner Kontext, eigener Thread)."""
if not self._lauf_sperre.acquire(blocking=False):
_logger.info("Erinnerungs-Check läuft bereits — Durchlauf übersprungen.")
return
try:
bericht = self.pruefe_und_erinnere_sync()
_logger.info("Erinnerungs-Check abgeschlossen: %s", bericht.als_text())
except Exception:
_logger.exception("Erinnerungs-Check fehlgeschlagen.")
finally:
self._lauf_sperre.release()
# ---------- Routen ----------
@staticmethod
def _meldung(url: str, text: str, art: str = "meldung") -> RedirectResponse:
return RedirectResponse(f"{url}?{art}={quote(text)}", status_code=303)
def _routen_registrieren(self) -> None:
@self.router.get("/erinnerung")
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
def seite(
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
meldung: str = "",
fehler: str = "",
):
"""Übersicht: Ausgaben mit Fristen; Admins verwalten und sehen
den Erinnerungsstatus (wer wurde wann erinnert)."""
self._merke_registry(request.app.state.registry)
ist_admin = user.role == Role.ADMIN.value
pruef_tag = heute()
ausgaben = alle_ausgaben(db)
zeilen = []
for ausgabe in ausgaben:
tage_rest = tage_bis(ausgabe.redaktionsschluss, pruef_tag)
zeilen.append(
{
"ausgabe": ausgabe,
"tage_rest": tage_rest,
"im_fenster": im_erinnerungsfenster(
ausgabe.redaktionsschluss, pruef_tag
),
"erinnert_anzahl": db.scalar(
select(func.count())
.select_from(ErinnerungsProtokoll)
.where(ErinnerungsProtokoll.ausgabe_id == ausgabe.id)
)
or 0,
}
)
protokoll = []
if ist_admin:
namen = {
benutzer.id: (benutzer.display_name or benutzer.username)
for benutzer in db.scalars(select(User)).all()
}
eintraege = list(
db.scalars(
select(ErinnerungsProtokoll).order_by(
ErinnerungsProtokoll.erstellt_am.desc(),
ErinnerungsProtokoll.id.desc(),
)
).all()
)
ausgaben_namen = {a.id: a.name for a in ausgaben}
for eintrag in eintraege:
protokoll.append(
{
"eintrag": eintrag,
"ausgabe_name": ausgaben_namen.get(eintrag.ausgabe_id, ""),
"benutzer": namen.get(eintrag.user_id, ""),
}
)
return self.context.templates.TemplateResponse(
request=request,
name="erinnerung/index.html",
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
"zeilen": zeilen,
"ist_admin": ist_admin,
"protokoll": protokoll,
"fenster_tage": ERINNERUNGSFENSTER_TAGE,
"meldung": meldung[:MAX_MELDUNGS_LAENGE],
"fehler": fehler[:MAX_MELDUNGS_LAENGE],
},
)
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/erinnerung")]
@self.router.post("/erinnerung/ausgaben")
def ausgabe_anlegen(
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_roles(Role.ADMIN.value)),
name: str = Form(""),
redaktionsschluss: str = Form(""),
):
self._merke_registry(request.app.state.registry)
roh_name = name.strip()
if not roh_name:
return self._meldung("/erinnerung", "Bitte einen Ausgaben-Namen angeben.", "fehler")
try:
schluss = parse_datum(redaktionsschluss)
except ValueError:
return self._meldung(
"/erinnerung",
"Ungültiges Datum — bitte als JJJJ-MM-TT angeben.",
"fehler",
)
if schluss is None:
return self._meldung("/erinnerung", "Bitte ein Redaktionsschluss-Datum angeben.", "fehler")
ausgabe = Ausgabe(name=roh_name, redaktionsschluss=schluss)
db.add(ausgabe)
db.commit()
self._audit_sync(request, user, "erstellt", ausgabe.id, {
"name": ausgabe.name,
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
})
return self._meldung(
"/erinnerung",
f"Ausgabe „{ausgabe.name}“ angelegt (Redaktionsschluss "
f"{ausgabe.redaktionsschluss.strftime('%d.%m.%Y')}).",
)
@self.router.get("/erinnerung/ausgaben/{ausgabe_id}/bearbeiten")
def bearbeiten_formular(
request: Request,
ausgabe_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_roles(Role.ADMIN.value)),
):
ausgabe = db.get(Ausgabe, ausgabe_id)
if ausgabe is None:
return self._meldung("/erinnerung", "Ausgabe nicht gefunden.", "fehler")
return self.context.templates.TemplateResponse(
request=request,
name="erinnerung/bearbeiten.html",
context={
"user": user,
"titel_headline": f"Ausgabe bearbeiten — {ausgabe.name}",
"ausgabe": ausgabe,
},
)
@self.router.post("/erinnerung/ausgaben/{ausgabe_id}/bearbeiten")
def bearbeiten_speichern(
request: Request,
ausgabe_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_roles(Role.ADMIN.value)),
name: str = Form(""),
redaktionsschluss: str = Form(""),
):
self._merke_registry(request.app.state.registry)
ausgabe = db.get(Ausgabe, ausgabe_id)
if ausgabe is None:
return self._meldung("/erinnerung", "Ausgabe nicht gefunden.", "fehler")
roh_name = name.strip()
if not roh_name:
return self._meldung("/erinnerung", "Bitte einen Ausgaben-Namen angeben.", "fehler")
try:
schluss = parse_datum(redaktionsschluss)
except ValueError:
return self._meldung(
"/erinnerung",
"Ungültiges Datum — bitte als JJJJ-MM-TT angeben.",
"fehler",
)
if schluss is None:
return self._meldung("/erinnerung", "Bitte ein Redaktionsschluss-Datum angeben.", "fehler")
alt = {
"name": ausgabe.name,
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
}
ausgabe.name = roh_name
ausgabe.redaktionsschluss = schluss
db.commit()
neu = {
"name": ausgabe.name,
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
}
geaendert = [k for k in alt if alt[k] != neu[k]]
if geaendert:
self._audit_sync(request, user, "geaendert", ausgabe.id, {
"alt": {k: alt[k] for k in geaendert},
"neu": {k: neu[k] for k in geaendert},
})
return self._meldung("/erinnerung", f"Ausgabe „{ausgabe.name}“ gespeichert.")
@self.router.post("/erinnerung/ausgaben/{ausgabe_id}/loeschen")
def ausgabe_loeschen(
request: Request,
ausgabe_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_roles(Role.ADMIN.value)),
):
self._merke_registry(request.app.state.registry)
ausgabe = db.get(Ausgabe, ausgabe_id)
if ausgabe is None:
return self._meldung("/erinnerung", "Ausgabe nicht gefunden.", "fehler")
snapshot = {
"name": ausgabe.name,
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
}
# Protokollzeilen dieser Ausgabe mitlöschen (FK ohne Cascade).
db.execute(
delete(ErinnerungsProtokoll).where(
ErinnerungsProtokoll.ausgabe_id == ausgabe_id
)
)
db.delete(ausgabe)
db.commit()
self._audit_sync(request, user, "geloescht", ausgabe_id, snapshot)
return self._meldung("/erinnerung", f"Ausgabe „{snapshot['name']}“ gelöscht.")
@self.router.post("/erinnerung/pruefen")
async def jetzt_pruefen(
request: Request,
user: User = Depends(require_roles(Role.ADMIN.value)),
):
"""Manueller Durchlauf (gleiche Logik wie der tägliche Job)."""
self._merke_registry(request.app.state.registry)
bericht = await self.pruefe_und_erinnere()
return self._meldung("/erinnerung", f"Prüfung abgeschlossen: {bericht.als_text()}")
# ---------- Audit (synchrone Routen → log_sync des audit-log-Plugins) ----------
def _audit_sync(self, request: Request, actor, action: str, objekt_id, details: dict) -> None:
audit = request.app.state.registry.get("audit-log")
if audit is None:
return
try:
audit.log_sync(actor, action, "ausgabe", objekt_id, details)
except Exception:
_logger.exception("Audit-Log-Eintrag für Ausgabe fehlgeschlagen.")
plugin = ErinnerungPlugin()

View File

@@ -0,0 +1,148 @@
"""Geschäftslogik des Plugins „erinnerung“.
Alle Regeln liegen bewusst hier (und nicht im Kern):
- **Fenster:** Vier Wochen (28 Tage) vor dem Redaktionsschluss öffnet das
Erinnerungsfenster; es gilt bis zum Redaktionsschluss (inklusive). Danach
wird nicht mehr erinnert — vor dem Fenster erst recht nicht.
- **Empfänger:** alle aktiven Rezensenten (Rolle „rezensent“), 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.
- **Uhr:** `heute()` liefert das aktuelle Datum und ist die einzige
Zeitquelle der Prüfung — Tests frieren sie ein (freezegun oder
Monkeypatch), der geplante Job und die Routen nutzen sie unverändert.
Die Planungsliste wird nicht importiert, sondern über die gemeinsame
SQLAlchemy-Metadata angesprochen (wie im planung-Plugin mit der
Neuheiten-Tabelle): Fehlt das planung-Plugin, entfällt die Unterscheidung
„hat offene Einträge“ und alle Rezensenten werden erinnert.
"""
from __future__ import annotations
from datetime import date
from sqlalchemy import select
from sqlalchemy.orm import Session
from redaktionskern.auth.models import Role, User
from redaktionskern.db import Base
from .models import Ausgabe, ErinnerungsProtokoll
#: Länge des Erinnerungsfensters in Tagen (4 Wochen vor Redaktionsschluss).
ERINNERUNGSFENSTER_TAGE = 4 * 7
#: Statuswert des planung-Plugins für „fertig“ (Kopie ohne Import-Abhängigkeit).
STATUS_ABGESCHLOSSEN = "abgeschlossen"
def heute() -> date:
"""Aktuelles Datum — die eingefrierbare Uhr dieses Plugins."""
return date.today()
def tage_bis(schluss: date, tag: date) -> int:
"""Tage vom Prüfdatum bis zum Redaktionsschluss (negativ = vorbei)."""
return (schluss - tag).days
def im_erinnerungsfenster(schluss: date, tag: date) -> bool:
"""True, wenn am Prüfdatum erinnert werden darf.
Fenster: `schluss - 28 Tage` … `schluss` (jeweils inklusive).
"""
return 0 <= tage_bis(schluss, tag) <= ERINNERUNGSFENSTER_TAGE
def parse_datum(roh: str) -> date | None:
"""Parst ein ISO-Datum (JJJJ-MM-TT); None bei leer, ValueError bei Unsinn."""
roh = (roh or "").strip()
if not roh:
return None
return date.fromisoformat(roh)
def alle_ausgaben(db: Session) -> list[Ausgabe]:
"""Alle Ausgaben, frühester Redaktionsschluss zuerst."""
return list(
db.scalars(select(Ausgabe).order_by(Ausgabe.redaktionsschluss, Ausgabe.id)).all()
)
def faellige_ausgaben(db: Session, tag: date) -> list[Ausgabe]:
"""Ausgaben, deren Erinnerungsfenster am Prüfdatum offen ist."""
return [
ausgabe
for ausgabe in alle_ausgaben(db)
if im_erinnerungsfenster(ausgabe.redaktionsschluss, tag)
]
def bereits_erinnerte_ids(db: Session, ausgabe_id: int) -> set[int]:
"""Benutzer, die für diese Ausgabe schon erinnert wurden (Doppelschutz)."""
return set(
db.scalars(
select(ErinnerungsProtokoll.user_id).where(
ErinnerungsProtokoll.ausgabe_id == ausgabe_id
)
).all()
)
def planungs_statistik(db: Session) -> tuple[set[int], set[int]]:
"""Liefert (IDs mit offenen Einträgen, IDs mit Einträgen überhaupt)."""
tabelle = Base.metadata.tables.get("planungsliste")
if tabelle is None:
# planung-Plugin nicht geladen → keine Unterscheidung möglich.
return set(), set()
offene = {
zeile[0]
for zeile in db.execute(
select(tabelle.c.rezensent_id)
.where(tabelle.c.status != STATUS_ABGESCHLOSSEN)
.distinct()
).all()
}
alle = {
zeile[0]
for zeile in db.execute(select(tabelle.c.rezensent_id).distinct()).all()
}
return offene, alle
def empfaenger(db: Session) -> list[User]:
"""Die zu erinnierenden Rezensenten (siehe Moduldokumentation).
Rezensent mit offenen Einträgen → erinnert.
Rezensent ohne jeden Eintrag → erinnert.
Rezensent nur mit abgeschlossenen Einträgen → nicht erinnert.
"""
rezensenten = list(
db.scalars(
select(User)
.where(User.role == Role.REZENSENT.value, User.active.is_(True))
.order_by(User.username)
).all()
)
offene, alle = planungs_statistik(db)
return [
benutzer
for benutzer in rezensenten
if benutzer.id not in alle or benutzer.id in offene
]
def nachricht_text(ausgabe: Ausgabe, tage_rest: int) -> str:
"""Text der Erinnerungs-Benachrichtigung (deutsch)."""
if tage_rest == 0:
rest = "heute ist der Redaktionsschluss"
elif tage_rest == 1:
rest = "morgen ist der Redaktionsschluss"
else:
rest = f"der Redaktionsschluss ist in {tage_rest} Tagen"
return (
f"Ausgabe „{ausgabe.name}“: {rest} ({ausgabe.redaktionsschluss.strftime('%d.%m.%Y')}).\n"
"Bitte schließe deine Planungseinträge ab oder trage noch fehlende Titel "
"unter „Planung“ nach."
)

View File

@@ -0,0 +1,64 @@
"""Datenmodelle des Plugins „erinnerung“.
Zwei eigene Tabellen (SQLite ↔ Postgres portabel), der Kern bleibt unberührt:
- `erinnerung_ausgabe` — eine Magazin-Ausgabe mit Redaktionsschluss-Datum.
Mehrere Ausgaben laufen parallel; der Name ist frei wählbar (z. B. „3/2025“).
- `erinnerung_protokoll` — protokolliert jede versendete Erinnerung
(Ausgabe + Benutzer). Die Eindeutigkeit von (ausgabe_id, user_id) stellt
auf Datenbankebene sicher, dass niemand zweimal pro Ausgabe erinnert wird.
Keine ORM-Indizes: Bei erneuter Ausführung des Moduls würde ein deklarativ
definierter Index doppelt im Table-Objekt landen und die Migration würde ihn
zweimal anlegen (gleiche Begründung wie im benachrichtigung-Plugin).
"""
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from redaktionskern.db import Base
class Ausgabe(Base):
"""Eine Magazin-Ausgabe mit ihrem Redaktionsschluss-Datum."""
__tablename__ = "erinnerung_ausgabe"
__table_args__ = {"extend_existing": True}
id: Mapped[int] = mapped_column(primary_key=True)
#: Name/Titel der Ausgabe, z. B. „3/2025“ oder „Sommer-Sonderheft“.
name: Mapped[str] = mapped_column(String(200))
#: Redaktionsschluss (Datum, ohne Uhrzeit).
redaktionsschluss: Mapped[date] = mapped_column(Date)
erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
aktualisiert_am: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
return f"<Ausgabe {self.name!r} (Redaktionsschluss {self.redaktionsschluss})>"
class ErinnerungsProtokoll(Base):
"""Eine versendete Erinnerung: wer wurde zu welcher Ausgabe wann erinnert."""
__tablename__ = "erinnerung_protokoll"
__table_args__ = (
UniqueConstraint("ausgabe_id", "user_id", name="uq_erinnerung_ausgabe_user"),
{"extend_existing": True},
)
id: Mapped[int] = mapped_column(primary_key=True)
ausgabe_id: Mapped[int] = mapped_column(ForeignKey("erinnerung_ausgabe.id"))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
#: Zugestellte Kanäle (Komma-liste, z. B. „email,inapp“) — wie vom
#: benachrichtigung-Plugin gemeldet; leer, falls kein Kanal zustellte.
kanaele: Mapped[str] = mapped_column(String(200), default="")
#: Wann wurde die Erinnerung ausgelöst.
erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
return f"<ErinnerungsProtokoll Ausgabe={self.ausgabe_id} User={self.user_id}>"

View File

@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">Ausgabe bearbeiten</h1>
<p class="text-slate-600 mb-4">&bdquo;{{ ausgabe.name }}&ldquo; &middot; Redaktionsschluss:
{{ ausgabe.redaktionsschluss.strftime('%d.%m.%Y') }}
</p>
<form method="post" action="/erinnerung/ausgaben/{{ ausgabe.id }}/bearbeiten"
class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4">
<div>
<label for="name" class="block text-xs font-medium text-slate-500 mb-1">Name/Titel der Ausgabe *</label>
<input type="text" id="name" name="name" required value="{{ ausgabe.name }}"
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label for="redaktionsschluss" class="block text-xs font-medium text-slate-500 mb-1">Redaktionsschluss *</label>
<input type="date" id="redaktionsschluss" name="redaktionsschluss" required value="{{ ausgabe.redaktionsschluss.isoformat() }}"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-48 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div class="flex items-center gap-3">
<button type="submit"
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-lg text-sm">
Speichern
</button>
<a href="/erinnerung" class="text-sm text-slate-500 hover:text-slate-800 underline">Abbrechen</a>
</div>
</form>
<p class="text-xs text-slate-400 mt-3 max-w-2xl">
Hinweis: Bereits protokollierte Erinnerungen bleiben bestehen &mdash; pro Ausgabe
und Benutzer wird nur einmal erinnert.
</p>
{% endblock %}

View File

@@ -2,9 +2,143 @@
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-2xl">
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
in Version {{ version }} ist geladen.
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
<p class="text-slate-600 mb-4">
Redaktionsschl&uuml;sse der kommenden Ausgaben. Vier Wochen vor jedem
Redaktionsschluss erhalten alle Rezensenten eine Erinnerung, die noch offene
Planungseinträge haben &mdash; oder noch gar keine eingetragen haben.
</p>
{% if meldung %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div>
{% endif %}
{% if fehler %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div>
{% endif %}
{% if ist_admin %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<h2 class="font-semibold mb-3">Neue Ausgabe anlegen</h2>
<form method="post" action="/erinnerung/ausgaben" class="flex flex-wrap items-end gap-3">
<div>
<label for="name" class="block text-xs font-medium text-slate-500 mb-1">Name/Titel der Ausgabe *</label>
<input type="text" id="name" name="name" required placeholder="z. B. 3/2025"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label for="redaktionsschluss" class="block text-xs font-medium text-slate-500 mb-1">Redaktionsschluss *</label>
<input type="date" id="redaktionsschluss" name="redaktionsschluss" required
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-48 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<button type="submit"
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
Ausgabe anlegen
</button>
</form>
<p class="text-xs text-slate-400 mt-2">Mehrere Ausgaben k&ouml;nnen parallel laufen.</p>
</div>
{% endif %}
<div class="overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm mb-6">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Ausgabe</th>
<th class="px-4 py-3 font-medium">Redaktionsschluss</th>
<th class="px-4 py-3 font-medium">Restzeit</th>
<th class="px-4 py-3 font-medium">Erinnerungen</th>
{% if ist_admin %}<th class="px-4 py-3 font-medium">Aktionen</th>{% endif %}
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for zeile in zeilen %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-medium">{{ zeile.ausgabe.name }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ zeile.ausgabe.redaktionsschluss.strftime('%d.%m.%Y') }}</td>
<td class="px-4 py-3 whitespace-nowrap">
{% if zeile.tage_rest > fenster_tage %}
<span class="inline-block px-2 py-0.5 rounded-full bg-slate-100 text-slate-600 text-xs">
noch {{ zeile.tage_rest }} Tage &middot; Erinnerung in {{ zeile.tage_rest - fenster_tage }} Tagen
</span>
{% elif zeile.im_fenster %}
<span class="inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs">
Erinnerungsphase &mdash; {{ zeile.tage_rest }} Tag{{ 'e' if zeile.tage_rest != 1 }} bis zum Redaktionsschluss
</span>
{% else %}
<span class="inline-block px-2 py-0.5 rounded-full bg-slate-200 text-slate-500 text-xs">Redaktionsschluss vorbei</span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-slate-600">
{{ zeile.erinnert_anzahl }} erinnert
</td>
{% if ist_admin %}
<td class="px-4 py-3">
<div class="flex flex-wrap items-center gap-1.5">
<a href="/erinnerung/ausgaben/{{ zeile.ausgabe.id }}/bearbeiten"
class="text-xs border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-2 py-1 rounded-lg whitespace-nowrap">Bearbeiten</a>
<form method="post" action="/erinnerung/ausgaben/{{ zeile.ausgabe.id }}/loeschen">
<button type="submit" class="text-xs border border-red-200 text-red-600 hover:bg-red-50 px-2 py-1 rounded-lg whitespace-nowrap">L&ouml;schen</button>
</form>
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr>
<td colspan="{{ 5 if ist_admin else 4 }}" class="px-4 py-8 text-center text-slate-400">
Noch keine Ausgaben angelegt.
{% if ist_admin %}Lege oben die erste Ausgabe mit ihrem Redaktionsschluss an.{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if ist_admin %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6 flex flex-wrap items-center gap-3">
<div class="mr-auto">
<h2 class="font-semibold">Sofort pr&uuml;fen</h2>
<p class="text-xs text-slate-500">F&uuml;hrt denselben Check aus wie der t&auml;gliche Hintergrund-Job ({{ fenster_tage }} Tage vor dem Redaktionsschluss). Doppelerinnerungen sind ausgeschlossen.</p>
</div>
<form method="post" action="/erinnerung/pruefen">
<button type="submit"
class="bg-sky-600 hover:bg-sky-700 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
Jetzt pr&uuml;fen und erinnern
</button>
</form>
</div>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<h2 class="font-semibold mb-3">Erinnerungsstatus</h2>
<p class="text-xs text-slate-500 mb-3">Wer wurde f&uuml;r welche Ausgabe wann erinnert? Pro Ausgabe und Benutzer wird h&ouml;chstens einmal erinnert.</p>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Ausgabe</th>
<th class="px-4 py-3 font-medium">Rezensent</th>
<th class="px-4 py-3 font-medium">Kan&auml;le</th>
<th class="px-4 py-3 font-medium">Erinnert am</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for eintrag in protokoll %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-medium">{{ eintrag.ausgabe_name }}</td>
<td class="px-4 py-3">{{ eintrag.benutzer }}</td>
<td class="px-4 py-3 text-slate-600">
{% if eintrag.eintrag.kanaele %}{{ eintrag.eintrag.kanaele.replace(',', ', ') }}{% else %}&mdash;{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-slate-500">
{{ eintrag.eintrag.erstellt_am.strftime('%d.%m.%Y %H:%M') if eintrag.eintrag.erstellt_am else '&mdash;' }}
</td>
</tr>
{% else %}
<tr><td colspan="4" class="px-4 py-8 text-center text-slate-400">Noch keine Erinnerungen versendet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,41 +1,212 @@
"""Plugin „export“ — Platzhalter gemäß Plugin-Vertrag.
"""Plugin „export“ — Listen als CSV und PDF herunterladen.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
Exportiert die drei Redaktionslisten **Neuheiten**, **Planung** und
**Archiv**:
- **CSV** (stdlib ``csv``): Semikolon-getrennt für Excel mit deutschem
Gebietsschema, UTF-8 mit Byte-Order-Mark.
- **PDF** (WeasyPrint): formatierte Tabellen mit deutscher Kopfzeile,
Erstell-Datum und Seitenzahlen; die Kopfzeile wiederholt sich auf jeder
Seite. WeasyPrint wird erst beim PDF-Download importiert — fehlen die
System-Bibliotheken, bleiben App und CSV-Export voll nutzbar.
Rollen: Admin/Redakteur dürfen alles exportieren, Rezensenten nur die
Planungsliste. Die Prüfung passiert serverseitig am Download-Endpunkt;
auf der Export-Seite werden nicht erlaubte Formate ausgegraut angezeigt.
Jeder Download wird best effort ins Audit-Log geschrieben (Aktion
„exportiert“). Eigene Routen/Templates, keine Logik im Kern — die
Datenzugriffe laufen über die gemeinsame SQLAlchemy-Metadata, ohne
Partner-Plugins zu importieren (siehe exporte.py).
"""
from __future__ import annotations
from fastapi import Depends, Request
import logging
from datetime import datetime
from redaktionskern.auth.deps import require_user
from redaktionskern.auth.models import User
from fastapi import Depends, HTTPException, Request
from fastapi.responses import RedirectResponse, StreamingResponse
from sqlalchemy.orm import Session
from redaktionskern.auth.deps import AccessDenied, get_db, require_user
from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, NavEntry
from .exporte import LISTEN, csv_erzeugen, dateiname, pdf_erzeugen
_logger = logging.getLogger("plugins.export")
REDAKTION = (Role.ADMIN.value, Role.REDAKTEUR.value)
#: Medien-Typen der Downloads.
MEDIA_CSV = "text/csv; charset=utf-8"
MEDIA_PDF = "application/pdf"
def _als_stream(daten: bytes, blockgroesse: int = 64 * 1024):
"""Zerlegt ein fertiges Dokument in Blöcke für die StreamingResponse."""
for start in range(0, len(daten), blockgroesse):
yield daten[start : start + blockgroesse]
def _download(daten: bytes, medientyp: str, name: str) -> StreamingResponse:
return StreamingResponse(
_als_stream(daten),
media_type=medientyp,
headers={"Content-Disposition": f'attachment; filename="{name}"'},
)
class ExportPlugin(BasePlugin):
name = "export"
title = "Export"
description = "Listen als CSV und PDF (Platzhalter)."
description = (
"Listen (Neuheiten, Planung, Archiv) als CSV für Excel oder als "
"PDF zum Drucken herunterladen."
)
def __init__(self) -> None:
super().__init__()
self._routen_registrieren()
# ---------- Plugin-Vertrag ----------
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/export")]
# ---------- Hilfsfunktionen ----------
@staticmethod
def _definition(liste: str):
definition = LISTEN.get((liste or "").lower())
if definition is None:
raise HTTPException(status_code=404, detail="Unbekannte Liste.")
return definition
@staticmethod
def _pruefe_zugriff(user: User, definition) -> None:
"""Admin/Redakteur dürfen alles; Rezensenten nur die Planungsliste."""
if definition.nur_redaktion and user.role not in REDAKTION:
raise AccessDenied(
f"Die Liste „{definition.titel}“ darf nur von Admins und "
"Redakteuren exportiert werden."
)
def _lade_zeilen(self, db: Session, definition):
"""Holt die Zeilen über den Lader der Liste (None = Partner fehlt)."""
return definition.lader(db)
def _audit_export(
self,
request: Request,
user: User,
liste: str,
format_name: str,
anzahl: int,
) -> None:
"""Schreibt einen Audit-Eintrag „exportiert“ (best effort)."""
audit = request.app.state.registry.get("audit-log")
if audit is None:
return
try:
audit.log_sync(
user,
"exportiert",
f"liste-{liste}",
None,
details={"format": format_name, "anzahl": anzahl},
ip_adresse=request.client.host if request.client else None,
)
except Exception:
_logger.exception("Audit-Log-Eintrag für Export fehlgeschlagen.")
# ---------- Routen ----------
def _routen_registrieren(self) -> None:
@self.router.get("/export")
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
def seite(
request: Request,
db: Session = Depends(get_db),
fehler: str = "",
user: User = Depends(require_user),
):
"""Export-Seite: Karten je Liste mit Format-Buttons."""
karten = []
for definition in LISTEN.values():
zeilen = self._lade_zeilen(db, definition)
karten.append(
{
"key": definition.key,
"titel": definition.titel,
"beschreibung": definition.beschreibung,
"erlaubt": not definition.nur_redaktion or user.role in REDAKTION,
"verfuegbar": zeilen is not None,
"anzahl": len(zeilen) if zeilen is not None else None,
}
)
return self.context.templates.TemplateResponse(
request=request,
name="export/index.html",
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
"karten": karten,
"ist_redaktion": user.role in REDAKTION,
"fehler": fehler[:300],
},
)
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/export")]
@self.router.get("/export/{liste}/csv")
def csv_download(
request: Request,
liste: str,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
"""Liste als Semikolon-getrennte CSV (UTF-8 mit BOM) herunterladen."""
definition = self._definition(liste)
self._pruefe_zugriff(user, definition)
zeilen = self._lade_zeilen(db, definition)
if zeilen is None:
return RedirectResponse("/export?fehler=Liste+nicht+verfügbar", status_code=303)
daten = csv_erzeugen(definition.spalten, zeilen)
self._audit_export(request, user, definition.key, "csv", len(zeilen))
return _download(
daten,
MEDIA_CSV,
dateiname(definition.key, "csv"),
)
@self.router.get("/export/{liste}/pdf")
def pdf_download(
request: Request,
liste: str,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
"""Liste als PDF (WeasyPrint) herunterladen."""
definition = self._definition(liste)
self._pruefe_zugriff(user, definition)
zeilen = self._lade_zeilen(db, definition)
if zeilen is None:
return RedirectResponse("/export?fehler=Liste+nicht+verfügbar", status_code=303)
try:
daten = pdf_erzeugen(definition.titel, definition.spalten, zeilen)
except Exception:
# Fehlende System-Bibliotheken (Pango & Co.) dürfen nur diesen
# Download betreffen — App und CSV-Export bleiben nutzbar.
_logger.exception("PDF-Erzeugung für Liste '%s' fehlgeschlagen.", definition.key)
return RedirectResponse(
"/export?fehler=PDF-Erzeugung+fehlgeschlagen+%28WeasyPrint%2FSystem-Bibliotheken+fehlen%3F%29",
status_code=303,
)
self._audit_export(request, user, definition.key, "pdf", len(zeilen))
return _download(
daten,
MEDIA_PDF,
dateiname(definition.key, "pdf", datetime.now()),
)
plugin = ExportPlugin()

266
plugins/export/exporte.py Normal file
View File

@@ -0,0 +1,266 @@
"""Export-Logik des Plugins „export“ — Listen als CSV und PDF.
Diese Datei enthält die komplette Fachlogik des Export-Plugins:
- **CSV** über das Standardmodul ``csv``, Semikolon-getrennt für Excel mit
deutschem Gebietsschema, kodiert als UTF-8 mit Byte-Order-Mark (BOM).
- **PDF** über WeasyPrint: sauber formatierte Tabellen mit deutscher
Kopfzeile (auf jeder Seite wiederholt), Erstell-Datum und Seitenzahlen.
WeasyPrint wird erst beim PDF-Aufruf importiert (lazy) — ohne die
System-Bibliotheken (Pango usw.) bleiben CSV-Exporte und die App nutzbar.
Die Datenzugriffe lesen die Tabellen der Partner-Plugins über die gemeinsame
SQLAlchemy-Metadata (``Base.metadata.tables``) — genau wie das planung-Plugin
es vorlebt. Fehlt ein Partner-Plugin, meldet der jeweilige Lader ``None``;
die Liste erscheint dann im Export als nicht verfügbar.
"""
from __future__ import annotations
import csv
import html
import io
from dataclasses import dataclass, field
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from redaktionskern.auth.models import User
from redaktionskern.db import Base
# ---------------- Status-Anzeigenamen (lokale Kopien, keine Plugin-Importe) ----------------
NEUHEITEN_STATUS_ANZEIGE: dict[str, str] = {
"neuheit": "Neuheit",
"planung": "In Planung",
"archiv": "Archiviert",
}
PLANUNG_STATUS_ANZEIGE: dict[str, str] = {
"offen": "Offen",
"in_bearbeitung": "In Bearbeitung",
"abgeschlossen": "Abgeschlossen",
}
#: Titel, die das archiv-Plugin später automatisch setzt (12-Monats-Regel).
STATUS_ARCHIV = "archiv"
def _datum_de(wert: datetime | None) -> str:
"""Deutsches Kurzformat (TT.MM.JJJJ HH:MM); leer bei None."""
return wert.strftime("%d.%m.%Y %H:%M") if wert else ""
def _oder_leer(wert) -> str:
"""None/Leer → Leerstring, alles andere als Text."""
return "" if wert is None else str(wert)
# ---------------- Listen-Definitionen ----------------
@dataclass(frozen=True)
class ListenDefinition:
"""Eine exportierbare Liste: Anzeige-Texte, Spalten und Daten-Lader."""
key: str
titel: str
beschreibung: str
nur_redaktion: bool
spalten: list[str] = field(default_factory=list)
lader: object = None # Callable[[Session], list[list[str]] | None]
def _lade_neuheiten(db: Session, *, nur_archiv: bool = False) -> list[list[str]] | None:
"""Zeilen der Neuheiten-Tabelle (Archiv = Filter auf Status „archiv“).
Gibt ``None`` zurück, wenn das neuheiten-Plugin nicht geladen ist.
"""
tabelle = Base.metadata.tables.get("neuheiten")
if tabelle is None:
return None
abfrage = select(tabelle)
if nur_archiv:
abfrage = abfrage.where(tabelle.c.status == STATUS_ARCHIV)
zeilen: list[list[str]] = []
# Core-Select auf die Tabelle: jede Zeile ist ein Row mit Attributzugriff.
for zeile in db.execute(abfrage.order_by(tabelle.c.titel.asc(), tabelle.c.id.asc())):
zeilen.append(
[
zeile.titel,
_oder_leer(zeile.verlag),
_oder_leer(zeile.autor),
_oder_leer(zeile.erscheinungsjahr),
NEUHEITEN_STATUS_ANZEIGE.get(zeile.status, zeile.status),
_datum_de(zeile.aktualisiert_am),
]
)
return zeilen
def _lade_planung(db: Session) -> list[list[str]] | None:
"""Zeilen der Planungsliste inklusive Rezensenten-Namen.
Gibt ``None`` zurück, wenn das planung-Plugin nicht geladen ist.
"""
tabelle = Base.metadata.tables.get("planungsliste")
if tabelle is None:
return None
namen = {
benutzer.id: (benutzer.display_name or benutzer.username)
for benutzer in db.scalars(select(User)).all()
}
zeilen: list[list[str]] = []
abfrage = select(tabelle).order_by(tabelle.c.titel.asc(), tabelle.c.id.asc())
for zeile in db.execute(abfrage):
zeilen.append(
[
zeile.titel,
_oder_leer(zeile.verlag),
_oder_leer(zeile.autor),
_oder_leer(zeile.ausgabe),
namen.get(zeile.rezensent_id, ""),
PLANUNG_STATUS_ANZEIGE.get(zeile.status, zeile.status),
_oder_leer(zeile.notizen),
_datum_de(zeile.aktualisiert_am),
]
)
return zeilen
NEUHEITEN_SPALTEN = ["Titel", "Verlag", "Autor", "Erscheinungsjahr", "Status", "Aktualisiert am"]
PLANUNG_SPALTEN = ["Titel", "Verlag", "Autor", "Ausgabe", "Rezensent", "Status", "Notizen", "Aktualisiert am"]
#: Alle exportierbaren Listen (Reihenfolge = Reihenfolge auf der Export-Seite).
LISTEN: dict[str, ListenDefinition] = {
definition.key: definition
for definition in (
ListenDefinition(
key="neuheiten",
titel="Neuheitenliste",
beschreibung=(
"Aktuelle Neuheiten aus der BoardGameGeek-Synchronisation — "
"inklusive Status (Neuheit, in Planung, archiviert)."
),
nur_redaktion=True,
spalten=NEUHEITEN_SPALTEN,
lader=lambda db: _lade_neuheiten(db),
),
ListenDefinition(
key="planung",
titel="Planungsliste",
beschreibung=(
"Geplante Rezensionen mit Zuordnung an Rezensentin/Rezensent, "
"Ausgabe und Bearbeitungsstatus."
),
nur_redaktion=False,
spalten=PLANUNG_SPALTEN,
lader=_lade_planung,
),
ListenDefinition(
key="archiv",
titel="Archiv",
beschreibung=(
"Archivierte Titel (älter als 12 Monate, Status „archiviert“) — "
"gefüllt durch den Autopilot des archiv-Plugins."
),
nur_redaktion=True,
spalten=list(NEUHEITEN_SPALTEN),
lader=lambda db: _lade_neuheiten(db, nur_archiv=True),
),
)
}
# ---------------- Formaterzeugung ----------------
def csv_erzeugen(kopfzeilen: list[str], zeilen: list[list[str]]) -> bytes:
"""CSV-Dokument als Bytes: Semikolon-getrennt, UTF-8 mit BOM (Excel-DE)."""
puffer = io.StringIO(newline="")
writer = csv.writer(puffer, delimiter=";")
writer.writerow(kopfzeilen)
writer.writerows(zeilen)
# utf-8-sig schreibt das Byte-Order-Mark (EF BB BF) — Excel erkennt
# daran die UTF-8-Kodierung auch ohne Datei-Import-Assistenten.
return puffer.getvalue().encode("utf-8-sig")
def _zelle(wert: str) -> str:
return html.escape(_oder_leer(wert))
def _html_tabelle(listen_titel: str, kopfzeilen: list[str], zeilen: list[list[str]], erstellt_am: datetime) -> str:
"""Baut das HTML-Gerüst für den PDF-Druck (deutsche Kopfzeile, Datum, Seitenzahlen)."""
kopf = "".join(f"<th>{_zelle(spalte)}</th>" for spalte in kopfzeilen)
koerper = "".join(
"<tr>" + "".join(f"<td>{_zelle(zelle)}</td>" for zelle in zeile) + "</tr>"
for zeile in zeilen
)
leer = "" if zeilen else '<p class="hinweis">Diese Liste enthält zurzeit keine Einträge.</p>'
datum_text = erstellt_am.strftime("%d.%m.%Y, %H:%M")
return f"""<!doctype html>
<html lang="de">
<head><meta charset="utf-8"><title>{html.escape(listen_titel)}</title>
<style>
@page {{
size: A4;
margin: 20mm 15mm 18mm 15mm;
@bottom-center {{
content: "Seite " counter(page) " von " counter(pages);
font-size: 9pt;
color: #64748b;
}}
}}
body {{ font-family: sans-serif; color: #0f172a; font-size: 10pt; }}
h1 {{ font-size: 17pt; margin: 0 0 2mm 0; }}
.meta {{ font-size: 9pt; color: #475569; margin: 0 0 6mm 0; }}
table {{ width: 100%; border-collapse: collapse; }}
thead {{ display: table-header-group; }} /* Kopfzeile auf jeder Seite */
th {{
background-color: #ecfdf5;
border-bottom: 1pt solid #047857;
text-align: left;
padding: 2mm 2.5mm;
font-size: 9.5pt;
}}
td {{
border-bottom: 0.4pt solid #cbd5e1;
padding: 1.8mm 2.5mm;
vertical-align: top;
word-wrap: break-word;
}}
tr {{ page-break-inside: avoid; }}
.hinweis {{ color: #475569; font-style: italic; }}
</style></head>
<body>
<h1>{html.escape(listen_titel)}</h1>
<p class="meta">Spiele-Redaktion &middot; Erstellt am {datum_text} Uhr &middot;
{len(zeilen)} Eintr&auml;ge</p>
{leer}
<table>
<thead><tr>{kopf}</tr></thead>
<tbody>{koerper}</tbody>
</table>
</body></html>"""
def pdf_erzeugen(
listen_titel: str,
kopfzeilen: list[str],
zeilen: list[list[str]],
erstellt_am: datetime | None = None,
) -> bytes:
"""PDF-Dokument über WeasyPrint (Import lazy, damit CSV ohne System-Bibliotheken bleibt)."""
from weasyprint import HTML # noqa: PLC0415 — bewusst spät
dokument = _html_tabelle(
listen_titel, kopfzeilen, zeilen, erstellt_am or datetime.now()
)
return HTML(string=dokument).write_pdf()
def dateiname(liste: str, endung: str, jetzt: datetime | None = None) -> str:
"""ASCII-sicherer Download-Name, z. B. ``spiele-planung-export-20250821-1430.csv``."""
stempel = (jetzt or datetime.now()).strftime("%Y%m%d-%H%M")
return f"spiele-{liste}-export-{stempel}.{endung}"

View File

@@ -2,9 +2,60 @@
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-2xl">
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
in Version {{ version }} ist geladen.
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
<p class="text-slate-600 mb-4 max-w-3xl">
Redaktionslisten als Datei herunterladen:
<strong>CSV</strong> (Semikolon-getrennt, UTF-8 mit BOM &mdash; direkt in Excel
&ouml;ffnbar) oder <strong>PDF</strong> (formatierte Tabelle mit Datum und
Seitenzahlen). Admins und Redakteure k&ouml;nnen alle Listen exportieren;
Rezensentinnen und Rezensenten die Planungsliste.
</p>
{% if fehler %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div>
{% endif %}
<div class="grid gap-4 md:grid-cols-3 mb-6">
{% for karte in karten %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 flex flex-col
{% if not karte.verfuegbar %}opacity-60{% endif %}">
<div class="flex items-start justify-between mb-2">
<h2 class="font-semibold text-slate-800">{{ karte.titel }}</h2>
{% if karte.verfuegbar %}
<span class="text-xs text-slate-500 bg-slate-100 rounded-full px-2 py-0.5 whitespace-nowrap">{{ karte.anzahl }} Eintr&auml;ge</span>
{% else %}
<span class="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5 whitespace-nowrap">Nicht verf&uuml;gbar</span>
{% endif %}
</div>
<p class="text-sm text-slate-600 mb-4 flex-1">{{ karte.beschreibung }}</p>
{% if karte.erlaubt %}
{% if karte.verfuegbar %}
<div class="flex gap-2">
<a href="/export/{{ karte.key }}/csv"
class="flex-1 text-center bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-3 py-2 rounded-lg text-sm">
CSV herunterladen
</a>
<a href="/export/{{ karte.key }}/pdf"
class="flex-1 text-center bg-slate-700 hover:bg-slate-800 text-white font-medium px-3 py-2 rounded-lg text-sm">
PDF herunterladen
</a>
</div>
{% else %}
<p class="text-xs text-slate-500">Das zugeh&ouml;rige Plugin ist nicht geladen &mdash; kein Export m&ouml;glich.</p>
{% endif %}
{% else %}
<p class="text-xs text-slate-400 italic">Nur f&uuml;r Admins und Redakteure &mdash; du hast hier keinen Export-Zugriff.</p>
{% endif %}
</div>
{% endfor %}
</div>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 text-sm text-slate-600">
<h3 class="font-semibold text-slate-800 mb-1">Hinweise zu den Formaten</h3>
<ul class="list-disc list-inside space-y-1">
<li><strong>CSV:</strong> Semikolon als Trennzeichen (Excel mit deutschem Gebietsschema), UTF-8 mit Byte-Order-Mark &mdash; Umlaute und &szlig; bleiben erhalten.</li>
<li><strong>PDF:</strong> A4, deutsche Kopfzeile auf jeder Seite, Erstell-Datum und Seitenzahlen („Seite X von Y“).</li>
<li>Jeder Download wird im <a href="/audit-log" class="text-emerald-700 underline">Audit-Log</a> protokolliert (nur Admins einsehbar).</li>
</ul>
</div>
{% endblock %}

View File

@@ -15,12 +15,14 @@ dependencies = [
"apscheduler>=3.11,<4",
"httpx>=0.27",
"rapidfuzz>=3.14.5",
"weasyprint>=69.0",
]
[dependency-groups]
dev = [
"pytest>=8.0",
"httpx>=0.27",
"freezegun>=1.5.5",
]
[build-system]

View File

@@ -82,6 +82,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
session_factory=session_factory,
settings=settings,
templates=templates,
registry=registry,
)
with engine.begin() as conn:

View File

@@ -22,6 +22,7 @@ if TYPE_CHECKING:
from sqlalchemy.orm import sessionmaker
from redaktionskern.config import Settings
from redaktionskern.plugin_loader import PluginRegistry
@dataclass(frozen=True)
@@ -49,11 +50,16 @@ class PluginContext:
session_factory: "sessionmaker",
settings: "Settings",
templates: "Jinja2Templates",
registry: "PluginRegistry | None" = None,
) -> None:
self.engine = engine
self.session_factory = session_factory
self.settings = settings
self.templates = templates
#: Registry aller geladenen Plugins — damit auch Hintergrund-Jobs
#: eines Plugins die öffentliche API anderer Plugins erreichen
#: (in Anfragen geht das üblicherweise über request.app.state.registry).
self.registry = registry
class BasePlugin:

View File

@@ -1,6 +1,7 @@
"""Gemeinsame Fixtures: App mit temporaerer SQLite-DB und TestClient."""
from __future__ import annotations
import sys
from dataclasses import replace
from pathlib import Path
@@ -22,6 +23,9 @@ def _hintergrundjobs_deaktiviert(monkeypatch):
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "1").
"""
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "0")
# Auch die täglichen Hintergrund-Jobs bleiben in Tests aus.
monkeypatch.setenv("SPIELE_ARCHIV_JOB_AKTIV", "0")
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_AKTIV", "0")
# Auch der BGG-Hilfsclient des dedup-Plugins bleibt in Tests offline.
monkeypatch.setenv("SPIELE_DEDUP_BGG_AKTIV", "0")
@@ -66,6 +70,58 @@ def melde_an(
return antwort
def _installiere_weasyprint():
"""Best effort: WeasyPrint nachinstallieren (z. B. frisches Checkout ohne uv sync).
Zuerst uv im Projektverzeichnis, als Rueckfallebene pip. Scheitert beides
(kein Netz, keine Tools), skippen die PDF-Tests spaeter sauber.
"""
import shutil
import subprocess
befehle = [
["uv", "pip", "install", "weasyprint"],
[sys.executable, "-m", "pip", "install", "--quiet", "weasyprint"],
]
for befehl in befehle:
if shutil.which(befehl[0]) is None:
continue
try:
subprocess.run(
befehl,
cwd=str(PROJEKT_WURZEL),
check=False,
timeout=180,
capture_output=True,
)
except Exception:
continue
@pytest.fixture(scope="session")
def pdf_faehig():
"""WeasyPrint bereitstellen; PDF-Tests ueberspringen, wenn das System es nicht hergibt.
- fehlt das Paket: einmaliger Installationsversuch (uv, sonst pip)
- fehlen die System-Bibliotheken (Pango/fontconfig & Co.): Probe-Renderung
schlaegt fehl -> pytest.skip statt rotem Test
"""
try:
import weasyprint # noqa: F401
except ImportError:
_installiere_weasyprint()
try:
import weasyprint # noqa: F401
except ImportError:
pytest.skip("WeasyPrint ist nicht installiert und konnte nicht nachinstalliert werden.")
from weasyprint import HTML
try:
HTML(string="<p>Probe äöü ß</p>").write_pdf()
except Exception as exc:
pytest.skip(f"WeasyPrint kann auf diesem System nicht rendern (System-Bibliotheken fehlen?): {exc}")
def lege_benutzer_an(app, username: str, role: str, password: str = "test-12345678"):
"""Legt direkt per DB einen Benutzer an (fuer Rollen-Tests)."""
from redaktionskern.auth.models import User

675
tests/test_archiv.py Normal file
View File

@@ -0,0 +1,675 @@
"""Tests: Plugin „archiv“ — 12-Monats-Regel, Job, Wiederherstellen, Rollen.
Alle Zeitpunkte werden mit eingefrorener Uhr geprüft: Der Service nimmt eine
einfrierbare „jetzt“-Fabrik entgegen (Plugin-Attribut `_jetzt` bzw.
Konstruktor-Argument), die Systemuhr wird nicht manipuliert. HTTP-Antworten
nutzen den echten TestClient; die Partner-Plugins (audit-log, neuheiten,
planung) laufen echt mit — nur ihre Tabellen werden direkt befüllt.
"""
from __future__ import annotations
import html
import tempfile
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
from sqlalchemy import insert, select
from redaktionskern.app import create_app
from redaktionskern.auth.models import User
from redaktionskern.config import Settings
from redaktionskern.contracts import Migration
from redaktionskern.db import Base
from tests.conftest import ADMIN_PASSWORD, lege_benutzer_an, melde_an
from plugins.archiv.models import ArchivNeuheit, ArchivPlanung
from plugins.archiv.service import ArchivService, als_utc, naiv_utc, vor_monaten
UTC = timezone.utc
# ---------------- Hilfen ----------------
def einfriere_uhr(jahr, monat, tag, stunde=12, minute=0, sekunde=0):
"""Fabrik für eine eingefrorene UTC-Uhr."""
moment = datetime(jahr, monat, tag, stunde, minute, sekunde, tzinfo=UTC)
return lambda: moment
def archiv_service(app, jetzt) -> ArchivService:
return ArchivService(app.state.session_factory, jetzt=jetzt)
def friere_plugin_uhr_ein(app, jetzt) -> None:
app.state.registry.get("archiv")._jetzt = jetzt
def fuege_neuheit_hinzu(app, **felder) -> int:
felder.setdefault("titel", "Testspiel")
felder.setdefault("bgg_id", 4711)
tabelle = Base.metadata.tables["neuheiten"]
with app.state.session_factory() as db:
ergebnis = db.execute(insert(tabelle).values(**felder))
db.commit()
return ergebnis.inserted_primary_key[0]
def fuege_planung_hinzu(app, **felder) -> int:
felder.setdefault("titel", "Testspiel")
felder.setdefault("rezensent_id", 1)
tabelle = Base.metadata.tables["planungsliste"]
with app.state.session_factory() as db:
ergebnis = db.execute(insert(tabelle).values(**felder))
db.commit()
return ergebnis.inserted_primary_key[0]
def _alle(app, tabellenname: str) -> list[dict]:
tabelle = Base.metadata.tables[tabellenname]
with app.state.session_factory() as db:
return [dict(z) for z in db.execute(select(tabelle)).mappings().all()]
def zaehle(app, tabellenname: str) -> int:
return len(_alle(app, tabellenname))
def audit_zeilen(app) -> list[dict]:
return _alle(app, "audit_eintraege")
def sichtbar(antwort) -> str:
return html.unescape(antwort.text)
def cron_feld(trigger, name: str) -> int:
"""Liest ein CronTrigger-Feld (z. B. hour=3 → 3)."""
feld = next(f for f in trigger.fields if f.name == name)
ausdruck = feld.expressions[0]
return int(getattr(ausdruck, "first", ausdruck))
JETZT = einfriere_uhr(2025, 8, 21, 12, 0, 0) # Stichtag: 21.08.2024 12:00 UTC
# ---------------- Migration ----------------
def test_migration_legt_beide_archiv_tabellen_an(app):
from sqlalchemy import inspect
inspektion = inspect(app.state.engine)
neuheiten_spalten = {c["name"] for c in inspektion.get_columns("archiv_neuheiten")}
planung_spalten = {c["name"] for c in inspektion.get_columns("archiv_planung")}
meta = {"quell_id", "archiviert_am", "archiviert_von", "grund", "referenz_am"}
assert {
"id", "titel", "verlag", "autor", "erscheinungsjahr",
"bgg_id", "status", "quelle", "erstellt_am", "aktualisiert_am",
} | meta <= neuheiten_spalten
assert {
"id", "titel", "verlag", "autor", "bgg_id", "ausgabe", "rezensent_id",
"status", "notizen", "quelle", "pruefung",
"erstellt_am", "aktualisiert_am",
} | meta <= planung_spalten
# ---------------- Monatsarithmetik & Zeitzonen (Einheitstests) ----------------
def test_vor_monaten_klemmt_monatstage():
assert vor_monaten(datetime(2025, 3, 31), 1) == datetime(2025, 2, 28)
assert vor_monaten(datetime(2024, 2, 29), 12) == datetime(2023, 2, 28)
assert vor_monaten(datetime(2024, 3, 31), 1) == datetime(2024, 2, 29) # Schaltjahr
assert vor_monaten(datetime(2025, 1, 15), 12) == datetime(2024, 1, 15)
def test_als_utc_normalisiert_naive_und_bewohnte_zeiten():
naive = datetime(2025, 3, 10, 23, 30)
assert als_utc(naive).tzinfo == UTC
assert als_utc(naive).hour == 23 # naive Werte gelten als UTC
berlin = timezone(timedelta(hours=2))
bewohnt = datetime(2025, 3, 11, 1, 0, tzinfo=berlin) # = 23:00 UTC am 10.03.
assert als_utc(bewohnt).utcoffset() == timedelta(0)
assert (als_utc(bewohnt).month, als_utc(bewohnt).day, als_utc(bewohnt).hour) == (3, 10, 23)
assert naiv_utc(bewohnt) == datetime(2025, 3, 10, 23, 0)
def test_stichtag_rechnet_in_utc_unabhaengig_von_der_uhrzeitzone():
berlin = timezone(timedelta(hours=2))
# Die Uhr steht auf Berliner Zeit: 01:30 +02:00 == 23:30 UTC am Vortag.
service = ArchivService(
session_factory=None,
jetzt=lambda: datetime(2025, 3, 11, 1, 30, tzinfo=berlin),
)
assert service.jetzt().utcoffset() == timedelta(0)
assert (service.jetzt().hour, service.jetzt().minute) == (23, 30)
assert service.stichtag() == datetime(2024, 3, 10, 23, 30, tzinfo=UTC)
# ---------------- 12-Monats-Regel: Neuheiten ----------------
def test_alte_neuheit_wird_vollstaendig_archiviert_und_auditiert(app):
quell_id = fuege_neuheit_hinzu(
app,
titel="Alter Klassiker",
verlag="Alt-Verlag",
autor="A. Autorin",
erscheinungsjahr=2023,
bgg_id=1001,
status="neuheit",
quelle="boardgamegeek",
erstellt_am=datetime(2023, 5, 1, 10, 0),
)
lauf, ereignisse = archiv_service(app, JETZT).lauf()
# Audit-Einträge schreibt der Aufrufkontext (Route/Job) über die Plugin-API:
app.state.registry.get("archiv")._auditiere_archivierung(ereignisse)
assert (lauf.neuheiten_archiviert, lauf.planung_archiviert) == (1, 0)
assert len(ereignisse) == 1
assert zaehle(app, "neuheiten") == 0
zeile = _alle(app, "archiv_neuheiten")[0]
# Sicherungsdatei-Prinzip: alle Felder vollständig erhalten.
assert zeile["quell_id"] == quell_id
assert zeile["titel"] == "Alter Klassiker"
assert zeile["verlag"] == "Alt-Verlag"
assert zeile["autor"] == "A. Autorin"
assert zeile["erscheinungsjahr"] == 2023
assert zeile["bgg_id"] == 1001
assert zeile["status"] == "neuheit"
assert zeile["quelle"] == "boardgamegeek"
assert zeile["erstellt_am"] == datetime(2023, 5, 1, 10, 0)
# Referenz = Ende des Erscheinungsjahres (BGG liefert nur das Jahr).
assert zeile["referenz_am"] == datetime(2023, 12, 31, 23, 59, 59)
assert "2023" in zeile["grund"]
assert zeile["archiviert_von"] == "System"
# Genau ein Audit-Eintrag pro Archivierung, Akteur „System“.
eintraege = audit_zeilen(app)
assert len(eintraege) == 1
audit = eintraege[0]
assert audit["actor_name"] == "System"
assert audit["action"] == "verschoben"
assert audit["objekt_typ"] == "neuheit"
assert audit["objekt_id"] == str(quell_id)
assert audit["details"]["titel"] == "Alter Klassiker"
assert audit["details"]["ziel"] == "archiv"
assert "12 Monate" in audit["details"]["grund"]
def test_junge_neuheiten_bleiben_in_der_aktiven_liste(app):
fuege_neuheit_hinzu(app, titel="Frisch 2025", bgg_id=1, erscheinungsjahr=2025)
fuege_neuheit_hinzu(app, titel="Letztes Jahr", bgg_id=2, erscheinungsjahr=2024)
fuege_neuheit_hinzu(app, titel="Uralt", bgg_id=3, erscheinungsjahr=2023)
lauf, _ = archiv_service(app, JETZT).lauf()
# Ein Titel des Jahres 2024 zählt erst nach dem 31.12.2025 als älter als
# 12 Monate (großzügige Jahres-Auslegung) — nur 2023 fällt unter die Regel.
assert lauf.neuheiten_archiviert == 1
assert {z["titel"] for z in _alle(app, "neuheiten")} == {
"Frisch 2025", "Letztes Jahr",
}
assert {z["titel"] for z in _alle(app, "archiv_neuheiten")} == {"Uralt"}
def test_neuheit_ohne_jahr_nutzt_eintragsdatum(app):
alt = datetime(2024, 6, 1, 9, 0) # > 12 Monate vor dem 21.08.2025
jung = datetime(2024, 10, 1, 9, 0) # < 12 Monate
fuege_neuheit_hinzu(app, titel="Ohne Jahr alt", bgg_id=10,
erscheinungsjahr=None, erstellt_am=alt)
fuege_neuheit_hinzu(app, titel="Ohne Jahr jung", bgg_id=11,
erscheinungsjahr=None, erstellt_am=jung)
lauf, _ = archiv_service(app, JETZT).lauf()
assert lauf.neuheiten_archiviert == 1
zeile = _alle(app, "archiv_neuheiten")[0]
assert zeile["titel"] == "Ohne Jahr alt"
assert zeile["referenz_am"] == alt
assert "eingetragen" in zeile["grund"]
# ---------------- Grenzfälle & Planungseinträge ----------------
def test_grenzfall_genau_12_monate_bleibt_eine_sekunde_elter_faellt(app):
stichtag = datetime(2024, 3, 15, 12, 0) # exakt „jetzt 12 Monate“
fuege_planung_hinzu(app, titel="Exakt 12 Monate", erstellt_am=stichtag)
fuege_planung_hinzu(
app, titel="Eine Sekunde älter", erstellt_am=stichtag - timedelta(seconds=1)
)
fuege_planung_hinzu(
app, titel="Eine Sekunde jünger", erstellt_am=stichtag + timedelta(seconds=1)
)
lauf, _ = archiv_service(app, einfriere_uhr(2025, 3, 15, 12, 0, 0)).lauf()
# „älter als 12 Monate“ ist streng: genau 12 Monate bleibt aktiv.
assert lauf.planung_archiviert == 1
assert {z["titel"] for z in _alle(app, "archiv_planung")} == {"Eine Sekunde älter"}
assert {z["titel"] for z in _alle(app, "planungsliste")} == {
"Exakt 12 Monate", "Eine Sekunde jünger",
}
def test_zeitzone_grenzfall_um_utc_mitternacht(app):
# Eingefrorene UTC-Uhr 23:30:30 → Stichtag 2024-03-10 23:30:30 UTC.
# In Berlin wäre es bereits der 11.03., 00:30 — die Entscheidung folgt
# trotzdem konsequent UTC.
jetzt = einfriere_uhr(2025, 3, 10, 23, 30, 30)
fuege_planung_hinzu(
app, titel="Knapp drunter", erstellt_am=datetime(2024, 3, 10, 23, 30, 0)
)
fuege_planung_hinzu(
app, titel="Knapp drueber", erstellt_am=datetime(2024, 3, 10, 23, 31, 0)
)
lauf, _ = archiv_service(app, jetzt).lauf()
assert lauf.planung_archiviert == 1
assert {z["titel"] for z in _alle(app, "archiv_planung")} == {"Knapp drunter"}
assert {z["titel"] for z in _alle(app, "planungsliste")} == {"Knapp drueber"}
def test_schaltjahr_29_februar_wird_geklemmt_nicht_uebersprungen(app):
fuege_planung_hinzu(
app, titel="29. Februar", erstellt_am=datetime(2024, 2, 29, 12, 0)
)
# 28.02.2025: Stichtag 29.02.2024 wird auf den 28.02 geklemmt — der
# Eintrag liegt einen Tag „danach“ und bleibt aktiv.
lauf, _ = archiv_service(app, einfriere_uhr(2025, 2, 28, 12, 0)).lauf()
assert lauf.planung_archiviert == 0
# 01.03.2025: Stichtag 01.03.2024 → der 29.02.2024 ist älter → Archiv.
lauf, _ = archiv_service(app, einfriere_uhr(2025, 3, 1, 12, 0)).lauf()
assert lauf.planung_archiviert == 1
assert {z["titel"] for z in _alle(app, "archiv_planung")} == {"29. Februar"}
def test_planungseintrag_wird_vollstaendig_archiviert(app):
fuege_planung_hinzu(
app,
titel="Altes Planungsspiel",
verlag="Plan-Verlag",
autor="B. Autor",
bgg_id=2002,
ausgabe="3/2024",
rezensent_id=1,
status="in_bearbeitung",
notizen="Rezension angefangen",
quelle="neuheiten",
pruefung=[{"art": "titel", "beschreibung": "ähnlicher Titel"}],
erstellt_am=datetime(2024, 5, 1, 8, 30),
)
lauf, ereignisse = archiv_service(app, JETZT).lauf()
app.state.registry.get("archiv")._auditiere_archivierung(ereignisse)
assert (lauf.neuheiten_archiviert, lauf.planung_archiviert) == (0, 1)
assert zaehle(app, "planungsliste") == 0
zeile = _alle(app, "archiv_planung")[0]
assert zeile["titel"] == "Altes Planungsspiel"
assert zeile["verlag"] == "Plan-Verlag"
assert zeile["autor"] == "B. Autor"
assert zeile["bgg_id"] == 2002
assert zeile["ausgabe"] == "3/2024"
assert zeile["status"] == "in_bearbeitung"
assert zeile["notizen"] == "Rezension angefangen"
assert zeile["quelle"] == "neuheiten"
assert zeile["pruefung"] == [{"art": "titel", "beschreibung": "ähnlicher Titel"}]
assert zeile["erstellt_am"] == datetime(2024, 5, 1, 8, 30)
assert zeile["referenz_am"] == datetime(2024, 5, 1, 8, 30)
audit = audit_zeilen(app)[0]
assert audit["objekt_typ"] == "planungseintrag"
assert audit["actor_name"] == "System"
assert audit["details"]["herkunft"] == "planung"
def test_lauf_ist_idempotent(app):
fuege_neuheit_hinzu(app, titel="Alt", bgg_id=1, erscheinungsjahr=2020)
fuege_planung_hinzu(app, titel="Alt geplant", erstellt_am=datetime(2023, 1, 1))
service = archiv_service(app, JETZT)
erster, ereignisse_eins = service.lauf()
zweiter, ereignisse_zwei = service.lauf()
plugin = app.state.registry.get("archiv")
plugin._auditiere_archivierung(ereignisse_eins)
plugin._auditiere_archivierung(ereignisse_zwei)
assert erster.gesamt == 2
assert len(ereignisse_eins) == 2
assert zweiter.gesamt == 0
assert ereignisse_zwei == []
assert zaehle(app, "archiv_neuheiten") == 1
assert zaehle(app, "archiv_planung") == 1
assert len([a for a in audit_zeilen(app) if a["actor_name"] == "System"]) == 2
def test_fehlende_partner_tabellen_brechen_nicht(tmp_path):
"""Ohne neuheiten-/planungsliste-Tabelle läuft der Lauf leer durch."""
from redaktionskern.db import make_engine, make_session_factory
engine = make_engine(f"sqlite:///{tmp_path / 'leer.db'}")
fabrik = make_session_factory(engine)
with engine.begin() as conn:
ArchivNeuheit.__table__.create(conn, checkfirst=True)
ArchivPlanung.__table__.create(conn, checkfirst=True)
try:
lauf, ereignisse = ArchivService(fabrik, jetzt=JETZT).lauf()
assert lauf.gesamt == 0
assert ereignisse == []
finally:
engine.dispose()
# ---------------- Hintergrund-Job & Scheduler ----------------
def test_job_laeuft_und_schreibt_audit_eintraege(app):
friere_plugin_uhr_ein(app, JETZT)
fuege_neuheit_hinzu(app, titel="Job-Kandidat", bgg_id=3003, erscheinungsjahr=2022)
fuege_planung_hinzu(app, titel="Job-Planung", erstellt_am=datetime(2023, 2, 2))
plugin = app.state.registry.get("archiv")
plugin._archiv_job()
assert zaehle(app, "neuheiten") == 0
assert zaehle(app, "planungsliste") == 0
assert zaehle(app, "archiv_neuheiten") == 1
assert zaehle(app, "archiv_planung") == 1
audit = audit_zeilen(app)
assert len(audit) == 2
assert {a["objekt_typ"] for a in audit} == {"neuheit", "planungseintrag"}
def test_job_ohne_audit_log_plugin_laeuft_trotzdem(app, monkeypatch):
friere_plugin_uhr_ein(app, JETZT)
fuege_neuheit_hinzu(app, titel="Ohne Audit", bgg_id=3004, erscheinungsjahr=2021)
registry = app.state.registry
original_get = registry.get
monkeypatch.setattr(
registry,
"get",
lambda name: None if name == "audit-log" else original_get(name),
)
plugin = app.state.registry.get("archiv")
plugin._archiv_job() # darf nicht werfen
assert zaehle(app, "archiv_neuheiten") == 1
assert audit_zeilen(app) == []
def test_scheduler_standardmaessig_deaktiviert_in_tests(app):
assert app.state.registry.get("archiv")._scheduler is None
def test_scheduler_laesst_sich_aktivieren_und_stoppt_beim_entladen(
monkeypatch, settings
):
monkeypatch.setenv("SPIELE_ARCHIV_JOB_AKTIV", "1")
app = create_app(settings)
plugin = app.state.registry.get("archiv")
with TestClient(app):
assert plugin._scheduler is not None
assert plugin._scheduler.running
job = plugin._scheduler.get_job("archiv-taeglich")
assert job is not None
assert cron_feld(job.trigger, "hour") == 3 # Standard 03:00 …
assert cron_feld(job.trigger, "minute") == 0 # … täglich
assert plugin._scheduler is None # on_unload hat ihn gestoppt
def test_scheduler_uhrzeit_ist_konfigurierbar(monkeypatch, settings):
monkeypatch.setenv("SPIELE_ARCHIV_JOB_AKTIV", "1")
monkeypatch.setenv("SPIELE_ARCHIV_JOB_UHRZEIT", "05:30")
app = create_app(settings)
plugin = app.state.registry.get("archiv")
with TestClient(app):
job = plugin._scheduler.get_job("archiv-taeglich")
assert cron_feld(job.trigger, "hour") == 5
assert cron_feld(job.trigger, "minute") == 30
def test_scheduler_unsinnige_uhrzeit_faellt_auf_standard(monkeypatch, settings):
monkeypatch.setenv("SPIELE_ARCHIV_JOB_AKTIV", "1")
monkeypatch.setenv("SPIELE_ARCHIV_JOB_UHRZEIT", "25:99")
app = create_app(settings)
plugin = app.state.registry.get("archiv")
with TestClient(app):
job = plugin._scheduler.get_job("archiv-taeglich")
assert cron_feld(job.trigger, "hour") == 3
assert cron_feld(job.trigger, "minute") == 0
def test_scheduler_aktiv_ist_der_standard(monkeypatch, settings):
monkeypatch.setenv("SPIELE_ARCHIV_JOB_AKTIV", "1")
app = create_app(settings)
plugin = app.state.registry.get("archiv")
with TestClient(app):
assert plugin._scheduler is not None and plugin._scheduler.running
# ---------------- Wiederherstellen ----------------
def test_wiederherstellen_neuheit_zurueck_in_aktive_liste(app, client):
melde_an(client)
friere_plugin_uhr_ein(app, JETZT)
fuege_neuheit_hinzu(
app, titel="Rückkehrer", verlag="V", autor="A",
erscheinungsjahr=2023, bgg_id=4004, status="neuheit",
erstellt_am=datetime(2023, 2, 1),
)
app.state.registry.get("archiv")._archiv_job()
archiv_id = _alle(app, "archiv_neuheiten")[0]["id"]
folgeseite = client.post(f"/archiv/neuheiten/{archiv_id}/wiederherstellen")
assert folgeseite.status_code == 200
assert zaehle(app, "archiv_neuheiten") == 0
aktiv = _alle(app, "neuheiten")
assert len(aktiv) == 1
eintrag = aktiv[0]
assert eintrag["titel"] == "Rückkehrer"
assert eintrag["bgg_id"] == 4004
assert eintrag["erscheinungsjahr"] == 2023
assert eintrag["status"] == "neuheit"
seite = sichtbar(folgeseite)
assert "zurück in die Neuheitenliste" in seite
audit = [a for a in audit_zeilen(app) if a["actor_name"] == "admin"]
assert len(audit) == 1
assert audit[0]["action"] == "verschoben"
assert audit[0]["objekt_typ"] == "neuheit"
assert audit[0]["objekt_id"] == str(eintrag["id"])
assert audit[0]["details"]["von"] == "archiv"
assert audit[0]["details"]["ziel"] == "neuheiten"
assert audit[0]["details"]["titel"] == "Rückkehrer"
def test_wiederherstellen_planung_mit_urspruenglichem_rezensenten(app, client):
lege_benutzer_an(app, "reza", "rezensent")
with app.state.session_factory() as db:
reza_id = db.scalar(select(User.id).where(User.username == "reza"))
melde_an(client)
friere_plugin_uhr_ein(app, JETZT)
fuege_planung_hinzu(
app, titel="Plan-Rückkehrer", rezensent_id=reza_id, status="offen",
ausgabe="1/2024", erstellt_am=datetime(2023, 3, 3),
)
app.state.registry.get("archiv")._archiv_job()
archiv_id = _alle(app, "archiv_planung")[0]["id"]
antwort = client.post(
f"/archiv/planung/{archiv_id}/wiederherstellen", follow_redirects=False
)
assert antwort.status_code == 303
aktiv = _alle(app, "planungsliste")
assert len(aktiv) == 1
assert aktiv[0]["rezensent_id"] == reza_id
assert aktiv[0]["status"] == "offen"
assert aktiv[0]["ausgabe"] == "1/2024"
assert zaehle(app, "archiv_planung") == 0
def test_wiederherstellen_neuheit_konflikt_bgg_id_wird_abgelehnt(app, client):
melde_an(client)
friere_plugin_uhr_ein(app, JETZT)
fuege_neuheit_hinzu(
app, titel="Konflikt-Kandidat", bgg_id=5005, erscheinungsjahr=2022
)
app.state.registry.get("archiv")._archiv_job()
# Der Sync hat den Titel inzwischen erneut geliefert — BGG-ID wieder aktiv.
fuege_neuheit_hinzu(
app, titel="Konflikt-Kandidat (neu)", bgg_id=5005, erscheinungsjahr=2025
)
archiv_id = _alle(app, "archiv_neuheiten")[0]["id"]
antwort = client.post(f"/archiv/neuheiten/{archiv_id}/wiederherstellen")
assert antwort.status_code == 200
assert "bereits einen aktiven Neuheiten-Eintrag" in sichtbar(antwort)
# Archiv unverändert, keine Duplikate entstanden.
assert zaehle(app, "archiv_neuheiten") == 1
assert zaehle(app, "neuheiten") == 1
def test_wiederherstellen_planung_ersatz_rezensent_bei_geloeschtem_benutzer(
app, client
):
lege_benutzer_an(app, "weg", "rezensent")
with app.state.session_factory() as db:
weg_id = db.scalar(select(User.id).where(User.username == "weg"))
db.delete(db.get(User, weg_id))
db.commit()
melde_an(client)
friere_plugin_uhr_ein(app, JETZT)
fuege_planung_hinzu(
app, titel="Verwaist", rezensent_id=weg_id, erstellt_am=datetime(2023, 4, 4)
)
app.state.registry.get("archiv")._archiv_job()
archiv_id = _alle(app, "archiv_planung")[0]["id"]
with app.state.session_factory() as db:
admin_id = db.scalar(select(User.id).where(User.username == "admin"))
antwort = client.post(f"/archiv/planung/{archiv_id}/wiederherstellen")
assert antwort.status_code == 200
assert "zugeordnet" in sichtbar(antwort)
aktiv = _alle(app, "planungsliste")
assert aktiv[0]["rezensent_id"] == admin_id
audit = [a for a in audit_zeilen(app) if a["actor_name"] == "admin"][0]
assert audit["details"]["rezensent_ersetzt"] is True
def test_wiederherstellen_unbekannter_eintrag_zeigt_fehler(app, client):
melde_an(client)
antwort = client.post("/archiv/neuheiten/9999/wiederherstellen")
assert antwort.status_code == 200
assert "nicht gefunden" in sichtbar(antwort)
def test_wiederherstellen_unbekannte_herkunft_wird_abgelehnt(app, client):
melde_an(client)
folgeseite = client.post("/archiv/unsinn/1/wiederherstellen")
assert folgeseite.status_code == 200
assert "Unbekannte Herkunft" in sichtbar(folgeseite)
# ---------------- Rollen ----------------
def test_archiv_ansicht_nur_fuer_admin(app, client):
# anonym → Login
antwort = client.get("/archiv", follow_redirects=False)
assert antwort.status_code == 303
assert antwort.headers["location"].startswith("/login")
# Rezensentin → 403
lege_benutzer_an(app, "rezensentin", "rezensent")
melde_an(client, "rezensentin", "test-12345678")
assert client.get("/archiv").status_code == 403
client.post("/logout")
# Redakteur → 403 (Archiv ist reine Admin-Sicht)
lege_benutzer_an(app, "redakteur", "redakteur")
melde_an(client, "redakteur", "test-12345678")
assert client.get("/archiv").status_code == 403
client.post("/logout")
# Admin → 200
melde_an(client)
assert client.get("/archiv").status_code == 200
def test_wiederherstellen_nur_fuer_admin(app, client):
friere_plugin_uhr_ein(app, JETZT)
fuege_neuheit_hinzu(app, titel="Geschützt", bgg_id=6006, erscheinungsjahr=2022)
app.state.registry.get("archiv")._archiv_job()
archiv_id = _alle(app, "archiv_neuheiten")[0]["id"]
lege_benutzer_an(app, "rezensentin", "rezensent")
melde_an(client, "rezensentin", "test-12345678")
antwort = client.post(
f"/archiv/neuheiten/{archiv_id}/wiederherstellen", follow_redirects=False
)
assert antwort.status_code == 403
assert zaehle(app, "archiv_neuheiten") == 1 # nichts verschoben
assert zaehle(app, "neuheiten") == 0
# ---------------- Admin-UI: Suche & Filter ----------------
def test_suche_filtert_ueber_titel_verlag_autor(app, client):
melde_an(client)
friere_plugin_uhr_ein(app, JETZT)
fuege_neuheit_hinzu(app, titel="Alpha Spiel", verlag="Beta Verlag",
bgg_id=1, erscheinungsjahr=2020)
fuege_neuheit_hinzu(app, titel="Gamma Spiel", autor="Delta Autorin",
bgg_id=2, erscheinungsjahr=2020)
fuege_planung_hinzu(app, titel="Epsilon Spiel",
erstellt_am=datetime(2020, 1, 1))
app.state.registry.get("archiv")._archiv_job()
def treffer(q="", quelle=""):
seite = sichtbar(client.get(f"/archiv?q={q}&quelle={quelle}"))
return [
t for t in ("Alpha Spiel", "Gamma Spiel", "Epsilon Spiel")
if t in seite
]
assert treffer(q="alpha") == ["Alpha Spiel"]
assert treffer(q="beta") == ["Alpha Spiel"] # Treffer im Verlag
assert treffer(q="delta") == ["Gamma Spiel"] # Treffer im Autor
assert treffer(quelle="planung") == ["Epsilon Spiel"]
assert treffer(quelle="neuheiten") == ["Alpha Spiel", "Gamma Spiel"]
assert treffer() == ["Alpha Spiel", "Gamma Spiel", "Epsilon Spiel"]
leer = sichtbar(client.get("/archiv?q=nirgends"))
assert "Keine Treffer" in leer
def test_archiv_seite_zeigt_details_und_job_status(app, client):
melde_an(client)
friere_plugin_uhr_ein(app, JETZT)
fuege_neuheit_hinzu(app, titel="Anzeige-Test", bgg_id=7007,
erscheinungsjahr=2021)
app.state.registry.get("archiv")._archiv_job()
seite = sichtbar(client.get("/archiv"))
assert "Anzeige-Test" in seite
assert "Erscheinungsjahr 2021" in seite # Grund der Archivierung
assert "Neuheiten" in seite # Herkunfts-Badge
assert "Wiederherstellen" in seite
assert "täglich automatisch" in seite # Erklärungstext
assert "Täglicher Archiv-Job: aus" in seite # in Tests standardmäßig aus

550
tests/test_erinnerung.py Normal file
View File

@@ -0,0 +1,550 @@
"""Tests: Plugin „erinnerung“ — Fristen, Empfänger, Doppelschutz, Rollen.
Die Uhr wird mit freezegun eingefroren; alle datumsabhängigen Prüfungen
laufen damit deterministisch. benachrichtigung und audit-log werden durch
aufzeichnende Stubs ersetzt (wie in test_planung.py); ein Test beweist
zusätzlich die Integration mit den echten Plugins.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass, field
from datetime import date
from types import SimpleNamespace
import pytest
from freezegun import freeze_time
from fastapi.testclient import TestClient
from sqlalchemy import select, text
from redaktionskern.app import create_app
from redaktionskern.auth.models import User
from tests.conftest import lege_benutzer_an, melde_an
# ---------------- Hilfen ----------------
def plugin_modul(app, name: str):
plugin_instanz = app.state.registry.get(name)
return sys.modules[type(plugin_instanz).__module__]
def ausgabe_anlegen(app, name: str, redaktionsschluss: date) -> int:
Ausgabe = plugin_modul(app, "erinnerung").Ausgabe
with app.state.session_factory() as db:
ausgabe = Ausgabe(name=name, redaktionsschluss=redaktionsschluss)
db.add(ausgabe)
db.commit()
return ausgabe.id
def protokoll_zeilen(app) -> set[tuple[int, int]]:
ErinnerungsProtokoll = plugin_modul(app, "erinnerung").ErinnerungsProtokoll
with app.state.session_factory() as db:
return {
(z.ausgabe_id, z.user_id)
for z in db.scalars(select(ErinnerungsProtokoll)).all()
}
def planung_anlegen(app, titel: str, rezensent_id: int, status: str = "offen") -> int:
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
eintrag = Planungseintrag(
titel=titel, rezensent_id=rezensent_id, status=status, ausgabe=""
)
db.add(eintrag)
db.commit()
return eintrag.id
def benutzer_objekt(app, username: str):
with app.state.session_factory() as db:
return db.scalar(select(User).where(User.username == username))
def erinnerungs_plugin(app):
plugin = app.state.registry.get("erinnerung")
plugin._merke_registry(app.state.registry) # Partner sauber auflösen
return plugin
def sichtbar(antwort) -> str:
import html
return html.unescape(antwort.text)
# ---------------- Stubs (gemockte Abhängigkeiten) ----------------
@dataclass
class Aufgezeichnet:
daten: dict = field(default_factory=dict)
class StubBenachrichtigung:
name = "benachrichtigung"
def __init__(self):
self.gesendet: list[Aufgezeichnet] = []
async def send_notification(self, user, titel, text, kategorie="allgemein"):
self.gesendet.append(Aufgezeichnet({
"user_id": user.id, "titel": titel, "text": text, "kategorie": kategorie,
}))
return SimpleNamespace(zugestellt=["inapp"], fehlgeschlagen=[])
class StubAudit:
name = "audit-log"
def __init__(self):
self.eintraege: list[Aufgezeichnet] = []
async def log(self, actor, action, objekt_typ="", objekt_id=None,
details=None, **kwargs):
self.eintraege.append(Aufgezeichnet({
"actor": actor, "action": action, "objekt_typ": objekt_typ,
"objekt_id": objekt_id, "details": details,
}))
def log_sync(self, *args, **kwargs):
import asyncio
asyncio.run(self.log(*args, **kwargs))
@pytest.fixture
def stubs(app):
"""Ersetzt benachrichtigung + audit-log im Registry-Zustand der App."""
benachrichtigung = StubBenachrichtigung()
audit = StubAudit()
registry_inhalt = app.state.registry._plugins
registry_inhalt["benachrichtigung"] = benachrichtigung
registry_inhalt["audit-log"] = audit
erinnerungs_plugin(app) # Registry-Referenz auffrischen
return SimpleNamespace(benachrichtigung=benachrichtigung, audit=audit)
# ---------------- Migration & Zugriff ----------------
def test_migration_legt_beide_tabellen_an(app):
from sqlalchemy import inspect
inspektion = inspect(app.state.engine)
ausgaben_spalten = {c["name"] for c in inspektion.get_columns("erinnerung_ausgabe")}
protokoll_spalten = {c["name"] for c in inspektion.get_columns("erinnerung_protokoll")}
assert {"id", "name", "redaktionsschluss", "erstellt_am", "aktualisiert_am"} <= ausgaben_spalten
assert {"id", "ausgabe_id", "user_id", "kanaele", "erstellt_am"} <= protokoll_spalten
with app.state.engine.connect() as conn:
versionen = [
z[0] for z in conn.execute(
text("SELECT version FROM schema_migrations WHERE plugin='erinnerung'")
).fetchall()
]
assert versionen == ["0001_ausgaben_und_protokoll"]
def test_anonym_wird_zum_login_umgeleitet(client):
antwort = client.get("/erinnerung", follow_redirects=False)
assert antwort.status_code == 303
assert antwort.headers["location"].startswith("/login")
# ---------------- Rollen & UI ----------------
def test_rezensent_sieht_seite_ohne_verwaltung(app, client):
lege_benutzer_an(app, "rez1", "rezensent")
melde_an(client, "rez1", "test-12345678")
antwort = client.get("/erinnerung")
assert antwort.status_code == 200
inhalt = sichtbar(antwort)
assert "Redaktionsschluss" in inhalt
assert "Neue Ausgabe anlegen" not in inhalt # keine Admin-Formulare
assert "Jetzt prüfen und erinnern" not in inhalt
def test_admin_sieht_verwaltung_und_status(client):
melde_an(client)
inhalt = sichtbar(client.get("/erinnerung"))
assert "Neue Ausgabe anlegen" in inhalt
assert "Erinnerungsstatus" in inhalt
def test_nur_admin_darf_ausgaben_verwalten(app, client):
"""Anlegen/Bearbeiten/Löschen/Prüfen: Admin ja, Redakteur und Rezensent 403."""
for rolle in ("redakteur", "rezensent"):
lege_benutzer_an(app, f"user-{rolle}", rolle)
melde_an(client, f"user-{rolle}", "test-12345678")
for methode, url, daten in (
("post", "/erinnerung/ausgaben", {"name": "X", "redaktionsschluss": "2030-01-01"}),
("get", "/erinnerung/ausgaben/1/bearbeiten", None),
("post", "/erinnerung/ausgaben/1/bearbeiten",
{"name": "Y", "redaktionsschluss": "2030-01-01"}),
("post", "/erinnerung/ausgaben/1/loeschen", {}),
("post", "/erinnerung/pruefen", {}),
):
if methode == "get":
antwort = client.get(url)
else:
antwort = client.post(url, data=daten)
assert antwort.status_code == 403, f"{rolle} auf {url} muss 403 geben"
client.post("/logout")
# ---------------- Ausgaben-CRUD (Admin) ----------------
def test_admin_legt_ausgabe_an_mit_audit(client, app, stubs):
melde_an(client)
antwort = client.post(
"/erinnerung/ausgaben",
data={"name": "3/2025", "redaktionsschluss": "2030-06-15"},
follow_redirects=True,
)
assert antwort.status_code == 200
assert "3/2025" in sichtbar(antwort)
Ausgabe = plugin_modul(app, "erinnerung").Ausgabe
with app.state.session_factory() as db:
ausgabe = db.scalar(select(Ausgabe))
assert ausgabe.name == "3/2025"
assert ausgabe.redaktionsschluss == date(2030, 6, 15)
assert [e.daten["action"] for e in stubs.audit.eintraege] == ["erstellt"]
eintrag = stubs.audit.eintraege[0].daten
assert eintrag["objekt_typ"] == "ausgabe"
assert eintrag["details"]["name"] == "3/2025"
def test_mehrere_ausgaben_parallel(app, client):
melde_an(client)
for name, schluss in (("3/2025", "2030-06-15"), ("4/2025", "2030-09-01")):
client.post("/erinnerung/ausgaben", data={"name": name, "redaktionsschluss": schluss})
inhalt = sichtbar(client.get("/erinnerung"))
assert "3/2025" in inhalt and "4/2025" in inhalt
def test_ausgabe_ohne_name_wird_abgewiesen(client, app):
melde_an(client)
antwort = client.post(
"/erinnerung/ausgaben",
data={"name": "", "redaktionsschluss": "2030-06-15"},
follow_redirects=True,
)
assert "Bitte einen Ausgaben-Namen angeben" in antwort.text
def test_ausgabe_mit_kaputtem_datum_wird_abgewiesen(client, app):
melde_an(client)
antwort = client.post(
"/erinnerung/ausgaben",
data={"name": "X", "redaktionsschluss": "15.06.2030"},
follow_redirects=True,
)
assert "Ungültiges Datum" in antwort.text
def test_admin_bearbeitet_ausgabe_mit_audit(client, app, stubs):
melde_an(client)
ausgabe_id = ausgabe_anlegen(app, "Alt", date(2030, 6, 15))
antwort = client.post(
f"/erinnerung/ausgaben/{ausgabe_id}/bearbeiten",
data={"name": "Neu", "redaktionsschluss": "2030-07-01"},
follow_redirects=True,
)
assert "gespeichert" in antwort.text
Ausgabe = plugin_modul(app, "erinnerung").Ausgabe
with app.state.session_factory() as db:
ausgabe = db.get(Ausgabe, ausgabe_id)
assert ausgabe.name == "Neu"
assert ausgabe.redaktionsschluss == date(2030, 7, 1)
aktionen = [e.daten["action"] for e in stubs.audit.eintraege]
assert aktionen == ["geaendert"] # Anlegen geschah direkt per Helper
geaendert = stubs.audit.eintraege[-1].daten
assert geaendert["details"]["alt"]["name"] == "Alt"
assert geaendert["details"]["neu"]["name"] == "Neu"
def test_admin_loescht_ausgabe_mit_audit(client, app, stubs):
melde_an(client)
ausgabe_id = ausgabe_anlegen(app, "Weg damit", date(2030, 6, 15))
antwort = client.post(f"/erinnerung/ausgaben/{ausgabe_id}/loeschen", follow_redirects=True)
assert "gelöscht" in antwort.text
Ausgabe = plugin_modul(app, "erinnerung").Ausgabe
with app.state.session_factory() as db:
assert db.get(Ausgabe, ausgabe_id) is None
assert [e.daten["action"] for e in stubs.audit.eintraege] == ["geloescht"]
# ---------------- Erinnerungslogik mit eingefrorener Uhr ----------------
def _drei_beteiligte(app):
"""Rezensent mit offenem Eintrag, Rezensent ohne Einträge; Admin existiert."""
lege_benutzer_an(app, "rez-offen", "rezensent")
lege_benutzer_an(app, "rez-leer", "rezensent")
rez_offen = benutzer_objekt(app, "rez-offen")
planung_anlegen(app, "Offenes Spiel", rez_offen.id, status="offen")
return rez_offen, benutzer_objekt(app, "rez-leer")
@freeze_time("2030-05-18") # genau 28 Tage vor dem 15.06.2030
def test_erinnerung_genau_beim_fensterstart(client, app, stubs):
rez_offen, rez_leer = _drei_beteiligte(app)
ausgabe_id = ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
melde_an(client)
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
assert bericht.gepruefte_ausgaben == 1
assert {v["benutzer"] for v in bericht.versendet} == {"rez-offen", "rez-leer"}
assert all(v["tage_rest"] == 28 for v in bericht.versendet)
assert protokoll_zeilen(app) == {(ausgabe_id, rez_offen.id), (ausgabe_id, rez_leer.id)}
# Benachrichtigungen über das Partner-Plugin, Kategorie „erinnerung“:
assert {g.daten["user_id"] for g in stubs.benachrichtigung.gesendet} == {
rez_offen.id, rez_leer.id,
}
assert all(g.daten["kategorie"] == "erinnerung" for g in stubs.benachrichtigung.gesendet)
assert "Redaktionsschluss: 3/2025" == stubs.benachrichtigung.gesendet[0].daten["titel"]
# Audit: jede Erinnerung als System-Ereignis protokolliert:
aktionen = [e.daten["action"] for e in stubs.audit.eintraege]
assert sorted(aktionen) == ["benachrichtigt", "benachrichtigt"]
assert all(e.daten["actor"] is None for e in stubs.audit.eintraege)
@freeze_time("2030-05-17") # 29 Tage vorher — Fenster noch zu
def test_kein_versand_vor_der_vier_wochen_frist(client, app, stubs):
_drei_beteiligte(app)
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
melde_an(client)
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
assert bericht.gepruefte_ausgaben == 0
assert bericht.versendet == []
assert stubs.benachrichtigung.gesendet == []
assert protokoll_zeilen(app) == set()
assert stubs.audit.eintraege == []
@freeze_time("2030-06-16") # einen Tag nach dem Redaktionsschluss
def test_kein_versand_nach_dem_redaktionsschluss(client, app, stubs):
_drei_beteiligte(app)
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
melde_an(client)
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
assert bericht.gepruefte_ausgaben == 0
assert stubs.benachrichtigung.gesendet == []
@freeze_time("2030-05-25") # 21 Tage Rest → im Fenster
def test_rezensent_mit_nur_abgeschlossenen_eintraegen_wird_nicht_erinnert(client, app, stubs):
for name, status in (
("rez-fertig", "abgeschlossen"),
("x-in_arbeit", "in_bearbeitung"),
("x-fertig", "abgeschlossen"),
):
lege_benutzer_an(app, name, "rezensent")
planung_anlegen(app, f"Spiel von {name}",
benutzer_objekt(app, name).id, status=status)
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15)) # 21 Tage Rest → im Fenster
melde_an(client)
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
# „offen“ heißt: nicht abgeschlossen (offen oder in Bearbeitung).
erinnerte = {v["benutzer"] for v in bericht.versendet}
assert erinnerte == {"x-in_arbeit"}
@freeze_time("2030-05-20")
def test_keine_doppelerinnerung_pro_ausgabe_und_benutzer(client, app, stubs):
_drei_beteiligte(app)
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
melde_an(client)
plugin = erinnerungs_plugin(app)
erster = plugin.pruefe_und_erinnere_sync()
zweiter = plugin.pruefe_und_erinnere_sync()
dritter = plugin.pruefe_und_erinnere_sync(jetzt=date(2030, 6, 14)) # später, noch im Fenster
assert erster.anzahl_versendet > 0
assert zweiter.anzahl_versendet == 0
assert zweiter.uebersprungen == erster.anzahl_versendet
assert dritter.anzahl_versendet == 0
assert len(stubs.benachrichtigung.gesendet) == erster.anzahl_versendet
assert len(protokoll_zeilen(app)) == erster.anzahl_versendet
@freeze_time("2030-05-20")
def test_zweite_ausgabe_wird_unabhaengig_erinnert(client, app, stubs):
"""Doppelschutz gilt pro Ausgabe — eine zweite Ausgabe erinnert erneut."""
_drei_beteiligte(app)
erste = ausgabe_anlegen(app, "3/2025", date(2030, 6, 10)) # im Fenster
zweite = ausgabe_anlegen(app, "4/2025", date(2030, 6, 12)) # auch im Fenster
melde_an(client)
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
assert bericht.gepruefte_ausgaben == 2
assert {v["ausgabe"] for v in bericht.versendet} == {"3/2025", "4/2025"}
ids = protokoll_zeilen(app)
rez_offen = benutzer_objekt(app, "rez-offen")
assert (erste, rez_offen.id) in ids
assert (zweite, rez_offen.id) in ids
@freeze_time("2030-05-20")
def test_rollenfilter_admin_und_redakteur_werden_nie_erinnert(client, app, stubs):
lege_benutzer_an(app, "chef-redakteur", "redakteur")
redakteur = benutzer_objekt(app, "chef-redakteur")
# Redakteur mit offenen Planungseinträgen bleibt trotzdem außen vor:
planung_anlegen(app, "Vom Redakteur übernommen", redakteur.id, status="offen")
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
melde_an(client)
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
erinnerte = {v["benutzer"] for v in bericht.versendet}
assert "chef-redakteur" not in erinnerte
assert "admin" not in erinnerte
@freeze_time("2030-05-20")
def test_inaktiver_rezensent_wird_nicht_erinnert(app, client, stubs):
lege_benutzer_an(app, "ex-rez", "rezensent")
with app.state.session_factory() as db:
ex_rez = db.scalar(select(User).where(User.username == "ex-rez"))
ex_rez.active = False
db.commit()
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
assert bericht.versendet == []
@freeze_time("2030-05-20")
def test_monkeypatched_uhr_steuert_job_pfad(client, app, stubs, monkeypatch):
"""Auch ohne `jetzt`-Argument nutzt der Durchlauf die eingefrierbare Uhr."""
dienst = plugin_modul(app, "erinnerung").dienst
monkeypatch.setattr(dienst, "heute", lambda: date(2030, 5, 20))
_drei_beteiligte(app)
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
assert bericht.anzahl_versendet == 2
@freeze_time("2030-05-20")
def test_manuelle_pruefung_ueber_route(client, app, stubs):
_drei_beteiligte(app)
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
melde_an(client)
antwort = client.post("/erinnerung/pruefen", follow_redirects=True)
assert "2 Erinnerung(en) versendet" in sichtbar(antwort)
@freeze_time("2030-05-20")
def test_integration_mit_echten_plugins(settings):
"""Echte Plugins statt Stubs: In-App-Nachricht + Audit-Eintrag in der DB."""
from tests.conftest import ADMIN_PASSWORD
from redaktionskern.auth.security import hash_password
with freeze_time("2030-05-20"):
app2 = create_app(settings)
with TestClient(app2):
# Rezensenten anlegen (Admin existiert bereits als Initial-Admin):
with app2.state.session_factory() as db:
db.add(User(username="rez-integration",
display_name="Rez Integration",
password_hash=hash_password(ADMIN_PASSWORD),
role="rezensent", active=True))
db.commit()
rez = None
with app2.state.session_factory() as db:
rez = db.scalar(select(User).where(User.username == "rez-integration"))
ausgabe_anlegen(app2, "3/2025", date(2030, 6, 15))
bericht = erinnerungs_plugin(app2).pruefe_und_erinnere_sync()
assert bericht.anzahl_versendet == 1
assert bericht.versendet[0]["kanaele"] == ["inapp"]
Benachrichtigung = sys.modules[
type(app2.state.registry.get("benachrichtigung")).__module__
].Benachrichtigung
AuditEintrag = sys.modules[
type(app2.state.registry.get("audit-log")).__module__
].AuditEintrag
ErinnerungsProtokoll = sys.modules[
type(app2.state.registry.get("erinnerung")).__module__
].ErinnerungsProtokoll
with app2.state.session_factory() as db:
nachricht = db.scalar(select(Benachrichtigung))
assert nachricht is not None
assert nachricht.user_id == rez.id
assert nachricht.titel == "Redaktionsschluss: 3/2025"
assert "Redaktionsschluss" in nachricht.text
eintraege = list(db.scalars(
select(AuditEintrag).where(AuditEintrag.action == "benachrichtigt")
).all())
assert len(eintraege) == 1
assert eintraege[0].actor_name == "System"
assert eintraege[0].objekt_typ == "erinnerung"
assert eintraege[0].details["ausgabe"] == "3/2025"
zeile = db.scalar(select(ErinnerungsProtokoll))
assert zeile.user_id == rez.id
assert zeile.kanaele == "inapp"
# ---------------- Scheduler-Lifecycle & Konfiguration ----------------
def test_scheduler_standardmaessig_deaktiviert_in_tests(settings):
app = create_app(settings)
assert app.state.registry.get("erinnerung")._scheduler is None
def test_scheduler_laesst_sich_aktivieren_und_konfigurieren(monkeypatch, settings):
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_AKTIV", "1")
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_UHRZEIT", "7:30")
app = create_app(settings)
plugin = app.state.registry.get("erinnerung")
with TestClient(app):
assert plugin._scheduler is not None
job = plugin._scheduler.get_job("erinnerungs-check")
assert job is not None
felder = {feld.name: str(feld) for feld in job.trigger.fields}
assert felder["hour"] == "7"
assert felder["minute"] == "30"
assert plugin._scheduler is None # on_unload hat ihn gestoppt
def test_scheduler_kaputte_uhrzeit_faellt_auf_standard(monkeypatch, settings):
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_AKTIV", "1")
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_UHRZEIT", "garstin:keinzahl")
app = create_app(settings)
plugin = app.state.registry.get("erinnerung")
with TestClient(app):
job = plugin._scheduler.get_job("erinnerungs-check")
felder = {feld.name: str(feld) for feld in job.trigger.fields}
assert felder["hour"] == "8"
assert felder["minute"] == "0"
# ---------------- Fenster-Grenzlogik (Unit) ----------------
def test_fenster_grenzen():
from plugins.erinnerung.dienst import (
ERINNERUNGSFENSTER_TAGE,
im_erinnerungsfenster,
tage_bis,
)
schluss = date(2030, 6, 15)
assert tage_bis(schluss, date(2030, 5, 18)) == ERINNERUNGSFENSTER_TAGE
assert im_erinnerungsfenster(schluss, date(2030, 5, 18)) # genau 28 Tage
assert not im_erinnerungsfenster(schluss, date(2030, 5, 17)) # 29 Tage → zu früh
assert im_erinnerungsfenster(schluss, date(2030, 6, 15)) # am Schluss selbst
assert not im_erinnerungsfenster(schluss, date(2030, 6, 16)) # vorbei

319
tests/test_export.py Normal file
View File

@@ -0,0 +1,319 @@
"""Tests: Plugin „export“ — CSV-Inhalt, PDF-Erzeugung, Rollen-Zugriff, UI.
CSV-Tests pruefen Semikolon-Trennzeichen, UTF-8-BOM und Umlaute; PDF-Tests
pruefen den gueltigen Datei-Kopf („%PDF-“) und laufen nur, wenn WeasyPrint
auf dem System rendern kann (Fixture `pdf_faehig` skippt sonst).
Datenzugriffe in den Tests nutzen dieselben Module wie die App.
"""
from __future__ import annotations
import csv
import io
import itertools
import sys
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select, text
from redaktionskern.app import create_app
from tests.conftest import lege_benutzer_an, melde_an
_bgg_zaehler = itertools.count(100000)
# ---------------- Hilfen ----------------
def plugin_modul(app, name: str):
plugin_instanz = app.state.registry.get(name) or sys.modules.get(
f"spiele_redaktion_plugins.{name}"
)
return sys.modules[type(plugin_instanz).__module__]
def neuheit_anlegen(app, titel, verlag=None, autor=None, jahr=None, status="neuheit"):
Neuheit = plugin_modul(app, "neuheiten").Neuheit
with app.state.session_factory() as db:
eintrag = Neuheit(
titel=titel, verlag=verlag, autor=autor,
erscheinungsjahr=jahr, bgg_id=next(_bgg_zaehler),
status=status,
)
db.add(eintrag)
db.commit()
return eintrag.id
def planung_anlegen(app, titel, rezensent_id, **kwargs):
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
eintrag = Planungseintrag(titel=titel, rezensent_id=rezensent_id, **kwargs)
db.add(eintrag)
db.commit()
return eintrag.id
def benutzer_objekt(app, username: str):
from redaktionskern.auth.models import User
with app.state.session_factory() as db:
return db.scalar(select(User).where(User.username == username))
def csv_zeilen(antwort) -> list[list[str]]:
"""Antwort-Bytes als geparste CSV-Zeilen (Semikolon, BOM-abhaengig)."""
text_dokument = antwort.content.decode("utf-8-sig")
return list(csv.reader(io.StringIO(text_dokument), delimiter=";"))
@pytest.fixture
def daten_bestand(app):
"""Kleiner Redaktions-Bestand: Neuheiten (inkl. archiviert), Planung."""
lege_benutzer_an(app, "rez1", "rezensent")
rez = benutzer_objekt(app, "rez1")
neuheit_anlegen(
app, "Füchse im Wunderland", verlag="Hans im Glück Verlag",
autor="Uwe Rosenberg", jahr=2025,
)
# Titel mit Trennzeichen/Sonderfällen für die CSV-Quoting-Prüfung.
neuheit_anlegen(
app, "Schätze; der Azteken", verlag='Anfänger "und" Profis',
autor="Ärger Ödipus ß", jahr=2024,
)
neuheit_anlegen(app, "Alte Schachtel", verlag="Museum Verlag", jahr=1999,
status="archiv")
planung_anlegen(
app, "Füchse im Wunderland", rez.id, ausgabe="3/2025",
notizen="Rezensionsexemplar anfordern Müller",
)
planung_anlegen(
app, "Schätze; der Azteken", rez.id, ausgabe="4/2025",
status="in_bearbeitung",
)
# ---------------- Seite & Zugriff ----------------
def test_export_seite_erreichbar(client):
melde_an(client)
antwort = client.get("/export")
assert antwort.status_code == 200
assert "Neuheitenliste" in antwort.text
assert "Planungsliste" in antwort.text
assert "Archiv" in antwort.text
assert "CSV herunterladen" in antwort.text
assert "PDF herunterladen" in antwort.text
def test_anonym_wird_zum_login_umgeleitet(client):
assert client.get("/export", follow_redirects=False).status_code == 303
assert client.get("/export/planung/csv", follow_redirects=False).status_code == 303
assert client.get("/export/neuheiten/pdf", follow_redirects=False).status_code == 303
weiterleitung = client.get("/export/planung/csv", follow_redirects=False)
assert weiterleitung.headers["location"] == "/login"
def test_unbekannte_liste_404(client):
melde_an(client)
assert client.get("/export/geheim/csv").status_code == 404
assert client.get("/export/geheim/pdf").status_code == 404
# ---------------- Rollen ----------------
@pytest.mark.parametrize("rolle", ["admin", "redakteur"])
@pytest.mark.parametrize("route", ["csv", "pdf"], ids=["csv", "pdf"])
def test_redaktion_exportiert_alles(app, client, daten_bestand, rolle, route, pdf_faehig):
lege_benutzer_an(app, f"{rolle}1", rolle)
melde_an(client, f"{rolle}1", "test-12345678")
for liste in ("neuheiten", "planung", "archiv"):
antwort = client.get(f"/export/{liste}/{route}")
assert antwort.status_code == 200, liste
if route == "pdf":
assert antwort.content.startswith(b"%PDF-")
else:
assert antwort.headers["content-type"].startswith("text/csv")
def test_rezensent_nur_planung_csv(app, client, daten_bestand):
melde_an(client, "rez1", "test-12345678")
assert client.get("/export/planung/csv").status_code == 200
assert client.get("/export/neuheiten/csv").status_code == 403
assert client.get("/export/archiv/csv").status_code == 403
def test_rezensent_nur_planung_pdf(app, client, daten_bestand, pdf_faehig):
melde_an(client, "rez1", "test-12345678")
assert client.get("/export/planung/pdf").status_code == 200
assert client.get("/export/neuheiten/pdf").status_code == 403
assert client.get("/export/archiv/pdf").status_code == 403
def test_rezensent_sieht_gesperrte_karten(app, client):
"""UI: Rezensent sieht alle Karten, aber Neuheiten/Archiv ohne Buttons."""
lege_benutzer_an(app, "rez1", "rezensent")
melde_an(client, "rez1", "test-12345678")
antwort = client.get("/export")
assert antwort.status_code == 200
assert "Nur f&uuml;r Admins und Redakteure" in antwort.text
assert "/export/neuheiten/csv" not in antwort.text
assert "/export/planung/csv" in antwort.text
# ---------------- CSV-Inhalt ----------------
def test_csv_bom_trennzeichen_umlaut(app, client, daten_bestand):
melde_an(client)
antwort = client.get("/export/planung/csv")
assert antwort.status_code == 200
assert antwort.headers["content-type"].startswith("text/csv")
roh = antwort.content
# 1) UTF-8 mit Byte-Order-Mark
assert roh.startswith(b"\xef\xbb\xbf")
# 2) Umlaute unzerstoert als UTF-8
assert "Füchse im Wunderland".encode("utf-8") in roh
assert "".encode("utf-8") in roh # Gedankenstrich aus der Notiz
zeilen = csv_zeilen(antwort)
kopf = zeilen[0]
assert kopf[:2] == ["Titel", "Verlag"]
assert "Ausgabe" in kopf and "Rezensent" in kopf and "Status" in kopf
daten = [zeile for zeile in zeilen[1:] if zeile]
assert len(daten) == 2
fuechse = next(zeile for zeile in daten if zeile[0] == "Füchse im Wunderland")
assert fuechse[kopf.index("Ausgabe")] == "3/2025"
assert fuechse[kopf.index("Status")] == "Offen"
# Rezensent ist aufgeloest (Anzeigename), nicht nur die ID:
assert fuechse[kopf.index("Rezensent")] == "Rez1"
def test_csv_semikolon_getrennt_und_quoting(app, client, daten_bestand):
"""Trennzeichen ist wirklich ';'; Felder mit ';' werden korrekt gequotet."""
melde_an(client)
antwort = client.get("/export/neuheiten/csv")
zeilen = csv_zeilen(antwort)
kopf = zeilen[0]
assert kopf[:6] == ["Titel", "Verlag", "Autor", "Erscheinungsjahr", "Status", "Aktualisiert am"]
schaetze = next(z for z in zeilen[1:] if z and z[0].startswith("Schätze"))
# Der Titel enthält selbst ein Semikolon — nur EIN Feld, sauber gequotet.
assert schaetze[0] == "Schätze; der Azteken"
assert schaetze[1] == 'Anfänger "und" Profis'
assert schaetze[3] == "2024"
def test_csv_archiv_enthaelt_nur_archiviertes(app, client, daten_bestand):
melde_an(client)
zeilen = csv_zeilen(client.get("/export/archiv/csv"))
titel = {z[0] for z in zeilen[1:] if z}
assert titel == {"Alte Schachtel"}
status_spalte = zeilen[0].index("Status")
assert all(z[status_spalte] == "Archiviert" for z in zeilen[1:] if z)
def test_csv_leere_liste_nur_kopfzeile(app, client):
melde_an(client)
antwort = client.get("/export/planung/csv")
assert antwort.status_code == 200
assert antwort.content.startswith(b"\xef\xbb\xbf")
zeilen = csv_zeilen(antwort)
assert len(zeilen) == 1 # nur Kopfzeile
assert zeilen[0][0] == "Titel"
def test_download_header_streaming(app, client, daten_bestand):
"""StreamingResponse: Attachment-Disposition mit Dateiname."""
melde_an(client)
antwort = client.get("/export/planung/csv")
disposition = antwort.headers["content-disposition"]
assert "attachment" in disposition
assert "filename=\"spiele-planung-export-" in disposition
# ---------------- PDF ----------------
def test_pdf_gueltige_datei(app, client, daten_bestand, pdf_faehig):
melde_an(client)
antwort = client.get("/export/planung/pdf")
assert antwort.status_code == 200
assert antwort.headers["content-type"].startswith("application/pdf")
# gültiger PDF-Kopf und -Abschluss:
assert antwort.content.startswith(b"%PDF-")
assert antwort.content.rstrip().endswith(b"%%EOF")
assert len(antwort.content) > 500 # kein leeres Dokument
def test_pdf_leere_liste_bleibt_gueltig(app, client, pdf_faehig):
melde_an(client)
antwort = client.get("/export/archiv/pdf")
assert antwort.status_code == 200
assert antwort.content.startswith(b"%PDF-")
assert antwort.content.rstrip().endswith(b"%%EOF")
def test_pdf_enthaelt_gerenderte_inhaltsstroeme(app, client, daten_bestand, pdf_faehig):
"""WeasyPrint komprimiert Inhaltsströme (Flate) — ohne Inhalt gäbe es keine."""
melde_an(client)
antwort = client.get("/export/neuheiten/pdf")
assert antwort.content.startswith(b"%PDF-")
assert b"FlateDecode" in antwort.content # komprimierte Seiten-Inhalte vorhanden
# Struktur: mindestens ein Seitenobjekt im Objektstrom des PDFs
import zlib
strome = []
daten = antwort.content
start = 0
while True:
start = daten.find(b"stream\r?\n", start)
if start == -1:
break
# robuster: alle Streams zwischen "stream\n" und "endstream" dekomprimieren
stuecke = daten.split(b"stream\n")
for stueck in stuecke[1:]:
roh = stueck.split(b"endstream")[0].rstrip(b"\r\n")
try:
strome.append(zlib.decompress(roh))
except Exception:
continue
assert strome, "Mindestens ein dekomprimierbarer Inhaltsstrom erwartet"
assert any(b"BT" in strom for strom in strome) # Text-Zeichenoperationen
# ---------------- Audit & Robustheit ----------------
def test_export_wird_im_audit_log_protokolliert(app, client, daten_bestand):
melde_an(client)
client.get("/export/planung/csv")
with app.state.engine.connect() as conn:
eintraege = conn.execute(
text(
"SELECT action, objekt_typ FROM audit_eintraege "
"WHERE action='exportiert' ORDER BY id DESC"
)
).fetchall()
assert len(eintraege) >= 1
assert eintraege[0] == ("exportiert", "liste-planung")
def test_fehlendes_partner_plugin_degradiert_sauber(settings):
"""Ohne planungs-/neuheiten-Tabelle zeigt die Seite „nicht verfügbar“ statt Fehler."""
from unittest.mock import patch
from redaktionskern.db import Base
class FehlendeTabellen:
def get(self, _name): # Base.metadata.tables-Ersatz
return None
app = create_app(settings)
with patch.object(Base.metadata, "tables", FehlendeTabellen()):
lege_benutzer_an(app, "admin2", "admin", "test-admin-123")
client = TestClient(app)
melde_an(client, "admin2", "test-admin-123")
seite = client.get("/export")
assert seite.status_code == 200
assert "Nicht verf&uuml;gbar" in seite.text
ziel = client.get("/export/planung/csv", follow_redirects=False)
assert ziel.status_code == 303
assert "fehler" in ziel.headers["location"]

View File

@@ -41,7 +41,10 @@ def test_plugins_haben_navigation(app):
# Voll implementierte Plugins tragen keinen Platzhalter-Text mehr auf ihrer
# Seite; die Prüfung läuft weiter für alle übrigen Platzhalter.
VOLL_IMPLEMENTIERT = {"audit-log", "neuheiten", "benachrichtigung", "dedup", "planung"}
VOLL_IMPLEMENTIERT = {
"audit-log", "neuheiten", "benachrichtigung", "dedup", "planung",
"archiv", "erinnerung", "export",
}
PLATZHALTER_STUBS = ERWARTETE_STUBS - VOLL_IMPLEMENTIERT

322
uv.lock generated
View File

@@ -3,7 +3,8 @@ revision = 3
requires-python = ">=3.11"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version < '3.14'",
"python_full_version == '3.13.*'",
"python_full_version < '3.13'",
]
[[package]]
@@ -108,6 +109,79 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/34/32109943bace7729233cc4ee78530baa306d8cc3c6501a64ba8cb3b58129/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0cc40f7b4050bb93eb67de95d2d759322fc7ce4930b9d645581ecf4913ec651e", size = 23584, upload-time = "2026-08-20T07:33:22.613Z" },
]
[[package]]
name = "brotli"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" },
{ url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" },
{ url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" },
{ url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" },
{ url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" },
{ url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" },
{ url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" },
{ url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" },
{ url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" },
{ url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" },
{ url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" },
{ url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" },
{ url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" },
{ url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" },
{ url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" },
{ url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" },
{ url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" },
{ url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" },
{ url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" },
{ url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" },
{ url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" },
{ url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" },
{ url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" },
{ url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" },
{ url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" },
{ url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" },
{ url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" },
{ url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" },
{ url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
{ url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
{ url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
{ url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
{ url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
{ url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
{ url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
{ url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
{ url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
]
[[package]]
name = "brotlicffi"
version = "1.2.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/97/7845739a36828ffe751a1c6b240692f552fd7ecf65026c51326c0a4aa369/brotlicffi-1.2.0.2.tar.gz", hash = "sha256:5e0fbd13644cf1f6015e75fa5e0ad8fdce1048d9c9ff90b0ce826174b249ee35", size = 478755, upload-time = "2026-08-21T17:29:18.415Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/a2/edda4f3fc7143434402eacad1e91433fe68ae648c22738eeddb6138638ba/brotlicffi-1.2.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad05ca993234cf947f0ad71b1c8bc0af3d74e0410b1e2c32bb99de0cef6a994b", size = 438789, upload-time = "2026-08-21T17:28:55.708Z" },
{ url = "https://files.pythonhosted.org/packages/0d/9c/506dc8edabb3cf9339c89f1ecc80a218aa166bb83b9f2e9cc1da67314072/brotlicffi-1.2.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0636cb5a85f31c36e08953d09a226cb788be900b976f81302895e3cf35d5e707", size = 1541246, upload-time = "2026-08-21T17:28:57.669Z" },
{ url = "https://files.pythonhosted.org/packages/9f/d6/74cee9f9fbea8c42030a81056c64e092030a95bd2756ea83da1d1e8f5f29/brotlicffi-1.2.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97bae40d45ebc2a6ac7b1c9b30825496a257192194b672ef5869e2df93467f69", size = 1542129, upload-time = "2026-08-21T17:28:59.502Z" },
{ url = "https://files.pythonhosted.org/packages/24/cc/c32630b042ec2a13e8342e6ecb6b9d3531b1be4647b733d6fd365976041c/brotlicffi-1.2.0.2-cp314-cp314t-win32.whl", hash = "sha256:8f3f9bd61293dc48359763e693951393f39656086315067cf97e23e23e8911ab", size = 346840, upload-time = "2026-08-21T17:29:01.085Z" },
{ url = "https://files.pythonhosted.org/packages/ee/0b/83cac3075721fe4c253ea1cc5310cb687c2f7d987e0fd60eb3ed769c24c0/brotlicffi-1.2.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:908add8a9c0eea00f5de799dc6de9f6d205d9ee11afabc7c03d6812c481200e2", size = 386079, upload-time = "2026-08-21T17:29:02.667Z" },
{ url = "https://files.pythonhosted.org/packages/2e/71/c27f24b8334f65f2492601c7764338f156cb904d2ffe0061e6004a76d9cc/brotlicffi-1.2.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:d5a8ffa154f16660ab818d78045b55fa6f9970f1ca4c38998766e99c672071cb", size = 438885, upload-time = "2026-08-21T17:29:04.113Z" },
{ url = "https://files.pythonhosted.org/packages/ef/22/d8fd1a4d09b7ab563b89380395e09151d2ef1344be31594df6a6987d4028/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec6b1af7b7a8ce788354f2c603651ada0fba166ec31ab879e2eec462a3e6dbf4", size = 1534365, upload-time = "2026-08-21T17:29:05.878Z" },
{ url = "https://files.pythonhosted.org/packages/06/78/076419ed6c2c6aa3eaac6fd6b076502b4be89d50625fcdc513cd4aeca718/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22916101de0e7ff535f2edf54b52a85591853b8ae9a98737643defdd3c063a3a", size = 1536851, upload-time = "2026-08-21T17:29:07.599Z" },
{ url = "https://files.pythonhosted.org/packages/35/dd/31ae9945cbd605339fb51c9a609f7dbb182cd361adeabc1d470142357206/brotlicffi-1.2.0.2-cp39-abi3-win32.whl", hash = "sha256:df1d34c4ad9adbf7f63a6b42f7d0e4dfd259c88141b85145b57abecc1abc3b24", size = 342379, upload-time = "2026-08-21T17:29:09.05Z" },
{ url = "https://files.pythonhosted.org/packages/95/ae/afd54e744df93b51cc29f6a19beccf9998b25743d7177697390de10479d1/brotlicffi-1.2.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:489ca4da3ee65926d72bf01584b61088a9da6bdd1bb01b2040901e1beaffa8f0", size = 379761, upload-time = "2026-08-21T17:29:10.687Z" },
{ url = "https://files.pythonhosted.org/packages/37/da/a5b65a86725d772504a348193cf1fab5ad6410794b422bf81faa17a96a66/brotlicffi-1.2.0.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:cf500bb9e02e1474ced1ecf22f74c568de2816b3627af6352ec51ac5e09e60ee", size = 407459, upload-time = "2026-08-21T17:29:12.385Z" },
{ url = "https://files.pythonhosted.org/packages/e1/c7/a253288e66ee340f2f6320eda7022daa723f2918438d586a59e9c998aa27/brotlicffi-1.2.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbb81489562dd5363bf86d9a8edb0ec8c97049b0819ba4936fc023e8847248bc", size = 406825, upload-time = "2026-08-21T17:29:13.992Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6c/ea8e3d34e1d64c5e5a920bb0c89bf9e92badf973937a60922820395e622d/brotlicffi-1.2.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc7647657e4f3d73eab591910dbecb57d1ecaea7aa3dd04e6d704a2756fe0c59", size = 402903, upload-time = "2026-08-21T17:29:15.524Z" },
{ url = "https://files.pythonhosted.org/packages/4e/17/17c22d48819001ca08cadab63b09b00e0c56a7579478aa7c2623f4280de6/brotlicffi-1.2.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5eb5563173afb92c9111b180349ff17d7c83c79febabadca5de983b552565c3c", size = 378395, upload-time = "2026-08-21T17:29:16.857Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
@@ -236,6 +310,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cssselect2"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tinycss2" },
{ name = "webencodings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/20/92eaa6b0aec7189fa4b75c890640e076e9e793095721db69c5c81142c2e1/cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb", size = 35595, upload-time = "2026-02-12T17:16:39.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" },
]
[[package]]
name = "fastapi"
version = "0.141.1"
@@ -252,6 +339,62 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
]
[[package]]
name = "fonttools"
version = "4.63.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" },
{ url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" },
{ url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" },
{ url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" },
{ url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" },
{ url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" },
{ url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" },
{ url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" },
{ url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" },
{ url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" },
{ url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" },
{ url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" },
{ url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" },
{ url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" },
{ url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" },
{ url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" },
{ url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" },
{ url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" },
{ url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" },
{ url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" },
{ url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" },
{ url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" },
{ url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" },
{ url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" },
{ url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" },
{ url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" },
{ url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" },
{ url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" },
{ url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" },
{ url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" },
{ url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" },
{ url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" },
{ url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" },
{ url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" },
{ url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" },
{ url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" },
{ url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" },
{ url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" },
{ url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" },
{ url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" },
{ url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" },
]
[package.optional-dependencies]
woff = [
{ name = "brotli", marker = "platform_python_implementation == 'CPython'" },
{ name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" },
{ name = "zopfli" },
]
[[package]]
name = "greenlet"
version = "3.5.5"
@@ -517,6 +660,91 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
{ url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
{ url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
{ url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
{ url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
{ url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
{ url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
{ url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
{ url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
{ url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -652,6 +880,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
]
[[package]]
name = "pydyf"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/36/ee/fb410c5c854b6a081a49077912a9765aeffd8e07cbb0663cfda310b01fb4/pydyf-0.12.1.tar.gz", hash = "sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095", size = 17716, upload-time = "2025-12-02T14:52:14.244Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/11/47efe2f66ba848a107adfd490b508f5c0cedc82127950553dca44d29e6c4/pydyf-0.12.1-py3-none-any.whl", hash = "sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc", size = 8028, upload-time = "2025-12-02T14:52:12.938Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
@@ -661,6 +898,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pyphen"
version = "0.18.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/47/8430452269cd28863d73b903d07d329d058cf762527ff211b3864ba61fc7/pyphen-0.18.1.tar.gz", hash = "sha256:dbae6fbbe4f01cb206108b43573d857c67107be9d0e38eb1b08d6fa2210634a7", size = 2116411, upload-time = "2026-08-14T11:30:12.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/1d/23801cf008f71575f0a4800463f349afa5f04b27fe783178a45cdc4d5edf/pyphen-0.18.1-py3-none-any.whl", hash = "sha256:0aa9051e15928cecadd4c632cea0258ba57215b2a197a39baa46abcdb0f47e84", size = 2116143, upload-time = "2026-08-14T11:30:10.428Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
@@ -844,6 +1090,7 @@ dependencies = [
{ name = "rapidfuzz" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "weasyprint" },
]
[package.dev-dependencies]
@@ -864,6 +1111,7 @@ requires-dist = [
{ name = "rapidfuzz", specifier = ">=3.14.5" },
{ name = "sqlalchemy", specifier = ">=2.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
{ name = "weasyprint", specifier = ">=69.0" },
]
[package.metadata.requires-dev]
@@ -927,6 +1175,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
]
[[package]]
name = "tinycss2"
version = "1.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "webencodings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" },
]
[[package]]
name = "tinyhtml5"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "webencodings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/1f/cfe2f6b30557c92b3f31d41707e09cef5c1efbd87392bc6c0430c46b0e4d/tinyhtml5-2.1.0.tar.gz", hash = "sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67", size = 179242, upload-time = "2026-03-05T17:06:30.704Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/48/01695a036b695f83fea7aef6955d735db0f517b1c8e25ddb399ac0bdbcbf/tinyhtml5-2.1.0-py3-none-any.whl", hash = "sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a", size = 39686, upload-time = "2026-03-05T17:06:28.498Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
@@ -1134,6 +1406,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" },
]
[[package]]
name = "weasyprint"
version = "69.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
{ name = "cssselect2" },
{ name = "fonttools", extra = ["woff"] },
{ name = "pillow" },
{ name = "pydyf" },
{ name = "pyphen" },
{ name = "tinycss2" },
{ name = "tinyhtml5" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/53/dcc3885c2f7a47faa45f6b8b801412f5f9e055173a52801ef01c09943c5a/weasyprint-69.0.tar.gz", hash = "sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c", size = 1549834, upload-time = "2026-06-02T14:42:17.765Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/cb/208525c6bd5033d7b2589b55e07bec23d9c61bb00703cbaf20ef52c3811f/weasyprint-69.0-py3-none-any.whl", hash = "sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6", size = 322872, upload-time = "2026-06-02T14:42:15.871Z" },
]
[[package]]
name = "webencodings"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d5/a0/8fd707bcb776a7be556bad06a2ea5fb9bd519df78ef8e26f70ccf0f38bff/webencodings-0.6.1.tar.gz", hash = "sha256:565f9ad031c702dae404e27a099e3e09186a3ab1b9520f06d215502b651fd910", size = 15001, upload-time = "2026-08-15T14:22:57.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl", hash = "sha256:7fab6269c8bf237c657876b52058ccb182e861518d1c695c1a9aaa8c1c105d5b", size = 8745, upload-time = "2026-08-15T14:22:56.31Z" },
]
[[package]]
name = "websockets"
version = "17.0.1"
@@ -1248,3 +1548,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/e3/91e297e41381d9131f3142a9c1a50389fd96b98125ce9b81526fa4e14b9f/websockets-17.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:afbce6e3f0fac32dc87c2a0d84869d1a706460d64f39f3889386413e6e4d3d26", size = 213434, upload-time = "2026-07-31T11:31:24.707Z" },
{ url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" },
]
[[package]]
name = "zopfli"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/74/21/3b6af43a663b22b00e738bb0642931a2579e15da6852613d56c6aa535d28/zopfli-0.4.3.tar.gz", hash = "sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe", size = 179156, upload-time = "2026-06-10T09:10:19.96Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/5f/b7d81b670daf990e15a0f7551da96c3c0700f69ae6d96b0245d6a19f51f3/zopfli-0.4.3-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073", size = 291492, upload-time = "2026-06-10T09:10:06.621Z" },
{ url = "https://files.pythonhosted.org/packages/55/c8/d8d8d731e0b192024567b7198fb77b748821d355f3c8bf0109de27191f43/zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc", size = 829354, upload-time = "2026-06-10T09:10:07.909Z" },
{ url = "https://files.pythonhosted.org/packages/0e/2b/fbe8ba2ec40f5986b8983a4752f7a32672a80a10ea6e68213324a7055469/zopfli-0.4.3-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206", size = 818436, upload-time = "2026-06-10T09:10:09.063Z" },
{ url = "https://files.pythonhosted.org/packages/de/d9/63568c54c8b68b9135f3456c5add83797a5528d596657f0e4f4910173b08/zopfli-0.4.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e", size = 1778931, upload-time = "2026-06-10T09:10:10.282Z" },
{ url = "https://files.pythonhosted.org/packages/7a/05/8f3aac10a858e89c2146d3a1f6ce33634c3db757365b4148fef1b85784d2/zopfli-0.4.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef", size = 1864132, upload-time = "2026-06-10T09:10:11.595Z" },
{ url = "https://files.pythonhosted.org/packages/8d/20/9ca59d14b91f9fbc631793b4b085b309777edadaca496aa518a180817827/zopfli-0.4.3-cp310-abi3-win32.whl", hash = "sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6", size = 271715, upload-time = "2026-06-10T09:10:12.7Z" },
{ url = "https://files.pythonhosted.org/packages/9d/3a/4ff4fdead77ef30f5832b38a47eb7a1283e98b3c678576b83f8fdfff53eb/zopfli-0.4.3-cp310-abi3-win_amd64.whl", hash = "sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357", size = 288550, upload-time = "2026-06-10T09:10:13.639Z" },
{ url = "https://files.pythonhosted.org/packages/e6/44/6264f929057236fde72dd6d271f54612b4811ce37288e002f5d5339d696a/zopfli-0.4.3-cp310-abi3-win_arm64.whl", hash = "sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42", size = 451343, upload-time = "2026-06-10T09:10:14.72Z" },
{ url = "https://files.pythonhosted.org/packages/6f/bf/403da5a753731d9a4e4d65a494c4a9ae5a0fe62e7afffe5ab49915adc9a3/zopfli-0.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0087c9a6f0c8a052be0f6d1a9bb71b6caffdd3e10201d6d6166e28d482cebe6d", size = 147045, upload-time = "2026-06-10T09:10:15.883Z" },
{ url = "https://files.pythonhosted.org/packages/bd/5f/afaa18db62ab44da01a3fc39b6cb110478d26cd2287baa83461c6454ed45/zopfli-0.4.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2e0adcf7d36c6fd0dd36cc771ef7f0c5803a05666feafcd90d7170174a4148e", size = 127265, upload-time = "2026-06-10T09:10:16.911Z" },
{ url = "https://files.pythonhosted.org/packages/04/3a/76bdfd8b35300391666b090357d059ce4c555b9d9ce9878dd551a9ad63a0/zopfli-0.4.3-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f51dd1ab5312e837e2091284e0d9f1a138188f2e65812f9a5799dc02c45f94", size = 124288, upload-time = "2026-06-10T09:10:17.891Z" },
{ url = "https://files.pythonhosted.org/packages/c5/95/5781bfb29782c39918686070dbf2ad1425c21384c3223f5c6bd911a806f8/zopfli-0.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:62248dbf8dbcbd588ee194b210e5be9fa80bce29641f55599d6d394bd2a9d8a3", size = 304624, upload-time = "2026-06-10T09:10:18.944Z" },
]