Compare commits
5 Commits
0f12cd952b
...
6b2e04d782
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b2e04d782 | ||
|
|
1d4cd400a3 | ||
|
|
469cf8826e | ||
|
|
d024f42f3c | ||
|
|
72fe3b7eda |
194
README.md
194
README.md
@@ -4,7 +4,8 @@
|
|||||||
modularer Plugin-Architektur. Lauffähiger Kern mit Plugin-System,
|
modularer Plugin-Architektur. Lauffähiger Kern mit Plugin-System,
|
||||||
Authentifizierung/Rollen und Migrationen; vollständig implementiert sind
|
Authentifizierung/Rollen und Migrationen; vollständig implementiert sind
|
||||||
bisher **Audit-Log**, **Benachrichtigung**, **Neuheiten (BGG-Sync)**,
|
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
|
## Stack
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ bisher **Audit-Log**, **Benachrichtigung**, **Neuheiten (BGG-Sync)**,
|
|||||||
- **Plugins:** Entry-Points (`importlib.metadata`, Gruppe `spiele_redaktion.plugins`)
|
- **Plugins:** Entry-Points (`importlib.metadata`, Gruppe `spiele_redaktion.plugins`)
|
||||||
plus lokales `plugins/`-Verzeichnis
|
plus lokales `plugins/`-Verzeichnis
|
||||||
- **Hintergrund-Jobs:** APScheduler (BGG-Neuheiten-Sync)
|
- **Hintergrund-Jobs:** APScheduler (BGG-Neuheiten-Sync)
|
||||||
|
- **PDF-Export:** WeasyPrint (System-Bibliotheken: Pango/fontconfig)
|
||||||
- **Dependencies/Tests:** [uv](https://docs.astral.sh/uv/) + pytest
|
- **Dependencies/Tests:** [uv](https://docs.astral.sh/uv/) + pytest
|
||||||
|
|
||||||
## Setup
|
## 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_SUCHBEGRIFFE` | `brettspiel` | Komma-getrennte Suchbegriffe für den regelmäßigen Sync |
|
||||||
| `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `25` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) |
|
| `SPIELE_BGG_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_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
|
## 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-
|
mit Stub-gemockter dedup-/Benachrichtigungs-/Audit-API, Verlags-Auswahl-
|
||||||
Dialog, Statuswechsel, Bearbeiten/Löschen, Rechte pro Rolle) plus ein
|
Dialog, Statuswechsel, Bearbeiten/Löschen, Rechte pro Rolle) plus ein
|
||||||
Integrationstest mit den echten Plugins (In-App-Nachricht + Audit-Eintrag).
|
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
|
## Architektur
|
||||||
|
|
||||||
@@ -94,7 +123,9 @@ plugins/ ein Ordner pro Funktion, ladbar über den Plugin-Load
|
|||||||
├── benachrichtigung/ VOLL IMPLEMENTIERT: E-Mail/Telegram/In-App
|
├── benachrichtigung/ VOLL IMPLEMENTIERT: E-Mail/Telegram/In-App
|
||||||
├── dedup/ VOLL IMPLEMENTIERT: check_titel-API, Heuristik, Protokoll
|
├── dedup/ VOLL IMPLEMENTIERT: check_titel-API, Heuristik, Protokoll
|
||||||
├── planung/ VOLL IMPLEMENTIERT: Planungsliste, Verschiebung, Dialoge
|
├── 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>/
|
└── … je __init__.py + templates/<name>/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -122,7 +153,9 @@ class MeinPlugin(BasePlugin):
|
|||||||
return [NavEntry(label=self.title, url="/meinplugin")]
|
return [NavEntry(label=self.title, url="/meinplugin")]
|
||||||
|
|
||||||
def on_load(self, context): # Lifecycle-Hook beim Start
|
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
|
def on_unload(self): # Lifecycle-Hook beim Herunterfahren
|
||||||
super().on_unload()
|
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
|
Bearbeiten, Löschen); Admins und Redakteure alle. Jede Änderung wird im
|
||||||
Audit-Log protokolliert (`erstellt`/`verschoben`/`geaendert`/`geloescht`).
|
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)
|
## Deployment (später)
|
||||||
|
|
||||||
Docker/Podman Compose ist vorgesehen (Henry-Lab, danach Kundenhardware).
|
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 |
|
| 2 | Plugin `benachrichtigung` (E-Mail/Telegram/In-App, User-Präferenzen) | ✅ fertig |
|
||||||
| 3 | Plugin `audit-log` (Wer/Was/Wann, Admin-Ansicht, öffentliche API) | ✅ fertig |
|
| 3 | Plugin `audit-log` (Wer/Was/Wann, Admin-Ansicht, öffentliche API) | ✅ fertig |
|
||||||
| 4 | Plugin `neuheiten` (BGG-Sync, APScheduler, Filter) | ✅ 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) |
|
| 5 | Plugin `dedup` (Verlags-Konflikt, deutsche Version, Vorgänger-/Planungs-Check) | ✅ fertig |
|
||||||
| 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ✅ fertig (141 Tests grün) |
|
| 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ✅ fertig |
|
||||||
| 7 | Plugin `archiv` (12-Monats-Autopilot) | ⏳ offen |
|
| 7 | Plugin `archiv` (12-Monats-Autopilot, Wiederherstellen, täglicher Job) | ✅ fertig |
|
||||||
| 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung) | ⏳ offen |
|
| 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung, Tages-Job) | ✅ fertig |
|
||||||
| 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ⏳ offen |
|
| 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ✅ fertig |
|
||||||
| 10 | Integrationstests über alle Plugins | ⏳ offen |
|
| 10 | Integrationstests über alle Plugins | ⏳ offen |
|
||||||
| 11 | Deployment auf swen.henry.insight-it.de (Compose + Traefik, Live-Check) | ⏳ 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)
|
## Offene Punkte (nicht Teil von Phase 1)
|
||||||
|
|
||||||
- CSRF-Schutz für Formulare (aktuell SameSite=Lax-Cookie als Basisschutz)
|
- CSRF-Schutz für Formulare (aktuell SameSite=Lax-Cookie als Basisschutz)
|
||||||
- Hintergrund-Jobs (APScheduler) für Erinnerungen
|
- WeasyPrint-Systemabhängigkeiten im Container prüfen
|
||||||
- WeasyPrint für PDF-Export (Systemabhängigkeiten im Container)
|
|
||||||
- Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets
|
- Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets
|
||||||
|
|||||||
@@ -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:
|
Titel der Neuheitenliste und der Planungsliste, deren Erscheinungsdatum
|
||||||
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
|
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 __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 fastapi import Depends, Request
|
||||||
from redaktionskern.auth.models import User
|
from fastapi.responses import RedirectResponse
|
||||||
from redaktionskern.contracts import BasePlugin, NavEntry
|
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):
|
class ArchivPlugin(BasePlugin):
|
||||||
name = "archiv"
|
name = "archiv"
|
||||||
title = "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:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
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")
|
@self.router.get("/archiv")
|
||||||
def seite(request: Request, user: User = Depends(require_user)):
|
def ansicht(
|
||||||
"""Platzhalterseite des Plugins."""
|
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(
|
return self.context.templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="archiv/index.html",
|
name="archiv/index.html",
|
||||||
context={
|
context={
|
||||||
"user": user,
|
"user": user,
|
||||||
"titel": self.title,
|
"titel": self.title,
|
||||||
"name": self.name,
|
"eintraege": eintraege,
|
||||||
"version": self.version,
|
"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]:
|
@self.router.post("/archiv/{quelle}/{archiv_id}/wiederherstellen")
|
||||||
return [NavEntry(label=self.title, url="/archiv")]
|
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()
|
plugin = ArchivPlugin()
|
||||||
|
|||||||
108
plugins/archiv/models.py
Normal file
108
plugins/archiv/models.py
Normal 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
494
plugins/archiv/service.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -2,9 +2,120 @@
|
|||||||
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
|
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
|
||||||
{% block inhalt %}
|
{% block inhalt %}
|
||||||
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
||||||
<p class="text-slate-600 max-w-2xl">
|
<p class="text-slate-600 max-w-3xl mb-4">
|
||||||
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
|
Sicherungsdatei der Redaktion: Titel, deren Erscheinungs- bzw. Eintragsdatum
|
||||||
in Version {{ version }} ist geladen.
|
mehr als 12 Monate zurückliegt, werden täglich automatisch aus den
|
||||||
Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert.
|
aktiven Listen hierher verschoben. Es geht nichts verloren — jeder
|
||||||
|
Eintrag lässt sich unverändert zurück in die aktive Liste verschieben.
|
||||||
</p>
|
</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äglicher Archiv-Job: {{ 'aktiv' if job_aktiv else 'aus' }}
|
||||||
|
</span>
|
||||||
|
<span>{{ anzahl_neuheiten }} archivierte Neuheiten</span>
|
||||||
|
<span>{{ anzahl_planung }} archivierte Planungseinträ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ücksetzen</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-slate-400 mb-3">{{ anzahl }} Einträ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 {{ 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">
|
||||||
|
↻ 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ür diese Suche.
|
||||||
|
{% else %}
|
||||||
|
Das Archiv ist leer — nichts zu sehen.
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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:
|
Umfang:
|
||||||
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
|
- **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 __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 fastapi import Depends, Form, Request
|
||||||
from redaktionskern.auth.models import User
|
from fastapi.responses import RedirectResponse
|
||||||
from redaktionskern.contracts import BasePlugin, NavEntry
|
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):
|
class ErinnerungPlugin(BasePlugin):
|
||||||
name = "erinnerung"
|
name = "erinnerung"
|
||||||
title = "Erinnerungen"
|
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:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
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")
|
@self.router.get("/erinnerung")
|
||||||
def seite(request: Request, user: User = Depends(require_user)):
|
def seite(
|
||||||
"""Platzhalterseite des Plugins."""
|
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(
|
return self.context.templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="erinnerung/index.html",
|
name="erinnerung/index.html",
|
||||||
context={
|
context={
|
||||||
"user": user,
|
"user": user,
|
||||||
"titel": self.title,
|
"titel": self.title,
|
||||||
"name": self.name,
|
"zeilen": zeilen,
|
||||||
"version": self.version,
|
"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]:
|
@self.router.post("/erinnerung/ausgaben")
|
||||||
return [NavEntry(label=self.title, url="/erinnerung")]
|
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()
|
plugin = ErinnerungPlugin()
|
||||||
|
|||||||
148
plugins/erinnerung/dienst.py
Normal file
148
plugins/erinnerung/dienst.py
Normal 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."
|
||||||
|
)
|
||||||
64
plugins/erinnerung/models.py
Normal file
64
plugins/erinnerung/models.py
Normal 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}>"
|
||||||
34
plugins/erinnerung/templates/erinnerung/bearbeiten.html
Normal file
34
plugins/erinnerung/templates/erinnerung/bearbeiten.html
Normal 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">„{{ ausgabe.name }}“ · 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 — pro Ausgabe
|
||||||
|
und Benutzer wird nur einmal erinnert.
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,9 +2,143 @@
|
|||||||
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
|
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
|
||||||
{% block inhalt %}
|
{% block inhalt %}
|
||||||
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
||||||
<p class="text-slate-600 max-w-2xl">
|
<p class="text-slate-600 mb-4">
|
||||||
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
|
Redaktionsschlüsse der kommenden Ausgaben. Vier Wochen vor jedem
|
||||||
in Version {{ version }} ist geladen.
|
Redaktionsschluss erhalten alle Rezensenten eine Erinnerung, die noch offene
|
||||||
Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert.
|
Planungseinträge haben — oder noch gar keine eingetragen haben.
|
||||||
</p>
|
</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ö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 · 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 — {{ 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ö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üfen</h2>
|
||||||
|
<p class="text-xs text-slate-500">Führt denselben Check aus wie der tä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ü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ür welche Ausgabe wann erinnert? Pro Ausgabe und Benutzer wird hö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ä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 %}—{% 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 '—' }}
|
||||||
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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:
|
Exportiert die drei Redaktionslisten **Neuheiten**, **Planung** und
|
||||||
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
|
**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 __future__ import annotations
|
||||||
|
|
||||||
from fastapi import Depends, Request
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from redaktionskern.auth.deps import require_user
|
from fastapi import Depends, HTTPException, Request
|
||||||
from redaktionskern.auth.models import User
|
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 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):
|
class ExportPlugin(BasePlugin):
|
||||||
name = "export"
|
name = "export"
|
||||||
title = "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:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
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")
|
@self.router.get("/export")
|
||||||
def seite(request: Request, user: User = Depends(require_user)):
|
def seite(
|
||||||
"""Platzhalterseite des Plugins."""
|
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(
|
return self.context.templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="export/index.html",
|
name="export/index.html",
|
||||||
context={
|
context={
|
||||||
"user": user,
|
"user": user,
|
||||||
"titel": self.title,
|
"titel": self.title,
|
||||||
"name": self.name,
|
"karten": karten,
|
||||||
"version": self.version,
|
"ist_redaktion": user.role in REDAKTION,
|
||||||
|
"fehler": fehler[:300],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def navigation(self) -> list[NavEntry]:
|
@self.router.get("/export/{liste}/csv")
|
||||||
return [NavEntry(label=self.title, url="/export")]
|
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()
|
plugin = ExportPlugin()
|
||||||
|
|||||||
266
plugins/export/exporte.py
Normal file
266
plugins/export/exporte.py
Normal 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 · Erstellt am {datum_text} Uhr ·
|
||||||
|
{len(zeilen)} Einträ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}"
|
||||||
@@ -2,9 +2,60 @@
|
|||||||
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
|
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
|
||||||
{% block inhalt %}
|
{% block inhalt %}
|
||||||
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
||||||
<p class="text-slate-600 max-w-2xl">
|
<p class="text-slate-600 mb-4 max-w-3xl">
|
||||||
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
|
Redaktionslisten als Datei herunterladen:
|
||||||
in Version {{ version }} ist geladen.
|
<strong>CSV</strong> (Semikolon-getrennt, UTF-8 mit BOM — direkt in Excel
|
||||||
Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert.
|
öffnbar) oder <strong>PDF</strong> (formatierte Tabelle mit Datum und
|
||||||
|
Seitenzahlen). Admins und Redakteure können alle Listen exportieren;
|
||||||
|
Rezensentinnen und Rezensenten die Planungsliste.
|
||||||
</p>
|
</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ä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ü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örige Plugin ist nicht geladen — kein Export möglich.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-xs text-slate-400 italic">Nur für Admins und Redakteure — 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 — Umlaute und ß 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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ dependencies = [
|
|||||||
"apscheduler>=3.11,<4",
|
"apscheduler>=3.11,<4",
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
"rapidfuzz>=3.14.5",
|
"rapidfuzz>=3.14.5",
|
||||||
|
"weasyprint>=69.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
|
"freezegun>=1.5.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
session_factory=session_factory,
|
session_factory=session_factory,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
templates=templates,
|
templates=templates,
|
||||||
|
registry=registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
|
|||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from redaktionskern.config import Settings
|
from redaktionskern.config import Settings
|
||||||
|
from redaktionskern.plugin_loader import PluginRegistry
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -49,11 +50,16 @@ class PluginContext:
|
|||||||
session_factory: "sessionmaker",
|
session_factory: "sessionmaker",
|
||||||
settings: "Settings",
|
settings: "Settings",
|
||||||
templates: "Jinja2Templates",
|
templates: "Jinja2Templates",
|
||||||
|
registry: "PluginRegistry | None" = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.engine = engine
|
self.engine = engine
|
||||||
self.session_factory = session_factory
|
self.session_factory = session_factory
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.templates = templates
|
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:
|
class BasePlugin:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Gemeinsame Fixtures: App mit temporaerer SQLite-DB und TestClient."""
|
"""Gemeinsame Fixtures: App mit temporaerer SQLite-DB und TestClient."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
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", "1").
|
||||||
"""
|
"""
|
||||||
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "0")
|
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.
|
# Auch der BGG-Hilfsclient des dedup-Plugins bleibt in Tests offline.
|
||||||
monkeypatch.setenv("SPIELE_DEDUP_BGG_AKTIV", "0")
|
monkeypatch.setenv("SPIELE_DEDUP_BGG_AKTIV", "0")
|
||||||
|
|
||||||
@@ -66,6 +70,58 @@ def melde_an(
|
|||||||
return antwort
|
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"):
|
def lege_benutzer_an(app, username: str, role: str, password: str = "test-12345678"):
|
||||||
"""Legt direkt per DB einen Benutzer an (fuer Rollen-Tests)."""
|
"""Legt direkt per DB einen Benutzer an (fuer Rollen-Tests)."""
|
||||||
from redaktionskern.auth.models import User
|
from redaktionskern.auth.models import User
|
||||||
|
|||||||
675
tests/test_archiv.py
Normal file
675
tests/test_archiv.py
Normal 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
550
tests/test_erinnerung.py
Normal 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
319
tests/test_export.py
Normal 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ü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ügbar" in seite.text
|
||||||
|
ziel = client.get("/export/planung/csv", follow_redirects=False)
|
||||||
|
assert ziel.status_code == 303
|
||||||
|
assert "fehler" in ziel.headers["location"]
|
||||||
@@ -41,7 +41,10 @@ def test_plugins_haben_navigation(app):
|
|||||||
|
|
||||||
# Voll implementierte Plugins tragen keinen Platzhalter-Text mehr auf ihrer
|
# Voll implementierte Plugins tragen keinen Platzhalter-Text mehr auf ihrer
|
||||||
# Seite; die Prüfung läuft weiter für alle übrigen Platzhalter.
|
# 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
|
PLATZHALTER_STUBS = ERWARTETE_STUBS - VOLL_IMPLEMENTIERT
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
322
uv.lock
generated
322
uv.lock
generated
@@ -3,7 +3,8 @@ revision = 3
|
|||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.14'",
|
"python_full_version >= '3.14'",
|
||||||
"python_full_version < '3.14'",
|
"python_full_version == '3.13.*'",
|
||||||
|
"python_full_version < '3.13'",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.7.22"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.141.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
version = "3.5.5"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.21.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.1.1"
|
version = "9.1.1"
|
||||||
@@ -844,6 +1090,7 @@ dependencies = [
|
|||||||
{ name = "rapidfuzz" },
|
{ name = "rapidfuzz" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
{ name = "weasyprint" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
@@ -864,6 +1111,7 @@ requires-dist = [
|
|||||||
{ name = "rapidfuzz", specifier = ">=3.14.5" },
|
{ name = "rapidfuzz", specifier = ">=3.14.5" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0" },
|
{ name = "sqlalchemy", specifier = ">=2.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
||||||
|
{ name = "weasyprint", specifier = ">=69.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.16.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "websockets"
|
name = "websockets"
|
||||||
version = "17.0.1"
|
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/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" },
|
{ 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" },
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user