Compare commits

..

28 Commits

Author SHA1 Message Date
Flo Hartmann
929cea1887 Fix: dedup BGG-Client nutzt Token; Sync-Filter Jahr+deutscher Titel; Defaults für deutsche Neuerscheinungen 2026-08-25 17:28:35 +00:00
Flo Hartmann
d91402881a Login-Seite: Powered-by-BGG-Logo ergänzt 2026-08-25 12:13:01 +00:00
Flo Hartmann
ca6d94938c BGG: Powered-by-Logo im Footer (Lizenzpflicht) + Erscheinungsjahr-Filter (Standard aktuelles Jahr+) 2026-08-25 12:11:06 +00:00
Flo Hartmann
667196a4ba Web-Quellen: An/Aus-Schalter pro Quelle im UI + Einzel-Quellen-Sync 2026-08-25 01:10:36 +00:00
Flo Hartmann
8b38bc6c89 Cliquenabend: Cover-Bilder aus den Spieldetailseiten übernehmen 2026-08-25 00:55:07 +00:00
Flo Hartmann
199f8b080d Cliquenabend: Release-Feld der Spieldetailseiten parsen (echtes Erscheinungsjahr statt Update-Datum) 2026-08-25 00:44:31 +00:00
Flo Hartmann
aea52d1563 Neuheiten+Planung: Pagination (50/Seite, Infinite Scroll) + quellen_url in Planung mit Quellen-Link 2026-08-24 22:44:18 +00:00
Flo Hartmann
fa453518d4 Spieleinträge: quellen_url speichern + als klickbaren Quellen-Link anzeigen 2026-08-24 21:55:39 +00:00
Flo Hartmann
96d6af8163 Coverbilder (bild_url) + responsives Layout für Mobil/Tablet
Teil 1 — Coverbilder:
- Migration 0004_bild_url (neuheiten), 0002_bild_url (planung, archiv
  mit beiden Spiegel-Tabellen); nullable VARCHAR(500), SQLite↔Postgres
  portabel, bestehende Zeilen bleiben erhalten
- BGG-Client extrahiert Cover aus der Thing-Antwort (bevorzugt <image>,
  sonst <thumbnail>; protokoll-relative URLs → https)
- Sync-Upsert pflegt bild_url beim Update; fehlendes Bild löscht keines
- Web-Quellen übernehmen img-URLs aus den Listenelementen (src/data-src,
  relative Pfade gegen die Seiten-URL aufgelöst); SPIEL-Essen nutzt das
  BILDER-Feld des JSON-Endpunkts
- Verschiebung Neuheiten→Planung und Archivierung/Wiederherstellen
  schreiben bild_url mit
- Anzeige: kleine Vorschaubilder (40x56) in den Listen, groß (max 200px)
  in Planung-Bearbeiten/Prüf-Dialog; Platzhalter-Div ohne Bild;
  Hotlinking mit loading=lazy + decoding=async

Teil 2 — Responsives Layout:
- Basislayout: Burger-Menü (Alpine.js) auf Mobil, Desktop-Leiste ab md:
- Listen kollabieren <768px zu Karten-Ansichten (Cover links, Titel +
  Metadaten rechts, Status-Badge oben); Desktop behält Tabellenform
- Touch-Ziele >= 44px auf Mobil, Formulare vollbreit mit Labels oben
- Sync-Übersicht/Audit-Log bleiben horizontal scrollbare Tabellen

Tests: 15 neue Fälle (tests/test_covers.py) — Migration inkl.
Daten-Erhalt, BGG-Parsing gemockt, Quellen-Übernahme, Template-Render;
316 Tests grün. README aktualisiert.
2026-08-24 16:54:56 +00:00
Flo Hartmann
20b84a8e1a TODO: alle Punkte abgeschlossen 2026-08-23 01:13:02 +00:00
Flo Hartmann
f27940db8e Plugin neuheiten: optionale LLM-Hilfsstufe für den Web-Quellen-Crawler
Neues Modul quellen/ki_hilfe.py (konsistent zum dedup-LLM-Muster):
- Struktur-Erkennung: bei verdächtig leerem Parser-Ergebnis schlägt das
  LLM Pagination-/Filter-Folge-URLs vor; hart gefiltert auf gleiche
  Domain, max. 20 je Seite, nicht erreichbare Vorschläge brechen den
  Lauf nicht ab.
- Feld-Extraktion: nur unsichere Treffer (fehlender Verlag, verdächtiger
  Titel); korrigiert ausschließlich titel/verlag/autor, niemals die URL.
- Env-Konfiguration SPIELE_NEUHEITEN_KI_* (AKTIV default 0,
  KONFIDENZ_MIN default 0.7), OpenAI-kompatible Chat-Completions via
  httpx mit Timeout und genau einem Retry.
- Fallback-Pflicht: ohne Konfiguration oder bei jedem Fehler läuft exakt
  der klassische Crawler; KI-Fehler blockieren den Sync nie.
- Audit: KI-Eingriffe je Quelle und Lauf ins audit-log (Modell,
  Konfidenz, korrigierte Felder, verfolgte Folge-URLs).

20 neue Tests (gemocktes HTTP/LLM): Fallback-Fälle, Konfidenz-Schwelle,
Domain-Filter, Max-20-Grenze, URL-Unveränderbarkeit, Audit.
2026-08-23 01:07:18 +00:00
Flo Hartmann
c6f9b19f6b Merge wt-quellen: Web-Quellen-Crawler im Neuheiten-Plugin 2026-08-23 00:41:34 +00:00
Flo Hartmann
3b0626c774 Plugin neuheiten: Web-Quellen-Crawler (4 Adapter, Rate-Limit, Dedup-Gate, Sync-UI) 2026-08-23 00:41:02 +00:00
Flo Hartmann
8e5f65d864 Plugin dedup: LLM als zweite Stufe für Grenzfälle
Regelbasierte Dedup-Prüfung bleibt maßgeblich; nur Grenzfälle gehen an ein
LLM: Titel mit Wort-Überschneidung unter der Fuzzy-Schwelle (Editionen, Big
Boxes, Übersetzungen wie 'El Grande Big Box' vs. 'El Grande: 25 Jahre') und
ein ergänzender Call zum Verlags-/Titel-Hinweis bei Verlags-Konflikt
(Verlags-Aliase). Klare Treffer und Non-Treffer bleiben regelbasiert.

- Neu: ki_pruefung.py — Env-Konfiguration (SPIELE_DEDUP_LLM_*), robuster
  JSON-Parser (Code-Fences, Zusatztext), OpenAI-kompatibler Client via httpx
  mit Timeout und genau einem Retry; liefert bei jedem Fehler None.
- pruefung.py: bestätigte Grenzfälle laufen in die üblichen Prüfungen a)-d)
  zurück; deutscher Titel füllt die Titel-Empfehlung, Verlags-Empfehlung
  landet im Konflikt-Detail. Neues Feld PruefErgebnis.ki_befunde.
- __init__.py: Aktivierungsprüfung vor jeder Fabrik — aus/unvollständig
  heißt nie ein LLM-Aufruf; KI-Befunde werden über die audit-log-API
  protokolliert (Modell, Konfidenz, Entscheidung), best effort.
- Tests: gemockte HTTP-Antworten (MockTransport/Fakes), Fallback-Fälle
  (aktiv=0, kein Key, Timeout, kaputtes JSON), Konfidenz-Schwelle,
  Parsing-Robustheit; kein echter LLM-Call in CI.
- README.md um die neuen Env-Variablen und den Zweitprüfungs-Abschnitt
  ergänzt.
2026-08-22 23:43:33 +00:00
Flo Hartmann
b6d67e04e5 TODO: Arbeitsliste LLM-Stufe Dedup 2026-08-22 23:37:03 +00:00
Flo Hartmann
8c9f542b8c TODO: Arbeitsliste Web-Quellen-Crawler 2026-08-22 23:36:49 +00:00
Flo Hartmann
3de8fa5227 Englische README (README.en.md), beide Sprachen untereinander verlinkt 2026-08-22 00:09:07 +00:00
Flo Hartmann
aad146be93 Fix: disabled Aktiv-Checkbox bei eigenem Account sendete keinen Wert 2026-08-21 23:52:27 +00:00
Flo Hartmann
6950cffd4d MIT-Lizenz hinzugefügt (LICENSE, pyproject, README) 2026-08-21 23:29:33 +00:00
Flo Hartmann
07e49a530e Fix: eigenes Profil bearbeiten — disabled role-Select sendete keinen Wert (422) 2026-08-21 23:17:54 +00:00
Flo Hartmann
60c4b6e818 Plugin neuheiten: Bearer-Token-Pflicht der BGG-XML-API2 (Fix für HTTP 401)
BoardGameGeek verlangt für die XML API2 jetzt Bearer-Authentifizierung
(Thread 3602374); ohne Token antwortet die API mit 401.

- BggClient: neuer Parameter token, sendet Authorization: Bearer … auf
  allen Requests; Token kommt aus SPIELE_BGG_TOKEN (vor jedem Sync gelesen)
- HTTP 401 → klarer BggAuthFehler ('BGG hat die Anfrage abgelehnt (401) —
  bitte gültigen API-Token in SPIELE_BGG_TOKEN hinterlegen.') statt
  generischer Meldung, kein Retry
- Ohne konfigurierten Token wird der Sync übersprungen ('Kein BGG-API-Token
  konfiguriert (SPIELE_BGG_TOKEN) — Sync übersprungen.'), statt Requests zu
  feuern; Meldung erscheint im Sync-Ergebnis, UI-Banner und Protokoll
- SyncService: BggAuthFehler bricht den Lauf ab (weitere Suchbegriffe
  scheitern zwangsläufig gleich), Teilergebnisse bleiben erhalten
- Tests: Bearer-Header, 401-Fall (Client/Sync/UI), Skip ohne Token;
  bestehende Sync-UI-Tests setzen Test-Token
- README: neue Env-Variable SPIELE_BGG_TOKEN + Hinweis auf Token-Pflicht
2026-08-21 23:14:58 +00:00
Flo Hartmann
55f6f32f73 README: Fortschritt final — alle Bausteine 2026-08-21 20:42:47 +00:00
Flo Hartmann
3f8f67994e Dockerfile: WeasyPrint-Systemabhängigkeiten (Pango, Fonts) 2026-08-21 20:42:37 +00:00
Flo Hartmann
6b2e04d782 Merge wt-export: Export-Plugin (Konflikte aufgelöst) 2026-08-21 20:38:34 +00:00
Flo Hartmann
1d4cd400a3 Merge wt-erinnerung: Erinnerungs-Plugin (Konflikte aufgelöst) 2026-08-21 20:37:20 +00:00
Flo Hartmann
469cf8826e Plugin erinnerung: Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung
- Ausgaben (Name + Redaktionsschluss-Datum) verwalten, mehrere parallel,
  Anlegen/Bearbeiten/Löschen nur für Admin, mit Audit-Log-Einträgen
- Erinnerung 28 Tage vor dem Redaktionsschluss an alle aktiven Rezensenten
  mit offener (oder fehlender) Planung, Versand über das benachrichtigung-
  Plugin je Kanal-Präferenz, Audit als System/benachrichtigt
- Täglicher APScheduler-Job (SPIELE_ERINNERUNG_JOB_UHRZEIT, abschaltbar)
  plus manuelle Sofort-Prüfung in der Admin-Ansicht
- Doppelschutz: Protokolltabelle mit Unique (Ausgabe, Benutzer)
- Admin-UI: Ausgabenliste mit Restzeit-Badges, Erinnerungsstatus
  (wer wurde wann über welche Kanäle erinnert), deutsche Templates
- Tests mit eingefrorener Uhr (freezegun): Fenster-Grenzen, kein Versand
  vor Frist/nach Schluss, Empfänger-/Rollenregeln, keine Duplikate,
  Scheduler-Konfiguration; Integrationstest mit den echten Plugins
  (167 Tests grün)
2026-08-21 20:31:13 +00:00
Flo Hartmann
d024f42f3c Plugin export: Listen als CSV (Excel-DE) und PDF (WeasyPrint)
- CSV über stdlib csv: Semikolon-getrennt, UTF-8 mit BOM, korrektes
  Quoting; PDF über WeasyPrint mit deutscher Kopfzeile (je Seite
  wiederholt), Erstell-Datum und Seitenzahlen (Seite X von Y)
- Drei Listen: Neuheiten, Planung, Archiv (Neuheiten gefiltert auf
  Status „archiviert“); Partner-Tabellen über gemeinsame Metadata,
  ohne Plugin-Importe — fehlende Plugins degradieren sauber
- Rollen: Admin/Redakteur alles, Rezensent nur Planungsliste;
  serverseitige Prüfung an den Download-Endpunkten (StreamingResponse),
  UI-Karten für nicht erlaubte Listen ausgegraut
- Jeder Download best effort im Audit-Log (Aktion „exportiert“)
- WeasyPrint lazy importiert: ohne System-Bibliotheken bleiben App und
  CSV nutzbar; Tests skippen PDF-Fälle nach Installationsversuch
- 20 neue Tests (CSV-Inhalt/BOM/Umlaute/Quoting, PDF-Gültigkeit,
  Rollen, Audit, Degradierung); 161 gesamt grün
- README: Plugin-Dokumentation, Fortschritt export auf 
2026-08-21 20:30:19 +00:00
Flo Hartmann
72fe3b7eda Plugin archiv: 12-Monats-Autopilot mit Sicherungsdatei-Prinzip
- Eigene Migration 0001_archiv_tabellen: Spiegel-Tabellen archiv_neuheiten
  und archiv_planung (ohne FK, damit Archiv-Zeilen Benutzerlöschung überleben)
- Service mit eingefrierbarer Uhr: Stichtag „jetzt minus 12 Monate“ in UTC,
  strenger Vergleich (genau 12 Monate bleibt aktiv); Neuheiten nach
  Erscheinungsjahr (großzügig Jahresende) sonst Eintragsdatum, Planung nach
  Eintragsdatum; Monatsarithmetik mit Klemmung (29. Februar)
- Täglicher APScheduler-Cron-Job (Standard 03:00, konfigurierbar über
  SPIELE_ARCHIV_JOB_UHRZEIT / SPIELE_ARCHIV_JOB_AKTIV); je archiviertem
  Titel ein Audit-Log-Eintrag als System
- Admin-Ansicht /archiv: vereinigte Liste beider Tabellen, Suche über
  Titel/Verlag/Autor, Herkunftsfilter, Wiederherstellen in die aktive Liste
  (BGG-Konflikt wird abgelehnt, verwaiste Rezensentenzuordnung übernimmt der
  Admin — gemeldet und auditiert); Rollen: nur Admin
- Kern minimal erweitert: PluginContext.registry stellt Hintergrund-Jobs die
  Plugin-Registry bereit (keine Fachlogik im Kern)
- 30 neue Tests mit eingefrorener Uhr (Grenzfälle, Zeitzonen, Schaltjahr,
  Job-Lauf, Wiederherstellen, Rollen, Suche/Filter); 171 Tests grün
- README aktualisiert: neues Plugin-Kapitel, Env-Variablen, Fortschritt #7 
2026-08-21 20:26:00 +00:00
71 changed files with 11554 additions and 174 deletions

9
.env Normal file
View File

@@ -0,0 +1,9 @@
SPIELE_SESSION_SECRET=48ebf7ddae261a931652b5a55cff708aa21cf4936a8bdeb287eeac7cec4ee3e2
SPIELE_INITIAL_ADMIN_PASSWORD=3fb93f84014ff45406b36ec3
SPIELE_BGG_TOKEN=6fa6f9f6-32fd-443d-a5c3-ac2527f6a6fc
# BGG-Sync: deutsche Neuerscheinungen ab aktuellem Jahr
SPIELE_BGG_SUCHBEGRIFFE=brettspiel,gesellschaftsspiel,familienspiel,strategiespiel,kartenspiel,würfelspiel
SPIELE_BGG_MAX_TREFFER_PRO_SUCHE=50
SPIELE_BGG_JAHR_FILTER=1
SPIELE_BGG_DEUTSCHE_TITEL_FILTER=1

View File

@@ -1,5 +1,11 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
# WeasyPrint-Systemabhängigkeiten (Pango, GDK-PixBuf, Fonts)
RUN apt-get update && apt-get install -y --no-install-recommends \
libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf-2.0-0 \
libffi-dev shared-mime-info fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \

18
LICENSE Normal file
View File

@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 Flo Hartmann (B0rbor4d)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

226
README.en.md Normal file
View File

@@ -0,0 +1,226 @@
# Board Game Editorial Desk (Spiele-Redaktion)
> **Deutsch:** [README.md](README.md) · **English:** [README.en.md](README.en.md)
**AI assistance for board game magazine editorial teams** — multi-user web
application with a modular plugin architecture. A lean core with a plugin
system, authentication/roles and migrations; fully implemented plugins:
**Audit Log**, **Notifications**, **New Releases (BGG sync)**, **Dedup
Checks**, **Planning List**, **Archive (12-month autopilot)**,
**Reminders**, and **Export (CSV/PDF)**.
## Stack
- **Backend:** Python 3.11+, FastAPI, SQLAlchemy 2 (SQLite, Postgres-ready)
- **Frontend:** HTMX + Jinja2 + Tailwind (CDN) + Alpine.js — no build step
- **Auth:** signed session cookies, Argon2id password hashing
- **Plugins:** entry points (`importlib.metadata`, group `spiele_redaktion.plugins`)
plus a local `plugins/` directory
- **Background jobs:** APScheduler (BGG new-release sync)
- **PDF export:** WeasyPrint (system libraries: Pango/fontconfig)
- **Dependencies/tests:** [uv](https://docs.astral.sh/uv/) + pytest
## Setup
```bash
uv sync # venv + dependencies
uv run uvicorn main:app --reload # dev server on http://127.0.0.1:8000
```
An initial admin account is created automatically on first start:
| Username | Password | Role |
|----------|----------|-------|
| `admin` | `admin` | admin |
> ⚠️ For anything beyond local development, set `SPIELE_INITIAL_ADMIN_PASSWORD`
> before starting and change the password after the first login.
### Environment variables
| Variable | Default | Meaning |
|----------|---------|---------|
| `SPIELE_DATABASE_URL` | `sqlite:///./data/spiele-redaktion.db` | SQLAlchemy URL (Postgres: e.g. `postgresql+psycopg://…`) |
| `SPIELE_SESSION_SECRET` | dev value | Secret for signed session cookies |
| `SPIELE_SESSION_DAUER` | `43200` | Session duration in seconds (12 h) |
| `SPIELE_INITIAL_ADMIN_PASSWORD` | `admin` | Password of the initial admin account |
| `SPIELE_PLUGINS_DIR` | `<project>/plugins` | Path to the local plugin directory |
| `SPIELE_SMTP_HOST` | *(empty)* | SMTP server for the e-mail channel; empty = dev fallback (log only) |
| `SPIELE_SMTP_PORT` | `587` | SMTP port |
| `SPIELE_SMTP_BENUTZER` | *(empty)* | SMTP login (optional) |
| `SPIELE_SMTP_PASSWORT` | *(empty)* | SMTP password (optional) |
| `SPIELE_SMTP_ABSENDER` | username or `spiele-redaktion@localhost` | From address |
| `SPIELE_SMTP_TLS` | `starttls` | `starttls`, `ssl` or `keine`/`none` |
| `SPIELE_TELEGRAM_BOT_TOKEN` | *(empty)* | Bot token for the Telegram channel; empty = dev fallback (log only) |
| `SPIELE_BGG_SYNC_AKTIV` | `1` | New-releases background sync on (`1`) or off (`0`) |
| `SPIELE_BGG_SYNC_INTERVALL_STUNDEN` | `24` | BGG sync interval in hours (min. 1) |
| `SPIELE_BGG_SUCHBEGRIFFE` | `brettspiel` | Comma-separated search terms for the recurring sync |
| `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `25` | Max. results per search term (protects BGG rate limits) |
| `SPIELE_BGG_TOKEN` | *(empty)* | API token for the BGG XML API2, sent as `Authorization: Bearer *** required since BGG introduced token auth ([thread 3602374](https://boardgamegeek.com/thread/3602374)) — without a token the sync is skipped |
| `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG auxiliary data for dedup checks on (`1`) or off (`0`): alternate names and expansion relations |
| `SPIELE_ARCHIV_JOB_AKTIV` | `1` | Daily archiving job on (`1`) or off (`0`) |
| `SPIELE_ARCHIV_JOB_UHRZEIT` | `03:00` | Time of day for the daily archive run, format `HH:MM` |
| `SPIELE_ERINNERUNG_JOB_AKTIV` | `1` | Daily reminder check on (`1`) or off (`0`) |
| `SPIELE_ERINNERUNG_JOB_UHRZEIT` | `08:00` | Time of day for the daily reminder check |
## Tests
```bash
uv run pytest
```
224 tests cover: plugin loader (all plugins incl. lifecycle hooks and entry
point path), migration runtime incl. idempotency, login/logout, role access
(admin/editor vs. reviewer), user management, the audit log plugin (logging,
migration, filters, pagination, admin-only access), the new releases plugin
(BGG parsing, expansion/prototype filters, rate limit & retry backoff against
mocked HTTP responses — no real API calls —, update-instead-of-duplicate
logic, UI search/filter/sort, role protection on the sync endpoint, scheduler
lifecycle), **dedup** (title heuristics, fuzzy matching, all four checks with
a fake BGG client, check log), **planning** (both entry paths, publisher
selection dialog, per-role permissions), **archive** (12-month rule with a
frozen clock incl. leap-year and timezone edge cases, restore incl. conflicts),
**reminders** (issue CRUD, exact 28-day window, duplicate protection),
and **export** (CSV content with semicolon/BOM/quoting, valid PDFs, role
access; PDF tests skip cleanly if WeasyPrint system libraries are missing).
Integration tests run the real plugins end to end (in-app message +
audit row in the database).
## Architecture
```
src/redaktionskern/ lean core — NO domain logic
├── app.py app factory: discovery → migrations → on_load → routes
├── config.py settings (environment variables)
├── db.py engine/session (SQLite ↔ Postgres portable)
├── migrationen.py migration runtime (schema_migrations table per plugin)
├── plugin_loader.py entry points + plugins/ directory, unique names
├── contracts.py plugin contract (BasePlugin, Migration, NavEntry, PluginContext)
└── auth/ login/logout, roles, user management
plugins/ one folder per feature, loaded by the plugin loader
├── audit-log/ FULLY IMPLEMENTED: model, migration, API, admin view
├── neuheiten/ FULLY IMPLEMENTED: BGG client, sync job, migration, UI
├── benachrichtigung/ FULLY IMPLEMENTED: e-mail/Telegram/in-app
├── dedup/ FULLY IMPLEMENTED: check_titel API, heuristics, log
├── planung/ FULLY IMPLEMENTED: planning list, moves, dialogs
├── archiv/ FULLY IMPLEMENTED: 12-month autopilot, job, UI
├── erinnerung/ FULLY IMPLEMENTED: issues, 4-week reminders, daily job
├── export/ FULLY IMPLEMENTED: CSV/PDF downloads, roles, audit
└── … each with __init__.py + templates/<name>/
```
### The plugin contract
A plugin is a package folder under `plugins/` whose `__init__.py` exposes a
module attribute `plugin` (an instance of a `BasePlugin` subclass).
Alternatively: an installed package with an entry point in the group
`spiele_redaktion.plugins`.
```python
from fastapi import Depends, Request
from redaktionskern.auth.deps import require_user
from redaktionskern.contracts import BasePlugin, Migration, NavEntry
class MyPlugin(BasePlugin):
name = "myplugin" # unique, = folder name
title = "My Plugin" # display name
description = "What it does."
def migrations(self): # own schema migrations (optional)
return []
def navigation(self): # entries in the main navigation (optional)
return [NavEntry(label=self.title, url="/myplugin")]
def on_load(self, context): # lifecycle hook at startup
super().on_load(context) # context.engine/.session_factory/.templates/
# .settings/.registry (plugin registry for
# background jobs without a request)
def on_unload(self): # lifecycle hook at shutdown
super().on_unload()
plugin = MyPlugin()
```
The core provides each plugin with a shared Jinja environment via
`context.templates` (plugin templates extend `base.html`), plus the engine and
session factory. Migrations run transactionally and are recorded per plugin in
`schema_migrations`; the `up` functions receive a SQLAlchemy connection and can
branch on dialect differences.
## The plugins
- **audit-log** — records who moved/created/changed what and when; public
`log()` / `log_sync()` API; admin view at `/audit-log` with filters and
pagination.
- **benachrichtigung (notifications)** — adapter pattern with three channels:
e-mail (SMTP), Telegram (bot API), in-app inbox with unread counter.
Per-user channel preferences; public `send_notification(user, title, text,
category)` API that never raises. Unconfigured channels fall back to a
dev-log adapter so nothing is lost in development.
- **neuheiten (new releases)** — builds and maintains the new-releases list
from the BoardGameGeek XML API2: search + thing queries in batches,
rate-limited (min. 1 s between requests) with exponential backoff and
HTTP 202 queue handling. Expansions and prototypes are filtered out;
existing entries are updated by unique `bgg_id` instead of duplicated.
Requires a BGG bearer token (`SPIELE_BGG_TOKEN`); without it the sync is
skipped with a clear message.
- **dedup** — four checks before any title enters the pipeline: (a) same game
published under different publishers → publisher selection conflict;
(b) same game under different titles → prefer the German version (heuristics
+ BGG alternate names); (c) game or predecessor already reviewed (BGG
expansion relations + rapidfuzz fuzzy matching); (d) title already being
reviewed by another editor. Public `check_titel()` API with structured
results; every check is logged.
- **planung (planning)** — reviewers pick titles from the new-releases list
("→ move to planning") or add them manually; both paths run through the
dedup checks. On a hit, the entering reviewer is notified and an audit log
entry written. Reviewers may only edit their own entries.
- **archiv (archive)** — titles older than 12 months are moved automatically
into archive tables (backup-copy principle: everything is preserved,
restorable). Daily APScheduler job, admin view with search/filters and
restore including conflict handling.
- **erinnerung (reminders)** — configurable issue deadlines; four weeks before
press date, every reviewer with open (or no) planning entries receives a
reminder through their preferred channels. Exactly once per issue and user
(unique constraint), audited, daily job plus manual "check now" button.
- **export** — all lists (new releases, planning, archive) as CSV
(semicolon-separated, UTF-8 BOM — Excel-friendly) and PDF (WeasyPrint,
repeated table headers, page numbers). Admins/editors export everything,
reviewers only the planning list; every download is audited.
## Deployment
Docker/Podman Compose with Traefik labels is included (`docker-compose.yml`,
`Dockerfile`). The Dockerfile installs the WeasyPrint system libraries
(Pango etc.). For Postgres simply set `SPIELE_DATABASE_URL`; the migrations
are portable.
## Project status
All planned components are implemented and deployed:
| # | Component | Status |
|---|-----------|--------|
| 1 | Core: FastAPI, plugin system, DB/migrations, auth/roles, HTMX layout | ✅ done |
| 2 | Plugin `notifications` (e-mail/Telegram/in-app, per-user preferences) | ✅ done |
| 3 | Plugin `audit-log` | ✅ done |
| 4 | Plugin `new releases` (BGG sync, APScheduler, filters) | ✅ done |
| 5 | Plugin `dedup` (publisher conflict, German title, predecessor/planning checks) | ✅ done |
| 6 | Plugin `planning` (move + manual entry, both checked, notifications) | ✅ done |
| 7 | Plugin `archive` (12-month autopilot, restore, daily job) | ✅ done |
| 8 | Plugin `reminders` (per-issue deadlines, 4-week reminder, daily job) | ✅ done |
| 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ✅ done |
| 10 | Integration tests across all plugins | ✅ 224 tests green |
| 11 | Deployment (Compose + Traefik behind HTTPS) | ✅ live |
## Open items
- CSRF protection for forms (currently SameSite=Lax cookies as a baseline)
- Self-hosted Tailwind assets instead of CDN for production
## License
This project is licensed under the [MIT License](LICENSE).

352
README.md
View File

@@ -1,10 +1,13 @@
# Spiele-Redaktion # Spiele-Redaktion
> **Deutsch:** [README.md](README.md) · **English:** [README.en.md](README.en.md)
**KI-Assistenz für Spielemagazin-Redaktionen** — Multi-User-Webanwendung mit **KI-Assistenz für Spielemagazin-Redaktionen** — Multi-User-Webanwendung mit
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 +17,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
@@ -51,9 +55,28 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt:
| `SPIELE_TELEGRAM_BOT_TOKEN` | *(leer)* | Bot-Token für den Telegram-Kanal; leer = Dev-Fallback (nur Protokoll) | | `SPIELE_TELEGRAM_BOT_TOKEN` | *(leer)* | Bot-Token für den Telegram-Kanal; leer = Dev-Fallback (nur Protokoll) |
| `SPIELE_BGG_SYNC_AKTIV` | `1` | Hintergrund-Sync des Neuheiten-Plugins an (`1`) oder aus (`0`) | | `SPIELE_BGG_SYNC_AKTIV` | `1` | Hintergrund-Sync des Neuheiten-Plugins an (`1`) oder aus (`0`) |
| `SPIELE_BGG_SYNC_INTERVALL_STUNDEN` | `24` | Intervall des BGG-Syncs in Stunden (min. 1) | | `SPIELE_BGG_SYNC_INTERVALL_STUNDEN` | `24` | Intervall des BGG-Syncs in Stunden (min. 1) |
| `SPIELE_BGG_SUCHBEGRIFFE` | `brettspiel` | Komma-getrennte Suchbegriffe für den regelmäßigen Sync | | `SPIELE_BGG_SUCHBEGRIFFE` | `brettspiel, gesellschaftsspiel, familienspiel, strategiespiel, kartenspiel, würfelspiel` | Komma-getrennte Suchbegriffe für den regelmäßigen Sync (deutsche Spielbegriffe) |
| `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `25` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) | | `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `50` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) |
| `SPIELE_BGG_JAHR_FILTER` | `1` | Nur Spiele mit Erscheinungsjahr ≥ aktuelles Jahr übernehmen (`1`) oder alle (`0`) |
| `SPIELE_BGG_DEUTSCHE_TITEL_FILTER` | `1` | Nur Spiele mit deutsch klingendem Titel übernehmen (`1`) oder alle (`0`) |
| `SPIELE_BGG_TOKEN` | *(leer)* | API-Token für die BGG-XML-API2, wird als `Authorization: Bearer *** gesendet; seit der Token-Pflicht von BGG erforderlich (siehe [BGG-Thread 3602374](https://boardgamegeek.com/thread/3602374)) — ohne Token wird der Sync übersprungen |
| `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG-Zusatzdaten für die Dedup-Prüfung an (`1`) oder aus (`0`): Alternate-Names und Erweiterungs-Relationen | | `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG-Zusatzdaten für die Dedup-Prüfung an (`1`) oder aus (`0`): Alternate-Names und Erweiterungs-Relationen |
| `SPIELE_DEDUP_LLM_AKTIV` | `0` | LLM-Zweitprüfung der Dedup-Grenzfälle an (`1`) oder aus (`0`) |
| `SPIELE_DEDUP_LLM_BASIS_URL` | *(leer)* | Basis-URL einer OpenAI-kompatiblen Chat-Completions-API, z. B. `https://api.openai.com/v1` |
| `SPIELE_DEDUP_LLM_API_KEY` | *(leer)* | API-Key, wird als `Authorization: Bearer …` gesendet |
| `SPIELE_DEDUP_LLM_MODELL` | *(leer)* | Modellname, z. B. `gpt-4o-mini` |
| `SPIELE_DEDUP_LLM_KONFIDENZ_MIN` | `0.7` | Mindest-Konfidenz; darunter wird das LLM-Ergebnis verworfen und nur der Regelbefund angezeigt |
| `SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN` | `20` | Timeout je LLM-Aufruf; Netzwerk-/Server-Fehler werden genau einmal wiederholt |
| `SPIELE_NEUHEITEN_KI_AKTIV` | `0` | Optionale LLM-Hilfsstufe des Web-Quellen-Crawlers an (`1`) oder aus (`0`) |
| `SPIELE_NEUHEITEN_KI_BASIS_URL` | *(leer)* | Basis-URL einer OpenAI-kompatiblen Chat-Completions-API, z. B. `https://api.openai.com/v1` |
| `SPIELE_NEUHEITEN_KI_API_KEY` | *(leer)* | API-Key, wird als `Authorization: Bearer …` gesendet |
| `SPIELE_NEUHEITEN_KI_MODELL` | *(leer)* | Modellname, z. B. `gpt-4o-mini` |
| `SPIELE_NEUHEITEN_KI_KONFIDENZ_MIN` | `0.7` | Mindest-Konfidenz; darunter wird das KI-Ergebnis verworfen und der klassische Parser-Befund behalten |
| `SPIELE_NEUHEITEN_KI_TIMEOUT_SEKUNDEN` | `20` | Timeout je LLM-Aufruf; Netzwerk-/Server-Fehler werden genau einmal wiederholt |
| `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 +98,34 @@ 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 die **Coverbilder-Erweiterung**: Migrationen für `bild_url` in allen
vier Tabellen inkl. Daten-Erhalt beim ALTER TABLE, BGG-Bildextraktion
(`<image>` bevorzugt, `<thumbnail>` als Rückfallebene, gemockte Antworten),
Bildübernahme aus Web-Quellen, Update-Pflege des Bildes im Sync-Upsert sowie
Template-Darstellung (`loading="lazy"`/`decoding="async"` bzw. Platzhalter).
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 +145,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 +175,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()
@@ -222,7 +277,8 @@ manuelle Synchronisation für die Redaktion, deutsche sortier-/filterbare Liste.
### Datentabelle ### Datentabelle
Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten` Eigene Migrationen `0001_neuheiten_tabelle`, `0002_bgg_id_nullable`,
`0003_quellen_status` und `0004_bild_url`; Tabelle `neuheiten`
(SQLite ↔ Postgres portabel): (SQLite ↔ Postgres portabel):
| Spalte | Inhalt | | Spalte | Inhalt |
@@ -231,6 +287,7 @@ Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten`
| `verlag` / `autor` | Verlag bzw. Autor(en), kommagetrennt | | `verlag` / `autor` | Verlag bzw. Autor(en), kommagetrennt |
| `erscheinungsjahr` | Erscheinungsjahr — BGG liefert nur das Jahr, kein genaues Datum | | `erscheinungsjahr` | Erscheinungsjahr — BGG liefert nur das Jahr, kein genaues Datum |
| `bgg_id` | BoardGameGeek-ID, eindeutig → Merge-Kriterium des Syncs | | `bgg_id` | BoardGameGeek-ID, eindeutig → Merge-Kriterium des Syncs |
| `bild_url` | URL des Coverbildes (nullable; BGG-Image/-Thumbnail oder Bild der Web-Quelle) |
| `status` | `neuheit` (spätere Plugins: Planung/Archiv) | | `status` | `neuheit` (spätere Plugins: Planung/Archiv) |
| `quelle` | Datenquelle (`boardgamegeek`) | | `quelle` | Datenquelle (`boardgamegeek`) |
| `erstellt_am` / `aktualisiert_am` | Zeitstempel | | `erstellt_am` / `aktualisiert_am` | Zeitstempel |
@@ -240,6 +297,14 @@ Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten`
Pro Suchbegriff: `/search?type=boardgame&query=…` → IDs sammeln → Pro Suchbegriff: `/search?type=boardgame&query=…` → IDs sammeln →
`/thing?id=…&type=boardgame` in Batches à 20 IDs. `/thing?id=…&type=boardgame` in Batches à 20 IDs.
**Coverbilder:** Aus der Thing-Antwort wird die Bild-URL extrahiert
(bevorzugt das große `<image>`, sonst das kleinere `<thumbnail>`;
protokoll-relative URLs werden auf https normalisiert) und in
`bild_url` gespeichert. Beim Update bestehender Einträge wird das Bild
mitgepflegt; fehlt es in einer Antwort, bleibt ein bereits gespeichertes
Bild erhalten. Bilder werden direkt von BGG eingebettet (Hotlinking,
kein Download) — im UI immer mit `loading="lazy"` und `decoding="async"`.
- **Rate-Limit:** mindestens 1 Sekunde zwischen zwei Requests. - **Rate-Limit:** mindestens 1 Sekunde zwischen zwei Requests.
- **Retry mit Backoff:** exponentielles Zurückhalten bei 5xx, 429 und - **Retry mit Backoff:** exponentielles Zurückhalten bei 5xx, 429 und
Netzwerkfehlern; HTTP 202 (BGG-Warteschlange) wird gemäß `Retry-After` Netzwerkfehlern; HTTP 202 (BGG-Warteschlange) wird gemäß `Retry-After`
@@ -267,6 +332,17 @@ einen sofortigen Sync aus — optional mit einem Sofort-Suchbegriff für eine
gezielte Recherche. Das Ergebnis (neu/aktualisiert/gefiltert) erscheint als gezielte Recherche. Das Ergebnis (neu/aktualisiert/gefiltert) erscheint als
Banner; der Endpunkt ist rollengeschützt (Rezensenten erhalten 403). Banner; der Endpunkt ist rollengeschützt (Rezensenten erhalten 403).
**BGG-API-Token (Pflicht):** BoardGameGeek verlangt seit seiner Umstellung
für die XML API2 eine Bearer-Authentifizierung ([Thread
3602374](https://boardgamegeek.com/thread/3602374)). Alle Requests des
Plugins senden deshalb den Header `Authorization: Bearer <Token>`; der Token
kommt aus `SPIELE_BGG_TOKEN`. Ist kein Token gesetzt, wird der Sync gar nicht
erst gestartet und mit der Meldung „Kein BGG-API-Token konfiguriert
(SPIELE_BGG_TOKEN) — Sync übersprungen.“ abgebrochen. Lehnt BGG eine Anfrage
mit HTTP 401 ab (fehlender oder ungültiger Token), erscheint statt der
generischen Fehlermeldung der Hinweis, einen gültigen API-Token in
`SPIELE_BGG_TOKEN` zu hinterlegen.
### UI ### UI
Die Seite `/neuheiten` zeigt alle Einträge als Tabelle (Spieltitel, Verlag, Die Seite `/neuheiten` zeigt alle Einträge als Tabelle (Spieltitel, Verlag,
@@ -278,6 +354,46 @@ Autor, Erscheinungsjahr, Status, aktualisiert am):
- Jeder Titel verlinkt direkt auf den BGG-Eintrag. - Jeder Titel verlinkt direkt auf den BGG-Eintrag.
- Sync-Steuerung nur für Rolle Admin/Redakteur. - Sync-Steuerung nur für Rolle Admin/Redakteur.
### Web-Quellen-Crawler mit optionaler KI-Hilfsstufe
Neben BGG crawlt das Plugin zusätzlich redaktionell gepflegte Web-Quellen
(spielbox.de, brettspielbox.de, spiel-essen.de, cliquenabend.de) über je einen
Adapter in `plugins/neuheiten/quellen/` — mit Rate-Limit (1 Request/s),
Retry/Backoff, konditionalen Requests (ETag/304), Fehler-Isolation pro Quelle
und Duplikat-Gate gegen die BGG-Liste. Der Crawler arbeitet standardmäßig rein
regelbasiert. Liefert eine Quelle im Listenelement ein Bild (`img` mit `src`
bzw. `data-src`), wird dessen URL in `bild_url` übernommen — sonst bleibt das
Feld leer.
Optional kann eine **LLM-Hilfsstufe** (`plugins/neuheiten/quellen/ki_hilfe.py`)
zugeschaltet werden — konsistent zur Dedup-KI über die
`SPIELE_NEUHEITEN_KI_*`-Variablen (siehe Tabelle oben). Das LLM wird nur in
zwei Situationen genutzt:
- **Struktur-Erkennung:** Liefert der Regel-Parser keine oder verdächtig wenige
Einträge (0 Treffer; 12 Treffer auf einer sehr linkreichen Seite), darf das
LLM aus dem gelieferten HTML Pagination-/Filter-Folge-URLs vorschlagen.
Der Vorschlag wird im Code hart gefiltert: nur URLs derselben Domain wie die
geparste Seite, maximal 20 je Seite, bereits besuchte/geplante URLs werden
übersprungen, nicht erreichbare Vorschläge brechen den Lauf nicht ab —
Schutz vor Rate-Limit-Überlastung und Domain-Ausbruch.
- **Feld-Extraktion:** Einzelne Treffer sind unsicher (fehlender Verlag oder
verdächtiger/generischer Titel). Nur solche Treffer gehen ans LLM, das
höchstens `titel`, `verlag` und `autor` korrigiert — die URL ist strukturell
nicht änderbar.
Unter der Mindest-Konfidenz (`SPIELE_NEUHEITEN_KI_KONFIDENZ_MIN`,
Standard 0.7) wird jedes KI-Ergebnis verworfen; der klassische Parser-Befund
bleibt dann unverändert bestehen.
- **Fallback-Pflicht:** Standardmäßig aus (`SPIELE_NEUHEITEN_KI_AKTIV=0`).
Ohne vollständige Konfiguration oder bei jedem Fehler (Timeout,
Server-Störung nach genau einem Retry, kaputtes JSON) läuft EXAKT der
klassische Crawler — ein KI-Fehler kann den Sync nie blockieren.
- **Audit:** KI-Eingriffe eines Sync-Laufs landen gebündelt im audit-log
(`korrigiert` / `neuheiten_ki_crawler`) mit Quelle, Modell, Konfidenz und
den jeweils korrigierten Feldern sowie den verfolgten KI-Folge-URLs.
## Plugin „dedup“ (implementiert) ## Plugin „dedup“ (implementiert)
Dedup-Prüfungen bei Import und händigem Eintrag. Die Prüf-Logik liegt Dedup-Prüfungen bei Import und händigem Eintrag. Die Prüf-Logik liegt
@@ -309,6 +425,37 @@ Teilprüfung. BGG-Störungen blockieren nie: der Hilfsclient degradiert auf
die reine Heuristik und protokolliert nur. Abschaltbar über die reine Heuristik und protokolliert nur. Abschaltbar über
`SPIELE_DEDUP_BGG_AKTIV=0` (Standard: an, Rate-Limit 1 s). `SPIELE_DEDUP_BGG_AKTIV=0` (Standard: an, Rate-Limit 1 s).
### LLM-Zweitprüfung für Grenzfälle
Regeln stoßen bei Editionen, Big Boxes, Übersetzungen und Verlags-Aliasen an
Grenzen („El Grande Big Box“ vs. „El Grande: 25 Jahre“, „Catan Das schnelle
Spiel“ vs. „Catan Fast Edition“). Deshalb kann optional ein LLM als **zweite
Stufe** eingeschaltet werden — die Regelprüfung bleibt maßgeblich:
- **Nur Grenzfälle gehen ans LLM:** Titel mit gemeinsamen Wort-Tokens, aber
Fuzzy-Score unter der Schwelle (max. 5 Paarungen je Prüfung), sowie ein
ergänzender Call zum Verlags-/Titel-Hinweis bei einem regelbasierten
Verlags-Konflikt. Klare Treffer und klare Non-Treffer kosten kein Token.
- **Kontext:** beide Titel inkl. Alternate-Names (BGG-Sync), Verlage und ggf.
BGG-Expansion-Relationen.
- **Strukturierte Antwort** (JSON): `ist_gleiches_spiel`, `konfidenz`,
`begruendung`, `empfohlener_verlag`, `deutscher_titel`. Bestätigte Treffer
laufen in die üblichen Prüfungen a)d) zurück; ein übernommener deutscher
Titel füllt die Titel-Empfehlung, ein Verlags-Hinweis landet im Detail des
Verlags-Konflikts. Unter der Mindest-Konfidenz
(`SPIELE_DEDUP_LLM_KONFIDENZ_MIN`, Standard 0.7) wird das Ergebnis
verworfen und nur der Regelbefund angezeigt.
- **Fallback-Pflicht:** Standardmäßig aus (`SPIELE_DEDUP_LLM_AKTIV=0`). Ohne
Key oder bei jedem Fehler (Timeout, Server-Störung nach genau einem Retry,
kaputtes JSON) verhält sich die Prüfung exakt wie rein regelbasiert — das
LLM kann die Prüfung nie blockieren.
- **Audit:** Jede Prüfung mit LLM-Befunden schreibt einen Eintrag ins
audit-log (`geprüft` / `dedup_ki`) mit Modell, Konfidenz und Entscheidung
je bewertetem Grenzfall.
Schnittstelle: OpenAI-kompatible Chat-Completions-API via httpx, konfiguriert
über die `SPIELE_DEDUP_LLM_*`-Variablen (siehe Tabelle oben).
### Prüfprotokoll & Prüfseite ### Prüfprotokoll & Prüfseite
Jede `check_titel`-Prüfung landet in der eigenen Migration Jede `check_titel`-Prüfung landet in der eigenen Migration
@@ -324,12 +471,14 @@ laufen automatisch durch die dedup-Prüfung.
### Datentabelle ### Datentabelle
Eigene Migration `0001_planungsliste`, Tabelle `planungsliste`: Eigene Migrationen `0001_planungsliste` und `0002_bild_url`,
Tabelle `planungsliste`:
| Spalte | Inhalt | | Spalte | Inhalt |
|--------|--------| |--------|--------|
| `titel` / `verlag` / `autor` | Spieldaten | | `titel` / `verlag` / `autor` | Spieldaten |
| `bgg_id` | BoardGameGeek-ID (Match-Kriterium der dedup-Prüfung) | | `bgg_id` | BoardGameGeek-ID (Match-Kriterium der dedup-Prüfung) |
| `bild_url` | URL des Coverbildes (nullable; wird beim Verschieben aus den Neuheiten übernommen) |
| `ausgabe` | Magazin-Ausgabe, z. B. „3/2025“ (mehrere parallel möglich) | | `ausgabe` | Magazin-Ausgabe, z. B. „3/2025“ (mehrere parallel möglich) |
| `rezensent_id` | Zuordnung (FK auf die Benutzer-Tabelle des Kerns) | | `rezensent_id` | Zuordnung (FK auf die Benutzer-Tabelle des Kerns) |
| `status` | `offen` → `in_bearbeitung` → `abgeschlossen` | | `status` | `offen` → `in_bearbeitung` → `abgeschlossen` |
@@ -363,12 +512,180 @@ 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 Migrationen `0001_archiv_tabellen` und `0002_bild_url`
(Spiegel-Tabellen, SQLite ↔ Postgres portabel, ohne Fremdschlüssel —
archivierte Zeilen überleben auch das Löschen eines Benutzers;
`bild_url` wird mit archiviert und beim Wiederherstellen zurückgeschrieben):
| 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).
Für Postgres genügt `SPIELE_DATABASE_URL`; die Migrationen sind portabel Für Postgres genügt `SPIELE_DATABASE_URL`; die Migrationen sind portabel
geschrieben. geschrieben.
## Oberfläche: Coverbilder & responsives Layout
**Coverbilder:** Die Listen (Neuheiten, Planung, Archiv) zeigen je Eintrag
eine kleine Cover-Vorschau (40×56 px, `loading="lazy"`, `decoding="async"`,
Alt-Text = Spieltitel); die Detail-/Prüf-Ansichten der Planung zeigen das
Cover großformatig (max. 200 px Breite). Fehlt ein Bild, erscheint ein
dezenter CSS-Platzhalter mit Spielicon-Zeichen. Eingebettet wird per
Hotlink direkt von BGG bzw. der jeweiligen Quelle — es werden keine Bilder
heruntergeladen oder lokal gespeichert.
**Responsive Verhalten:** Das Basis-Layout ist mobil-first:
- Navigation: Ab Tablet (`md:`) horizontale Leiste; auf Mobilgeräten ein
Burger-Menü (Alpine.js) mit vollhöhen Touch-Zielen.
- Listen: Auf schmalen Screens (<768 px) kollabieren Neuheiten, Planung und
Archiv zu Karten-Ansichten — Cover links, Titel + Metadaten rechts,
Status-Badge oben. Desktop behält die Tabellenform.
- Formulare (Planung, Benutzerverwaltung, Einstellungen): vollbreite
Eingabefelder mit Labels oberhalb auf Mobil.
- Sync-Übersicht und Audit-Log bleiben Tabellen und scrollen horizontal
(`overflow-x-auto`).
- Buttons/Links haben auf Mobil mindestens 44 px Höhe (Touch-Ziel).
Getestet gegen 375 px (Smartphone), 768 px (Tablet) und Desktop.
## Projekt-Fortschritt ## Projekt-Fortschritt
> Diese Tabelle ist der Live-Status. Sie wird bei jedem Push aktualisiert. > Diese Tabelle ist der Live-Status. Sie wird bei jedem Push aktualisiert.
@@ -379,19 +696,22 @@ 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 | ✅ 316 Tests grün |
| 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) | ✅ live |
Legende: ✅ fertig · 🔄 in Arbeit · ⏳ offen · ⚠️ fertig mit offenen Punkten Legende: ✅ fertig · 🔄 in Arbeit · ⏳ offen · ⚠️ fertig mit offenen Punkten
## 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
## Lizenz
Dieses Projekt steht unter der [MIT-Lizenz](LICENSE).

14
TODO.md Normal file
View File

@@ -0,0 +1,14 @@
# TODO — Web-Quellen im Neuheiten-Plugin (Branch wt-quellen)
> Abgeschlossen.
- [x] Quellen-Adapter angelegt: basis.py, spielbox.py, brettspielbox.py, cliquenabend.py
- [x] Adapter spiel-essen
- [x] Vollständiges Crawling (Pagination/Quartale/A-Z)
- [x] Rate-Limit (1 req/s pro Domain) + User-Agent + ETag/Last-Modified
- [x] Dedup-Gate vor Übernahme in Neuheitenliste
- [x] APScheduler-Job + Env-Schalter je Quelle (_AKTIV)
- [x] KI-Hilfsstufe optional (Struktur-Erkennung + Feld-Extraktion, Fallback klassisch) → folgt als eigener Nachtrag
- [x] Sync-Übersicht im UI (/neuheiten)
- [x] Tests (gemocktes HTML)
- [x] README.md aktualisieren

View File

@@ -8,6 +8,8 @@ services:
- SPIELE_SESSION_SECRET=${SPIELE_SESSION_SECRET:-change-me-in-production} - SPIELE_SESSION_SECRET=${SPIELE_SESSION_SECRET:-change-me-in-production}
# Vor dem ersten Start setzen, sonst wird der Admin mit "admin" angelegt: # Vor dem ersten Start setzen, sonst wird der Admin mit "admin" angelegt:
- SPIELE_INITIAL_ADMIN_PASSWORD=${SPIELE_INITIAL_ADMIN_PASSWORD:-admin} - SPIELE_INITIAL_ADMIN_PASSWORD=${SPIELE_INITIAL_ADMIN_PASSWORD:-admin}
# BGG XML API2 (Pflicht seit Token-Umstellung): siehe .env
- SPIELE_BGG_TOKEN=${SPIELE_BGG_TOKEN}
# Benachrichtigungen (optional): ohne SMTP-Host/Bot-Token wird nur protokolliert # Benachrichtigungen (optional): ohne SMTP-Host/Bot-Token wird nur protokolliert
#- SPIELE_SMTP_HOST=smtp.example.org #- SPIELE_SMTP_HOST=smtp.example.org
#- SPIELE_SMTP_PORT=587 #- SPIELE_SMTP_PORT=587

View File

@@ -1,41 +1,308 @@
"""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)
def _spalte_existiert(conn, tabelle: str, spalte: str) -> bool:
"""Dialekt-agnostische Spaltenprüfung (PRAGMA bzw. information_schema)."""
if conn.dialect.name == "sqlite":
info = conn.exec_driver_sql(f"PRAGMA table_info({tabelle})").fetchall()
return any(zeile[1] == spalte for zeile in info)
existiert = conn.exec_driver_sql(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = :tabelle AND column_name = :spalte",
{"tabelle": tabelle, "spalte": spalte},
).scalar()
return existiert is not None
def _bild_url_spalten(conn) -> None:
"""Migration 0002: Spalte bild_url in beiden Spiegel-Tabellen.
`ALTER TABLE … ADD COLUMN` mit nullable VARCHAR ohne Default ist
SQLite- und Postgres-portabel und erhält bestehende Zeilen.
"""
for tabelle in ("archiv_neuheiten", "archiv_planung"):
if _spalte_existiert(conn, tabelle, "bild_url"):
continue
conn.exec_driver_sql(
f"ALTER TABLE {tabelle} ADD COLUMN bild_url VARCHAR(500)"
)
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),
Migration(version="0002_bild_url", up=_bild_url_spalten),
]
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()

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

@@ -0,0 +1,110 @@
"""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)
bild_url: Mapped[str | None] = mapped_column(String(500))
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)
bild_url: Mapped[str | None] = mapped_column(String(500))
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",
}

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

@@ -0,0 +1,500 @@
"""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"],
bild_url=zeile["bild_url"],
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"],
bild_url=zeile["bild_url"],
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,
"bild_url": a.bild_url,
"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,
"bild_url": a.bild_url,
"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,
bild_url=archiv.bild_url,
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,
bild_url=archiv.bild_url,
ausgabe=archiv.ausgabe,
rezensent_id=rezensent_id,
status=archiv.status,
notizen=archiv.notizen,
quelle=archiv.quelle,
pruefung=archiv.pruefung,
erstellt_am=archiv.erstellt_am,
aktualisiert_am=zeitpunkt,
)
)
neuer_id = ergebnis.inserted_primary_key[0]
titel = archiv.titel
db.delete(archiv)
db.commit()
meldung = f"{titel}“ wurde zurück in die Planungsliste verschoben."
if rezensent_ersetzt:
meldung += (
" Der ursprüngliche Rezensent existierte nicht mehr — "
"der Eintrag wurde dir zugeordnet."
)
return Wiederherstellung(
True,
meldung,
ziel=QUELLE_PLANUNG,
neuer_id=int(neuer_id),
rezensent_ersetzt=rezensent_ersetzt,
titel=titel,
)

View File

@@ -0,0 +1,24 @@
{# Coverbild-Vorschau des Archiv-Plugins.
Erwartete Variablen: cover_bild (URL oder None), cover_titel,
cover_gross (optional: true für großformatige Ansichten).
Bilder werden direkt von der Quelle eingebettet (Hotlinking, kein
Download) — mit loading="lazy" und decoding="async". Ohne Bild zeigt
das Partial einen dezenten Platzhalter mit Spielicon-Zeichen. #}
{% set _gross = cover_gross | default(false) %}
{% if _gross %}
{% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async"
class="block h-auto w-auto max-w-[200px] max-h-[280px] rounded-lg border border-slate-200 bg-white shadow-sm">
{% else %}
<div aria-hidden="true" title="Kein Cover vorhanden"
class="flex h-[210px] w-[150px] max-w-[200px] items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 text-6xl text-slate-300 select-none">&#127922;</div>
{% endif %}
{% else %}
{% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" width="40" height="56"
class="block h-14 w-10 shrink-0 rounded border border-slate-200 bg-slate-50 object-cover">
{% else %}
<div aria-hidden="true" title="Kein Cover vorhanden"
class="flex h-14 w-10 shrink-0 items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 text-lg text-slate-400 select-none">&#127922;</div>
{% endif %}
{% endif %}

View File

@@ -2,9 +2,180 @@
{% 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&uuml;ckliegt, werden t&auml;glich automatisch aus den
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert. aktiven Listen hierher verschoben. Es geht nichts verloren &mdash; jeder
Eintrag l&auml;sst sich unver&auml;ndert zur&uuml;ck in die aktive Liste verschieben.
</p> </p>
<div class="flex items-center gap-4 mb-4 text-xs text-slate-500">
<span class="inline-flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full {{ 'bg-emerald-500' if job_aktiv else 'bg-slate-300' }}"></span>
T&auml;glicher Archiv-Job: {{ 'aktiv' if job_aktiv else 'aus' }}
</span>
<span>{{ anzahl_neuheiten }} archivierte Neuheiten</span>
<span>{{ anzahl_planung }} archivierte Planungseintr&auml;ge</span>
</div>
{% if meldung %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div>
{% endif %}
{% if fehler %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div>
{% endif %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<form method="get" action="/archiv" class="grid grid-cols-1 gap-3 sm:flex sm:flex-wrap sm:items-end">
<div class="sm:w-64 lg:w-72">
<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="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div class="sm:w-48">
<label for="quelle" class="block text-xs font-medium text-slate-500 mb-1">Herkunft</label>
<select id="quelle" name="quelle"
class="w-full sm:w-auto min-h-[44px] md:min-h-0 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="w-full sm:w-auto min-h-[44px] inline-flex items-center justify-center bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
Suchen
</button>
{% if q or quelle_filter %}
<a href="/archiv" class="text-sm text-slate-500 hover:text-emerald-700 px-2 py-2">Zur&uuml;cksetzen</a>
{% endif %}
</form>
</div>
<p class="text-xs text-slate-400 mb-3">{{ anzahl }} Eintr&auml;ge im Archiv</p>
<!-- Mobil (<768px): Karten-Ansicht — Cover links, Titel+Metadaten rechts, Status oben -->
<ul class="md:hidden space-y-3" role="list">
{% for eintrag in eintraege %}
<li class="bg-white rounded-xl border border-slate-200 shadow-sm p-3 flex gap-3">
<div class="pt-0.5">
{% set cover_bild = eintrag.bild_url %}
{% set cover_titel = eintrag.titel %}
{% include "archiv/_cover.html" %}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<h3 class="text-sm font-medium leading-snug break-words">
{{ eintrag.titel }}
{% if eintrag.bgg_id %}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
class="ml-0.5 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
{% endif %}
</h3>
{% if eintrag.quelle == 'neuheiten' %}
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-sky-100 text-sky-800 text-xs">Neuheiten</span>
{% else %}
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-violet-100 text-violet-800 text-xs">Planung</span>
{% endif %}
</div>
<dl class="mt-1.5 space-y-0.5 text-xs text-slate-500">
{% if eintrag.verlag %}<div><dt class="inline font-medium text-slate-400">Verlag: </dt><dd class="inline">{{ eintrag.verlag }}</dd></div>{% endif %}
{% if eintrag.jahr or eintrag.ausgabe %}<div><dt class="inline font-medium text-slate-400">Jahr/Ausgabe: </dt><dd class="inline">{{ eintrag.jahr if eintrag.jahr else eintrag.ausgabe }}</dd></div>{% endif %}
<div><dt class="inline font-medium text-slate-400">Status: </dt><dd class="inline">{{ status_anzeige.get(eintrag.status, eintrag.status) }}</dd></div>
<div>
<dt class="inline font-medium text-slate-400">Archiviert am: </dt>
<dd class="inline">{{ eintrag.archiviert_am.strftime('%d.%m.%Y %H:%M') if eintrag.archiviert_am else '—' }}</dd>
</div>
<div><dt class="inline font-medium text-slate-400">Grund: </dt><dd class="inline">{{ eintrag.grund }}</dd></div>
</dl>
<form method="post" action="/archiv/{{ eintrag.quelle }}/{{ eintrag.id }}/wiederherstellen" class="mt-2">
<button type="submit"
class="w-full min-h-[44px] inline-flex items-center justify-center gap-1 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-3 py-2 rounded-lg text-sm whitespace-nowrap"
title="Eintrag unverändert zurück in die aktive Liste verschieben">
&#8635; Wiederherstellen
</button>
</form>
</div>
</li>
{% else %}
<li class="bg-white rounded-xl border border-slate-200 shadow-sm px-4 py-8 text-center text-sm text-slate-400">
{% if q or quelle_filter %}
Keine Treffer f&uuml;r diese Suche.
{% else %}
Das Archiv ist leer — nichts zu sehen.
{% endif %}
</li>
{% endfor %}
</ul>
<div class="hidden md:block 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">Cover</th>
<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">
{% set cover_bild = eintrag.bild_url %}
{% set cover_titel = eintrag.titel %}
{% include "archiv/_cover.html" %}
</td>
<td class="px-4 py-3 font-medium">
{{ eintrag.titel }}
{% if eintrag.bgg_id %}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap">
{% if eintrag.quelle == 'neuheiten' %}
<span class="inline-block px-2 py-0.5 rounded-full bg-sky-100 text-sky-800 text-xs">Neuheiten</span>
{% else %}
<span class="inline-block px-2 py-0.5 rounded-full bg-violet-100 text-violet-800 text-xs">Planung</span>
{% endif %}
</td>
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ eintrag.jahr if eintrag.jahr else (eintrag.ausgabe or '—') }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ status_anzeige.get(eintrag.status, eintrag.status) }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ namen.get(eintrag.rezensent_id, '—') if eintrag.rezensent_id else '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap text-slate-500">
{{ eintrag.archiviert_am.strftime('%d.%m.%Y %H:%M') if eintrag.archiviert_am else '—' }}
</td>
<td class="px-4 py-3 text-slate-500">{{ eintrag.grund }}</td>
<td class="px-4 py-3">
<form method="post" action="/archiv/{{ eintrag.quelle }}/{{ eintrag.id }}/wiederherstellen">
<button type="submit"
class="text-xs bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-2.5 py-2 rounded-lg whitespace-nowrap"
title="Eintrag unverändert zurück in die aktive Liste verschieben">
&#8635; Wiederherstellen
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="10" class="px-4 py-8 text-center text-slate-400">
{% if q or quelle_filter %}
Keine Treffer f&uuml;r diese Suche.
{% else %}
Das Archiv ist leer — nichts zu sehen.
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %} {% endblock %}

View File

@@ -66,7 +66,7 @@
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button type="submit" class="px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700"> <button type="submit" class="w-full sm:w-auto min-h-[44px] inline-flex items-center justify-center px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 active:bg-emerald-800">
Speichern Speichern
</button> </button>
<span class="text-xs text-slate-400">Ohne ausgew&auml;hlten Kanal erhalten Sie keine Benachrichtigungen.</span> <span class="text-xs text-slate-400">Ohne ausgew&auml;hlten Kanal erhalten Sie keine Benachrichtigungen.</span>

View File

@@ -15,6 +15,17 @@ in `pruefung.py` beschrieben. Jede Prüfung wird in der eigenen Tabelle
Zusatzdaten von BoardGameGeek (Alternate-Names, Erweiterungs-Relationen) Zusatzdaten von BoardGameGeek (Alternate-Names, Erweiterungs-Relationen)
können über SPIELE_DEDUP_BGG_AKTIV=0 abgeschaltet werden (Standard: an); können über SPIELE_DEDUP_BGG_AKTIV=0 abgeschaltet werden (Standard: an);
ohne Netzwerk degradiert die Prüfung automatisch auf die Heuristik. ohne Netzwerk degradiert die Prüfung automatisch auf die Heuristik.
Als zweite Stufe kann ein LLM Grenzfälle bewerten — Titel mit
Wort-Überschneidung unter der Fuzzy-Schwelle (Editionen, Big Boxes,
Übersetzungen) und Verlags-Konflikte mit ähnlichem Titel (Verlags-Aliase).
Standardmäßig aus (`SPIELE_DEDUP_LLM_AKTIV=0`); konfiguriert wird eine
OpenAI-kompatible Chat-Completions-API über `SPIELE_DEDUP_LLM_*`-Variablen
(siehe ki_pruefung.py / README). Ohne Aktivierung, ohne Key oder bei jedem
Fehler verhält sich die Prüfung exakt wie rein regelbasiert; KI-Befunde
unterhalb der Mindest-Konfidenz (`SPIELE_DEDUP_LLM_KONFIDENZ_MIN`,
Standard 0.7) werden verworfen. Übernommene Befunde laufen in die üblichen
Prüfungen a)d) zurück und werden im audit-log protokolliert.
""" """
from __future__ import annotations from __future__ import annotations
@@ -30,6 +41,7 @@ from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry from redaktionskern.contracts import BasePlugin, Migration, NavEntry
from .bgg import BggPruefClient from .bgg import BggPruefClient
from .ki_pruefung import KiPruefClient, lade_konfiguration
from .models import Pruefung from .models import Pruefung
from .pruefung import KONFLIKT_ANZEIGE, PruefErgebnis, fuehre_pruefung_durch from .pruefung import KONFLIKT_ANZEIGE, PruefErgebnis, fuehre_pruefung_durch
@@ -59,6 +71,12 @@ class DedupPlugin(BasePlugin):
#: Von Tests überschreibbare Fabrik für den BGG-Hilfsclient. #: Von Tests überschreibbare Fabrik für den BGG-Hilfsclient.
self.bgg_client_fabrik = None self.bgg_client_fabrik = None
#: Konfiguration der KI-Zweitprüfung (wird in on_load aus Env gelesen).
self.ki_konfiguration = lade_konfiguration({})
self._ki_client: KiPruefClient | None = None
#: Von Tests überschreibbare Fabrik für den KI-Client (Mock-Transport).
self.ki_client_fabrik = None
self._routen_registrieren() self._routen_registrieren()
# ---------- Plugin-Vertrag ---------- # ---------- Plugin-Vertrag ----------
@@ -72,11 +90,17 @@ class DedupPlugin(BasePlugin):
def on_load(self, context) -> None: def on_load(self, context) -> None:
super().on_load(context) super().on_load(context)
self._bgg_aktiv = os.environ.get("SPIELE_DEDUP_BGG_AKTIV", "1").strip() != "0" self._bgg_aktiv = os.environ.get("SPIELE_DEDUP_BGG_AKTIV", "1").strip() != "0"
# KI-Zweitprüfung (Standard aus, siehe ki_pruefung.py): ohne Key oder
# mit SPIELE_DEDUP_LLM_AKTIV=0 läuft die Prüfung exakt wie bisher.
self.ki_konfiguration = lade_konfiguration()
def on_unload(self) -> None: def on_unload(self) -> None:
if self._bgg_client is not None: if self._bgg_client is not None:
self._bgg_client.schliessen() self._bgg_client.schliessen()
self._bgg_client = None self._bgg_client = None
if self._ki_client is not None:
self._ki_client.schliessen()
self._ki_client = None
super().on_unload() super().on_unload()
# ---------- Öffentliche Prüf-API für andere Plugins ---------- # ---------- Öffentliche Prüf-API für andere Plugins ----------
@@ -102,6 +126,7 @@ class DedupPlugin(BasePlugin):
id_wert = None id_wert = None
client = self._hilfsclient() client = self._hilfsclient()
ki_client = self._ki_hilfsclient()
try: try:
ergebnis = fuehre_pruefung_durch( ergebnis = fuehre_pruefung_durch(
self.context.session_factory, self.context.session_factory,
@@ -110,22 +135,65 @@ class DedupPlugin(BasePlugin):
verlag=verlag, verlag=verlag,
bgg_id=id_wert, bgg_id=id_wert,
user_id=user.id if user is not None else None, user_id=user.id if user is not None else None,
ki_client=ki_client,
ki_konfidenz_min=self.ki_konfiguration.konfidenz_min,
) )
finally: finally:
if client is not None: if client is not None:
client.schliessen() client.schliessen()
if ki_client is not None:
ki_client.schliessen()
self._protokolliere(ergebnis, user) self._protokolliere(ergebnis, user)
await self._auditiere_ki_befunde(ergebnis, user)
return ergebnis return ergebnis
# ---------- Internas ---------- # ---------- Internas ----------
def _ki_hilfsclient(self) -> KiPruefClient | None:
"""KI-Client nur bei aktivierter, vollständiger Konfiguration (sonst None).
Die Aktivierungsprüfung gilt vor jeder injizierten Fabrik — ist die
Zweitprüfung deaktiviert oder unvollständig konfiguriert, wird nie
ein Client gebaut und damit nie ein LLM-Aufruf getätigt.
"""
if self.context is None or not self.ki_konfiguration.vollstaendig:
return None
if self.ki_client_fabrik is not None:
return self.ki_client_fabrik()
if self._ki_client is None:
self._ki_client = KiPruefClient(self.ki_konfiguration)
return self._ki_client
async def _auditiere_ki_befunde(self, ergebnis: PruefErgebnis, user) -> None:
"""LLM-Befunde ins audit-log schreiben (best effort, nie blockierend).
Ein Eintrag je Prüfung mit allen KI-Entscheidungen: Modell, Konfidenz
und Entscheidung je bewertetem Grenzfall.
"""
if not ergebnis.ki_befunde:
return
registry = self.context.registry if self.context is not None else None
audit = registry.get("audit-log") if registry is not None else None
if audit is None:
return
details = {
"titel": ergebnis.titel,
"modell": ergebnis.ki_befunde[0].get("modell", ""),
"befunde": ergebnis.ki_befunde,
}
try:
await audit.log(user, "geprüft", "dedup_ki", None, details)
except Exception:
_logger.exception("dedup: KI-Befunde konnten nicht auditiert werden.")
def _hilfsclient(self) -> BggPruefClient | None: def _hilfsclient(self) -> BggPruefClient | None:
if self.bgg_client_fabrik is not None: if self.bgg_client_fabrik is not None:
return self.bgg_client_fabrik() return self.bgg_client_fabrik()
if not self._bgg_aktiv or self.context is None: if not self._bgg_aktiv or self.context is None:
return None return None
if self._bgg_client is None: if self._bgg_client is None:
self._bgg_client = BggPruefClient() token = (os.environ.get("SPIELE_BGG_TOKEN") or "").strip() or None
self._bgg_client = BggPruefClient(token=token)
return self._bgg_client return self._bgg_client
def _protokolliere(self, ergebnis: PruefErgebnis, user: User | None) -> None: def _protokolliere(self, ergebnis: PruefErgebnis, user: User | None) -> None:

View File

@@ -33,6 +33,7 @@ class BggPruefClient:
self, self,
mindestabstand_sekunden: float = 1.0, mindestabstand_sekunden: float = 1.0,
*, *,
token: str | None = None,
transport: httpx.BaseTransport | None = None, transport: httpx.BaseTransport | None = None,
uhr: Callable[[], float] = time.monotonic, uhr: Callable[[], float] = time.monotonic,
schlafen: Callable[[float], None] = time.sleep, schlafen: Callable[[float], None] = time.sleep,
@@ -42,8 +43,11 @@ class BggPruefClient:
self._zuletzt: float | None = None self._zuletzt: float | None = None
self._uhr = uhr self._uhr = uhr
self._schlafen = schlafen self._schlafen = schlafen
headers: dict[str, str] = {}
if token:
headers["Authorization"] = f"Bearer {token}"
self._client = httpx.Client( self._client = httpx.Client(
base_url=BASIS_URL, timeout=timeout, transport=transport base_url=BASIS_URL, timeout=timeout, transport=transport, headers=headers
) )
def schliessen(self) -> None: def schliessen(self) -> None:

View File

@@ -0,0 +1,345 @@
"""KI-Zweitprüfung des Plugins „dedup“ — LLM nur für Grenzfälle.
Die Regelprüfung (`pruefung.py`) bleibt maßgeblich: Klare Treffer und klare
Non-Treffer werden ausschließlich regelbasiert entschieden. Nur wenn die
Regeln einen Grenzfall liefern — Titel-Score über 0, aber unter der
Fuzzy-Schwelle (z. B. „El Grande Big Box“ vs. „El Grande: 25 Jahre“) oder
ein Verlags-Konflikt bei ähnlichem Titel — wird ein LLM als zweite Stufe
gefragt.
Schnittstelle: OpenAI-kompatible Chat-Completions-API (`POST
{basis_url}/chat/completions`), konfigurierbar über Umgebungsvariablen:
| Variable | Bedeutung |
|----------|-----------|
| `SPIELE_DEDUP_LLM_AKTIV` | `1` = Zweitprüfung an (Standard `0`) |
| `SPIELE_DEDUP_LLM_BASIS_URL` | Basis-URL, z. B. `https://api.openai.com/v1` |
| `SPIELE_DEDUP_LLM_API_KEY` | API-Key (wird als Bearer gesendet) |
| `SPIELE_DEDUP_LLM_MODELL` | Modellname, z. B. `gpt-4o-mini` |
| `SPIELE_DEDUP_LLM_KONFIDENZ_MIN` | Mindest-Konfidenz (Standard `0.7`) |
| `SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN` | Timeout je Aufruf (Standard `20`) |
Der Client ist bewusst genauso defensiv wie der BGG-Hilfsclient: Ohne
Aktivierung/Key oder bei jedem Fehler liefert `bewerte()` None und die
Prüfung fällt auf den reinen Regelbefund zurück — die Prüfung wird nie
blockiert. Netzwerkfehler und Server-Störungen werden genau einmal
wiederholt (insgesamt zwei Versuche), Antwort-Parsing-Fehler nicht.
"""
from __future__ import annotations
import json
import logging
import os
import re
from dataclasses import dataclass
from typing import Any, Mapping
import httpx
_logger = logging.getLogger("plugins.dedup")
#: Standard-Timeout je Chat-Completions-Aufruf in Sekunden.
STANDARD_TIMEOUT = 20.0
#: Gesamtzahl der Versuche (erster Versuch + genau ein Retry).
VERSUCHE = 2
#: HTTP-Statuscodes, die einen Retry rechtfertigen (zeitweilige Störungen).
RETRY_STATUS = frozenset({408, 429, 500, 502, 503, 504})
#: Mindest-Konfidenz, ab der ein KI-Befund übernommen wird.
STANDARD_KONFIDENZ_MIN = 0.7
# ---------------- Konfiguration ----------------
@dataclass(frozen=True)
class KiKonfiguration:
"""Effektive Konfiguration der KI-Zweitprüfung (aus Env gelesen)."""
aktiv: bool = False
basis_url: str = ""
api_key: str = ""
modell: str = ""
konfidenz_min: float = STANDARD_KONFIDENZ_MIN
timeout: float = STANDARD_TIMEOUT
@property
def vollstaendig(self) -> bool:
"""True, wenn Aktiv-Schalter und Zugangsdaten vollständig sind."""
return bool(self.aktiv and self.basis_url and self.api_key and self.modell)
def lade_konfiguration(quelle: Mapping[str, str] | None = None) -> KiKonfiguration:
"""Liest die KI-Konfiguration aus der Umgebung (fehlertolerant).
Kaputte Zahlenwerte (z. B. `KONFIDENZ_MIN=abc`) führen nicht zum Fehler,
sondern zum jeweiligen Standardwert — die Zweitprüfung darf die
Regelprüfung niemals blockieren.
"""
env = os.environ if quelle is None else quelle
aktiv = env.get("SPIELE_DEDUP_LLM_AKTIV", "0").strip() == "1"
try:
konfidenz_min = float(env.get("SPIELE_DEDUP_LLM_KONFIDENZ_MIN", ""))
except ValueError:
konfidenz_min = STANDARD_KONFIDENZ_MIN
konfidenz_min = min(1.0, max(0.0, konfidenz_min))
try:
timeout = float(env.get("SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN", ""))
except ValueError:
timeout = STANDARD_TIMEOUT
timeout = max(1.0, timeout)
return KiKonfiguration(
aktiv=aktiv,
basis_url=env.get("SPIELE_DEDUP_LLM_BASIS_URL", "").strip().rstrip("/"),
api_key=env.get("SPIELE_DEDUP_LLM_API_KEY", "").strip(),
modell=env.get("SPIELE_DEDUP_LLM_MODELL", "").strip(),
konfidenz_min=konfidenz_min,
timeout=timeout,
)
# ---------------- Antwort-Parsing (robust gegen Code-Fences u. Ä.) ----------------
_FENCE_MUSTER = re.compile(r"```(?:json|JSON)?\s*(.*?)\s*```", re.DOTALL)
def extrahiere_json(text: str) -> dict | None:
"""Extrahiert das erste JSON-Objekt aus einer LLM-Antwort.
Toleriert Code-Fences (```json … ```) und begleitenden Text vor/nach dem
Objekt; None, wenn nichts Sinnvolles übrig bleibt.
"""
if not isinstance(text, str):
return None
text = text.strip()
if not text:
return None
# 1) Direkter Versuch.
versuche = [text]
# 2) Inhalt von Code-Fences (alle, der längste gewinnt meistens).
versuche.extend(m.group(1).strip() for m in _FENCE_MUSTER.finditer(text))
# 3) Erstes „{“ bis letztes „}“ (Text drumherum weg).
erstes, letztes = text.find("{"), text.rfind("}")
if 0 <= erstes < letztes:
versuche.append(text[erstes : letztes + 1])
for kandidat in versuche:
try:
daten = json.loads(kandidat)
except (ValueError, TypeError):
continue
if isinstance(daten, dict):
return daten
return None
def _optional_text(wert: Any) -> str | None:
"""Normalisiert ein nullable String-Feld der Antwort."""
if isinstance(wert, str):
wert = wert.strip()
return wert or None
return None
def validiere_antwort(daten: Any) -> dict | None:
"""Prüft und normalisiert die LLM-Antwort gegen das vereinbarte Schema.
Erwartet (nach JSON-Extraktion):
`{ist_gleiches_spiel: bool, konfidenz: float, begruendung: str,
empfohlener_verlag: str|null, deutscher_titel: str|null}`
Kleine Nachsichten: boolesche Werte dürfen als ja/nein-Text kommen,
Konfidenz darf auf 0100-Skala geliefert werden. Alles andere → None.
"""
if not isinstance(daten, dict):
return None
ist_gleiches_spiel = daten.get("ist_gleiches_spiel")
if isinstance(ist_gleiches_spiel, str):
ist_gleiches_spiel = {
"ja": True, "true": True, "yes": True,
"nein": False, "false": False, "no": False,
}.get(ist_gleiches_spiel.strip().lower())
if not isinstance(ist_gleiches_spiel, bool):
return None
try:
konfidenz = float(daten.get("konfidenz"))
except (TypeError, ValueError):
return None
if 10.0 <= konfidenz <= 100.0: # Prozent-Skala tolerieren (z. B. 92 → 0.92)
konfidenz = konfidenz / 100.0
if not 0.0 <= konfidenz <= 1.0: # 1 < x < 10 ist auf keiner Skala plausibel
return None
begruendung = daten.get("begruendung")
if not isinstance(begruendung, str):
begruendung = ""
return {
"ist_gleiches_spiel": ist_gleiches_spiel,
"konfidenz": round(konfidenz, 4),
"begruendung": begruendung.strip(),
"empfohlener_verlag": _optional_text(daten.get("empfohlener_verlag")),
"deutscher_titel": _optional_text(daten.get("deutscher_titel")),
}
# ---------------- Prompt ----------------
_SYSTEM_NACHRICHT = (
"Du assistierst einer Spielemagazin-Redaktion beim Deduplizieren von "
"Brettspieltiteln. Entscheide, ob zwei Einträge dasselbe Spiel meinen "
"(auch across Sprachen, Editionen, Big Boxes und Verlags-Aliase) oder "
"verschiedene Spiele/Editionen sind. Antworte AUSSCHLIESSLICH mit einem "
"JSON-Objekt nach exakt diesem Schema:\n"
'{"ist_gleiches_spiel": <bool>, "konfidenz": <float 0.0-1.0>, '
'"begruendung": "<kurzer deutscher Satz>", '
'"empfohlener_verlag": "<Verlagsname oder null>", '
'"deutscher_titel": "<bekannter deutscher Titel oder null>"}\n'
"Kein weiterer Text, keine Code-Fences."
)
def baue_prompt(
*,
titel_a: str,
verlag_a: str | None = None,
alternativen_a: list[str] | None = None,
expansionen_a: list[str] | None = None,
titel_b: str,
verlag_b: str | None = None,
alternativen_b: list[str] | None = None,
frage: str,
) -> str:
"""Baut die User-Nachricht mit vollem Kontext beider Einträge."""
def seite(name: str, titel: str, verlag, alternativen, expansionen) -> str:
zeilen = [f"{name}: „{titel}"]
if verlag:
zeilen.append(f"{name} Verlag: {verlag}")
if alternativen:
zeilen.append(f"{name} Alternate Names: " + "; ".join(alternativen))
if expansionen:
zeilen.append(f"{name} ergänzt (BGG-Erweiterungs-Relation): " + "; ".join(expansionen))
return "\n".join(zeilen)
teile = [
seite("Eintrag A", titel_a, verlag_a, alternativen_a, expansionen_a),
seite("Eintrag B", titel_b, verlag_b, alternativen_b, None),
frage,
]
return "\n\n".join(teile)
# ---------------- Client ----------------
class KiPruefClient:
"""OpenAI-kompatibler Chat-Completions-Client für die Zweitprüfung.
Genauso defensiv wie der BGG-Hilfsclient: `bewerte()` wirft nicht,
sondern liefert bei jedem Problem None — die Regelprüfung bleibt dann
allein maßgeblich. Transport und Timeout sind injizierbar, damit Tests
netzwerkfrei bleiben.
"""
def __init__(
self,
konfiguration: KiKonfiguration,
*,
transport: httpx.BaseTransport | None = None,
) -> None:
self._konfiguration = konfiguration
self._client = httpx.Client(
base_url=konfiguration.basis_url,
timeout=konfiguration.timeout,
transport=transport,
headers={
"Authorization": f"Bearer {konfiguration.api_key}",
"Content-Type": "application/json",
},
)
@property
def modell(self) -> str:
return self._konfiguration.modell
def schliessen(self) -> None:
self._client.close()
# ---------- Öffentliche API ----------
def bewerte(
self,
*,
titel_a: str,
titel_b: str,
verlag_a: str | None = None,
verlag_b: str | None = None,
alternativen_a: list[str] | None = None,
alternativen_b: list[str] | None = None,
expansionen_a: list[str] | None = None,
frage: str | None = None,
) -> dict | None:
"""Fragt das LLM, ob zwei Einträge dasselbe Spiel meinen.
Rückgabe ist das validierte Antwort-Dict (siehe `validiere_antwort`)
oder None — ohne Key, bei Netzwerk-/Server-Problemen (nach genau
einem Retry) oder bei unbrauchbarer Antwort.
"""
if not self._konfiguration.vollstaendig:
return None
nutzernachricht = baue_prompt(
titel_a=titel_a,
verlag_a=verlag_a,
alternativen_a=alternativen_a,
expansionen_a=expansionen_a,
titel_b=titel_b,
verlag_b=verlag_b,
alternativen_b=alternativen_b,
frage=frage or (
"Meinen Eintrag A und Eintrag B dasselbe Spiel? Wenn ja, nenne "
"bitte auch, welcher Verlag geführt werden sollte und welchen "
"deutschen Titel es gibt (falls bekannt)."
),
)
payload = {
"model": self._konfiguration.modell,
"temperature": 0,
"messages": [
{"role": "system", "content": _SYSTEM_NACHRICHT},
{"role": "user", "content": nutzernachricht},
],
}
for versuch in range(1, VERSUCHE + 1):
try:
antwort = self._client.post("/chat/completions", json=payload)
except httpx.RequestError as exc: # Netzwerk/Timeout → genau ein Retry
_logger.warning("dedup/KI: Versuch %d/%d fehlgeschlagen (%s)", versuch, VERSUCHE, exc)
continue
if antwort.status_code in RETRY_STATUS: # zeitweilige Störung → genau ein Retry
_logger.warning(
"dedup/KI: Versuch %d/%d mit Status %d — wiederholt.",
versuch, VERSUCHE, antwort.status_code,
)
continue
try:
antwort.raise_for_status() # andere 4xx: kein Retry hilft
except httpx.HTTPStatusError as exc:
_logger.warning("dedup/KI: Anfrage abgelehnt (%s) — nur Regelbefund.", exc)
return None
try:
inhalte = antwort.json()["choices"][0]["message"]["content"]
except Exception as exc: # kaputtes Antwort-Layout
_logger.warning("dedup/KI: unbrauchbare Antwort (%s)", exc)
return None
return validiere_antwort(extrahiere_json(inhalte))
_logger.warning("dedup/KI: alle %d Versuche fehlgeschlagen — nur Regelbefund.", VERSUCHE)
return None

View File

@@ -43,6 +43,10 @@ TITEL_SCHWELLE = 85
#: Literal, damit keine Import-Abhängigkeit zwischen den Plugins entsteht). #: Literal, damit keine Import-Abhängigkeit zwischen den Plugins entsteht).
STATUS_ABGESCHLOSSEN = "abgeschlossen" STATUS_ABGESCHLOSSEN = "abgeschlossen"
#: Obergrenze für LLM-Aufrufe je Prüfung (Token sparen): höchstens so viele
#: Grenzfall-Paarungen plus ein Verlags-/Titel-Call bei Verlags-Konflikt.
MAX_KI_PAARUNGEN = 5
#: Funktionswörter, die stark auf einen deutschen Spieltitel hindeuten. #: Funktionswörter, die stark auf einen deutschen Spieltitel hindeuten.
DEUTSCHE_INDIKATOREN = frozenset({ DEUTSCHE_INDIKATOREN = frozenset({
"der", "die", "das", "den", "dem", "des", "der", "die", "das", "den", "dem", "des",
@@ -142,6 +146,67 @@ def _verlags_schluessel(verlag: str) -> str:
return normalisiere(verlag) return normalisiere(verlag)
def _gemeinsame_woerter(a: str, b: str) -> int:
"""Anzahl gemeinsamer Wort-Tokens zweiter Titel nach Normalisierung.
Als „Titel-Score > 0, aber unter Schwelle“ gilt eine Wort-Überschneidung
über 0 bei Fuzzy-Score unter TITEL_SCHWELLE — reine Zeichenähnlichkeit
ohne gemeinsames Wort („Cascadia“ vs. „Everdell“) bleibt ein klarer
Non-Treffer und kostet kein LLM-Token.
"""
wa = set(normalisiere(a).split())
wb = set(normalisiere(b).split())
return len(wa & wb)
def _titel_score(titel_a: str, titel_b: str) -> int:
if fuzz is None: # pragma: no cover
return 0
return int(fuzz.token_set_ratio(normalisiere(titel_a), normalisiere(titel_b)))
def _ki_bestaetigt(antwort: dict | None, konfidenz_min: float) -> bool:
"""True, wenn ein KI-Befund übernommen wird (gleiches Spiel, genug Konfidenz)."""
return bool(
antwort
and antwort.get("ist_gleiches_spiel")
and float(antwort.get("konfidenz") or 0.0) >= konfidenz_min
)
def _ki_protokoll(
typ: str,
titel_a: str,
titel_b: str,
ki_client,
antwort: dict | None,
*,
konfidenz_min: float,
) -> dict:
"""Fasst einen KI-Aufruf für Audit-Log/Protokoll zusammen."""
if antwort is None:
entscheidung = "fehler_verworfen"
konfidenz = None
elif not antwort.get("ist_gleiches_spiel"):
entscheidung = "verschiedene_spiele"
konfidenz = antwort.get("konfidenz")
elif float(antwort.get("konfidenz") or 0.0) < konfidenz_min:
entscheidung = "verworfen_unter_schwelle"
konfidenz = antwort.get("konfidenz")
else:
entscheidung = "gleiches_spiel"
konfidenz = antwort.get("konfidenz")
return {
"typ": typ,
"titel_a": titel_a[:300],
"titel_b": titel_b[:300],
"modell": getattr(ki_client, "modell", "") or "",
"konfidenz": konfidenz,
"entscheidung": entscheidung,
"begruendung": (antwort or {}).get("begruendung", ""),
}
# ---------------- Prüfergebnis ---------------- # ---------------- Prüfergebnis ----------------
#: Anzeigenamen der Konflikt-Arten (deutsche UI). #: Anzeigenamen der Konflikt-Arten (deutsche UI).
@@ -181,6 +246,9 @@ class PruefErgebnis:
verlags_optionen: list[str] = field(default_factory=list) verlags_optionen: list[str] = field(default_factory=list)
#: Empfohlener (deutschester) Titel, falls Titel-Varianten bekannt sind. #: Empfohlener (deutschester) Titel, falls Titel-Varianten bekannt sind.
titel_empfehlung: str | None = None titel_empfehlung: str | None = None
#: Protokoll der KI-Zweitprüfung (Grenzfälle) — je Eintrag u. a. Modell,
#: Konfidenz und Entscheidung. Leer bei rein regelbasierten Prüfungen.
ki_befunde: list[dict] = field(default_factory=list)
@classmethod @classmethod
def ohne_befund(cls, titel: str, verlag: str | None = None, bgg_id: int | None = None): def ohne_befund(cls, titel: str, verlag: str | None = None, bgg_id: int | None = None):
@@ -206,6 +274,8 @@ class PruefErgebnis:
and normalisiere(self.titel_empfehlung) != normalisiere(self.titel) and normalisiere(self.titel_empfehlung) != normalisiere(self.titel)
): ):
zeilen.append(f"• Empfohlener Titel: „{self.titel_empfehlung}") zeilen.append(f"• Empfohlener Titel: „{self.titel_empfehlung}")
if self.ki_befunde:
zeilen.append(f"• KI-Zweitprüfung: {len(self.ki_befunde)} Grenzfall/Grenzfälle bewertet.")
return "\n".join(zeilen) return "\n".join(zeilen)
@@ -269,12 +339,18 @@ def fuehre_pruefung_durch(
verlag: str | None = None, verlag: str | None = None,
bgg_id: int | None = None, bgg_id: int | None = None,
user_id: int | None = None, user_id: int | None = None,
ki_client=None,
ki_konfidenz_min: float = 0.7,
) -> PruefErgebnis: ) -> PruefErgebnis:
"""Führt alle vier Prüfungen durch und liefert ein PruefErgebnis. """Führt alle vier Prüfungen durch und liefert ein PruefErgebnis.
`bgg_hilfsclient` darf None sein (BGG-Zusatzdaten deaktiviert) — dann `bgg_hilfsclient` darf None sein (BGG-Zusatzdaten deaktiviert) — dann
entfallen Alternate-Names und Vorgänger-Relationen, die Heuristik und entfallen Alternate-Names und Vorgänger-Relationen, die Heuristik und
der Fuzzy-Match arbeiten weiter. der Fuzzy-Match arbeiten weiter.
`ki_client` darf None sein (KI-Zweitprüfung deaktiviert/fehlerhaft) —
dann läuft ausschließlich die Regelprüfung; sonst werden nur Grenzfälle
(Titel-Score > 0 unter Schwelle bzw. Verlags-Konflikt) dem LLM vorgelegt
und bestätigte Treffer in die Regelprüfungen zurückgeführt.
`user_id` ist der Eintragende: eigene Planungseinträge lösen keinen `user_id` ist der Eintragende: eigene Planungseinträge lösen keinen
„schon in Planung“-Konflikt aus. „schon in Planung“-Konflikt aus.
""" """
@@ -284,6 +360,7 @@ def fuehre_pruefung_durch(
ergebnis = PruefErgebnis.ohne_befund(titel, verlag, id_wert) ergebnis = PruefErgebnis.ohne_befund(titel, verlag, id_wert)
if not titel: if not titel:
return ergebnis return ergebnis
konfidenz_min = max(0.0, min(1.0, float(ki_konfidenz_min)))
with session_factory() as db: with session_factory() as db:
neuheiten = _lade_zeilen(db, "neuheiten", ("id", "titel", "verlag", "bgg_id")) neuheiten = _lade_zeilen(db, "neuheiten", ("id", "titel", "verlag", "bgg_id"))
@@ -304,12 +381,58 @@ def fuehre_pruefung_durch(
except Exception as exc: except Exception as exc:
_logger.warning("dedup: BGG-Vorgänger nicht verfügbar (%s)", exc) _logger.warning("dedup: BGG-Vorgänger nicht verfügbar (%s)", exc)
# Kandidaten = Einträge zum selben Spiel (Neuheiten + Planungsliste). # Kandidaten = Einträge zum selben Spiel laut Regeln (Neuheiten + Planung).
alle_zeilen = (*neuheiten, *planungen)
kandidaten = [ kandidaten = [
zeile for zeile in (*neuheiten, *planungen) zeile for zeile in alle_zeilen
if _gleiches_spiel(zeile, titel, id_wert) if _gleiches_spiel(zeile, titel, id_wert)
] ]
# ---- KI-Zweitprüfung für Grenzfälle (optional, niemals blockierend) ----
# Klare Treffer (oben) und klare Non-Treffer bleiben regelbasiert; nur
# Titel mit Wort-Überschneidung über 0, aber Fuzzy-Score unter der
# Schwelle, werden dem LLM vorgelegt. Bestätigte Treffer laufen in die
# üblichen Prüfungen a)d) zurück, als wären sie regelbasiert gematcht.
ki_bestaetigt: dict[int, dict] = {} # id(zeile) -> KI-Antwort-Dict
ki_titel_vorschlag: str | None = None # erster übernommener deutscher Titel
if ki_client is not None and alle_zeilen:
kandidat_ids = {id(zeile) for zeile in kandidaten}
grenzfaelle = []
for zeile in alle_zeilen:
if id(zeile) in kandidat_ids:
continue
fremd_titel = (zeile.get("titel") or "").strip()
if not fremd_titel:
continue
if _gemeinsame_woerter(titel, fremd_titel) <= 0:
continue # klarer Non-Treffer — kein LLM-Token wert
score = _titel_score(titel, fremd_titel)
if score >= TITEL_SCHWELLE:
continue # wäre ohnehin regelbasiert gematcht
grenzfaelle.append((score, fremd_titel, zeile))
grenzfaelle.sort(key=lambda eintrag: -eintrag[0])
for score, fremd_titel, zeile in grenzfaelle[:MAX_KI_PAARUNGEN]:
try:
antwort = ki_client.bewerte(
titel_a=titel, titel_b=fremd_titel,
verlag_a=verlag, verlag_b=zeile.get("verlag"),
alternativen_a=alternativen,
expansionen_a=[name for _, name in vorgaenger],
)
except Exception as exc: # Zusatzdaten dürfen nie blockieren
_logger.warning("dedup/KI: Grenzfall-Bewertung fehlgeschlagen (%s)", exc)
antwort = None
ergebnis.ki_befunde.append(_ki_protokoll(
"grenzfall", titel, fremd_titel, ki_client, antwort,
konfidenz_min=konfidenz_min,
))
if _ki_bestaetigt(antwort, konfidenz_min):
kandidaten.append(zeile)
ki_bestaetigt[id(zeile)] = antwort
if antwort.get("deutscher_titel"):
ki_titel_vorschlag = ki_titel_vorschlag or antwort["deutscher_titel"]
# a) Verlags-Konflikt: gleiches Spiel unter anderem Verlag/Vertrieb. # a) Verlags-Konflikt: gleiches Spiel unter anderem Verlag/Vertrieb.
bekannte_verlage: dict[str, str] = {} bekannte_verlage: dict[str, str] = {}
for zeile in kandidaten: for zeile in kandidaten:
@@ -340,8 +463,9 @@ def fuehre_pruefung_durch(
zeile["titel"] for zeile in kandidaten zeile["titel"] for zeile in kandidaten
if normalisiere(zeile.get("titel") or "") != normalisiere(titel) if normalisiere(zeile.get("titel") or "") != normalisiere(titel)
] ]
ergebnis.titel_empfehlung = bevorzuge_deutschen_titel( ergebnis.titel_empfehlung = (
[titel, *varianten, *alternativen] ki_titel_vorschlag # KI-Empfehlung (bestätigter Grenzfall) hat Vorrang
or bevorzuge_deutschen_titel([titel, *varianten, *alternativen])
) )
if varianten and ergebnis.titel_empfehlung \ if varianten and ergebnis.titel_empfehlung \
and normalisiere(ergebnis.titel_empfehlung) != normalisiere(titel): and normalisiere(ergebnis.titel_empfehlung) != normalisiere(titel):
@@ -361,12 +485,16 @@ def fuehre_pruefung_durch(
# c) Spiel oder Vorgänger bereits besprochen (Planungsstatus „abgeschlossen“). # c) Spiel oder Vorgänger bereits besprochen (Planungsstatus „abgeschlossen“).
besprochen = [z for z in planungen if z.get("status") == STATUS_ABGESCHLOSSEN] besprochen = [z for z in planungen if z.get("status") == STATUS_ABGESCHLOSSEN]
for zeile in besprochen: for zeile in besprochen:
if _gleiches_spiel(zeile, titel, id_wert): if _gleiches_spiel(zeile, titel, id_wert) or id(zeile) in ki_bestaetigt:
wer = _rezensent_name(zeile, namen) wer = _rezensent_name(zeile, namen)
details = {"planungseintrag": zeile["id"], "titel": zeile["titel"], "rezensent": wer}
if id(zeile) in ki_bestaetigt:
details["ki_bestaetigt"] = True
details["ki_konfidenz"] = ki_bestaetigt[id(zeile)].get("konfidenz")
ergebnis.konflikte.append(TitelKonflikt( ergebnis.konflikte.append(TitelKonflikt(
art="besprochen", art="besprochen",
beschreibung=f"{zeile['titel']}“ wurde bereits besprochen ({wer}).", beschreibung=f"{zeile['titel']}“ wurde bereits besprochen ({wer}).",
details={"planungseintrag": zeile["id"], "titel": zeile["titel"], "rezensent": wer}, details=details,
)) ))
for fremd_id, fremd_titel in vorgaenger: for fremd_id, fremd_titel in vorgaenger:
for zeile in besprochen: for zeile in besprochen:
@@ -394,20 +522,102 @@ def fuehre_pruefung_durch(
for zeile in planungen: for zeile in planungen:
if user_id is not None and zeile.get("rezensent_id") == user_id: if user_id is not None and zeile.get("rezensent_id") == user_id:
continue continue
if _gleiches_spiel(zeile, titel, id_wert): if _gleiches_spiel(zeile, titel, id_wert) or id(zeile) in ki_bestaetigt:
wer = _rezensent_name(zeile, namen) wer = _rezensent_name(zeile, namen)
status = zeile.get("status") or "" status = zeile.get("status") or ""
details = {
"planungseintrag": zeile["id"],
"rezensent": wer,
"status": status,
}
if id(zeile) in ki_bestaetigt:
details["ki_bestaetigt"] = True
details["ki_konfidenz"] = ki_bestaetigt[id(zeile)].get("konfidenz")
ergebnis.konflikte.append(TitelKonflikt( ergebnis.konflikte.append(TitelKonflikt(
art="planung", art="planung",
beschreibung=( beschreibung=(
f"{zeile['titel']}“ ist bereits in der Planungsliste " f"{zeile['titel']}“ ist bereits in der Planungsliste "
f"von {wer} (Status: {status})." f"von {wer} (Status: {status})."
), ),
details={ details=details,
"planungseintrag": zeile["id"],
"rezensent": wer,
"status": status,
},
)) ))
# ---- KI-Zweitprüfung Teil 2: Verlags-/Titel-Hinweis bei Verlags-Konflikt ----
# Verlags-Aliase („Hans im Glück“ vs. „Hans im Glück Verlag“) und
# Übersetzungsfragen kann die Regelprüfung nicht entscheiden — ein
# ergänzender LLM-Call liefert Empfehlungen, ohne den Befund zu ändern.
if ki_client is not None and ergebnis.hat_verlagskonflikt:
_ki_verlags_empfehlung(
ergebnis, ki_client, konfidenz_min=konfidenz_min,
titel=titel, verlag=verlag, kandidaten=kandidaten,
alternativen=alternativen, vorgaenger=vorgaenger,
)
return ergebnis return ergebnis
def _uebernimm_deutschen_titel(ergebnis: PruefErgebnis, deutscher_titel: str | None, eigener_titel: str) -> None:
"""Übernimmt einen KI-deutschen Titel als Empfehlung (konservativ).
Nur wenn die Heuristik selbst nichts Deutsches fand (Empfehlung leer oder
gleich dem eigenen Titel), weißt das LLM die Redaktion ggf. besser.
"""
if not deutscher_titel:
return
aktuell = ergebnis.titel_empfehlung
if aktuell and normalisiere(aktuell) != normalisiere(eigener_titel):
return # Heuristik/KI hat bereits eine abweichende deutsche Variante gewählt
if normalisiere(deutscher_titel) == normalisiere(eigener_titel):
return
ergebnis.titel_empfehlung = deutscher_titel
def _ki_verlags_empfehlung(
ergebnis: PruefErgebnis,
ki_client,
*,
konfidenz_min: float,
titel: str,
verlag: str | None,
kandidaten: list[dict],
alternativen: list[str],
vorgaenger: list[tuple[int, str]],
) -> None:
"""Ein ergänzender KI-Call zum Verlags-Konflikt (Verlags-Aliase, Übersetzung)."""
fremd_zeile = next(
(z for z in kandidaten
if (z.get("verlag") or "").strip() and verlag
and _verlags_schluessel(z["verlag"]) != _verlags_schluessel(verlag)),
None,
)
if fremd_zeile is None:
return
fremd_titel = (fremd_zeile.get("titel") or "").strip()
try:
antwort = ki_client.bewerte(
titel_a=titel, titel_b=fremd_titel,
verlag_a=verlag, verlag_b=fremd_zeile.get("verlag"),
alternativen_a=alternativen,
expansionen_a=[name for _, name in vorgaenger],
frage=(
"Beide Einträge meinen laut Regelprüfung dasselbe Spiel, wurden "
"aber unter verschiedenen Verlagen geführt (ggf. Alias oder "
"Lokalausgabe). Welcher Verlag sollte geführt werden "
"(empfohlener_verlag) und welchen deutschen Titel gibt es?"
),
)
except Exception as exc: # Zusatzdaten dürfen nie blockieren
_logger.warning("dedup/KI: Verlags-Bewertung fehlgeschlagen (%s)", exc)
antwort = None
ergebnis.ki_befunde.append(_ki_protokoll(
"verlag", titel, fremd_titel, ki_client, antwort,
konfidenz_min=konfidenz_min,
))
if _ki_bestaetigt(antwort, konfidenz_min):
verlags_konflikt = next(
(k for k in ergebnis.konflikte if k.art == "verlag"), None
)
if verlags_konflikt is not None and antwort.get("empfohlener_verlag"):
verlags_konflikt.details["ki_empfohlener_verlag"] = antwort["empfohlener_verlag"]
verlags_konflikt.details["ki_konfidenz"] = antwort.get("konfidenz")
_uebernimm_deutschen_titel(ergebnis, antwort.get("deutscher_titel"), titel)

View File

@@ -1,41 +1,538 @@
"""Plugin „erinnerung“ — Platzhalter gemäß Plugin-Vertrag. """Plugin „erinnerung“ — Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag: 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()

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,41 +1,212 @@
"""Plugin „export“ — Platzhalter gemäß Plugin-Vertrag. """Plugin „export“ — Listen als CSV und PDF herunterladen.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag: 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
View File

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

View File

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

View File

@@ -7,23 +7,29 @@ Umfang:
- Manueller „Jetzt synchronisieren“-Button für Admins/Redakteure. - Manueller „Jetzt synchronisieren“-Button für Admins/Redakteure.
- Sortier-/filterbare Neuheitenliste mit Suche (deutsche UI). - Sortier-/filterbare Neuheitenliste mit Suche (deutsche UI).
Konfiguration über Umgebungsvariablen (gelesen beim Plugin-Start): Konfiguration über Umgebungsvariablen (gelesen beim Plugin-Start;
SPIELE_BGG_TOKEN wird dagegen vor jedem Sync gelesen):
- SPIELE_BGG_SYNC_AKTIV „1“ (Standard) = Hintergrund-Job an - SPIELE_BGG_SYNC_AKTIV „1“ (Standard) = Hintergrund-Job an
- SPIELE_BGG_SYNC_INTERVALL_STUNDEN Intervall in Stunden (Standard 24) - SPIELE_BGG_SYNC_INTERVALL_STUNDEN Intervall in Stunden (Standard 24)
- SPIELE_BGG_SUCHBEGRIFFE Komma-getrennte Suchbegriffe - SPIELE_BGG_SUCHBEGRIFFE Komma-getrennte Suchbegriffe
(Standard: „brettspiel“) (Standard: „brettspiel“)
- SPIELE_BGG_MAX_TREFFER_PRO_SUCHE Obergrenze Treffer/Suchbegriff (25) - SPIELE_BGG_MAX_TREFFER_PRO_SUCHE Obergrenze Treffer/Suchbegriff (25)
- SPIELE_BGG_TOKEN API-Token für die BGG-XML-API2
(Pflicht seit BGG-Umstellung, wird als
„Authorization: Bearer …“ gesendet);
ohne Token wird der Sync übersprungen
Keine Fachlogik im Kern — alles hier im Plugin gemäß Plugin-Vertrag. Keine Fachlogik im Kern — alles hier im Plugin gemäß Plugin-Vertrag.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import threading import threading
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from urllib.parse import quote from urllib.parse import quote
from fastapi import Depends, Form, Request from fastapi import Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
@@ -32,11 +38,21 @@ from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext
from .bgg import BggClient, BggFehler from .bgg import BggClient, BggFehler
from .models import Neuheit from .models import Neuheit, QuellenStatus
from .sync import SyncService from .quellen import ADAPTER_KLASSEN
from .quellen_sync import (
QuellenLaufZeile,
WebQuellenSyncService,
quelle_aktiv,
)
from .sync import SyncErgebnis, SyncService
_logger = logging.getLogger("plugins.neuheiten") _logger = logging.getLogger("plugins.neuheiten")
MELDUNG_OHNE_TOKEN = (
"Kein BGG-API-Token konfiguriert (SPIELE_BGG_TOKEN) — Sync übersprungen."
)
SORTIERBAR = { SORTIERBAR = {
"titel": Neuheit.titel, "titel": Neuheit.titel,
"verlag": Neuheit.verlag, "verlag": Neuheit.verlag,
@@ -68,8 +84,11 @@ class NeuheitenPlugin(BasePlugin):
super().__init__() super().__init__()
self._scheduler = None # BackgroundScheduler, falls aktiviert self._scheduler = None # BackgroundScheduler, falls aktiviert
self._sync_sperre = threading.Lock() self._sync_sperre = threading.Lock()
self._quellen_sperre = threading.Lock()
self._suchbegriffe: list[str] = [] self._suchbegriffe: list[str] = []
self._max_treffer_pro_suche = 25 self._max_treffer_pro_suche = 25
self._jahr_filter = True
self._deutscher_titel_filter = True
@self.router.get("/neuheiten") @self.router.get("/neuheiten")
def seite( def seite(
@@ -77,14 +96,17 @@ class NeuheitenPlugin(BasePlugin):
user: User = Depends(require_user), user: User = Depends(require_user),
q: str = "", q: str = "",
status: str = "", status: str = "",
jahr: int | None = None,
sort: str = STANDARD_SORTIERUNG[0], sort: str = STANDARD_SORTIERUNG[0],
richtung: str = STANDARD_SORTIERUNG[1], richtung: str = STANDARD_SORTIERUNG[1],
meldung: str = "", meldung: str = "",
fehler: str = "", fehler: str = "",
seite_nr: int = 1,
): ):
spalte = SORTIERBAR.get(sort, SORTIERBAR[STANDARD_SORTIERUNG[0]]) spalte = SORTIERBAR.get(sort, SORTIERBAR[STANDARD_SORTIERUNG[0]])
abwaerts = richtung == "ab" if sort in SORTIERBAR else False abwaerts = richtung == "ab" if sort in SORTIERBAR else False
spalte_sortiert = spalte.desc() if abwaerts else spalte.asc() spalte_sortiert = spalte.desc() if abwaerts else spalte.asc()
pro_seite = 50
with self.context.session_factory() as db: with self.context.session_factory() as db:
abfrage = select(Neuheit) abfrage = select(Neuheit)
@@ -99,8 +121,31 @@ class NeuheitenPlugin(BasePlugin):
) )
if status.strip(): if status.strip():
abfrage = abfrage.where(Neuheit.status == status.strip()) abfrage = abfrage.where(Neuheit.status == status.strip())
# Erscheinungsjahr-Filter: Standard „aktuelles Jahr und neuer“.
jahr_standard = datetime.now(timezone.utc).year
if jahr is None:
jahr_filter = jahr_standard
abfrage = abfrage.where(
or_(
Neuheit.erscheinungsjahr >= jahr_standard,
Neuheit.erscheinungsjahr.is_(None), # ohne Jahr nicht wegfiltern
)
)
else:
jahr_filter = jahr
abfrage = abfrage.where(Neuheit.erscheinungsjahr >= jahr)
jahr_optionen = [
j for j in db.scalars(
select(Neuheit.erscheinungsjahr).distinct().order_by(
Neuheit.erscheinungsjahr.desc()
)
) if j is not None
]
gesamt = db.scalar(select(func.count()).select_from(abfrage.subquery())) or 0
eintraege = db.scalars( eintraege = db.scalars(
abfrage.order_by(spalte_sortiert, Neuheit.id.asc()) abfrage.order_by(spalte_sortiert, Neuheit.id.asc())
.offset((max(seite_nr, 1) - 1) * pro_seite)
.limit(pro_seite)
).all() ).all()
status_optionen = [ status_optionen = [
zeile for zeile in db.scalars( zeile for zeile in db.scalars(
@@ -108,12 +153,17 @@ class NeuheitenPlugin(BasePlugin):
) )
] ]
quellen_zeilen = self._quellen_uebersicht(db)
hat_weitere = max(seite_nr, 1) * pro_seite < gesamt
kontext = { kontext = {
"user": user, "user": user,
"titel": self.title, "titel": self.title,
"eintraege": eintraege, "eintraege": eintraege,
"q": q, "q": q,
"status_filter": status, "status_filter": status,
"jahr_filter": jahr_filter,
"jahr_standard": jahr_standard,
"jahr_optionen": jahr_optionen,
"sort": sort if sort in SORTIERBAR else STANDARD_SORTIERUNG[0], "sort": sort if sort in SORTIERBAR else STANDARD_SORTIERUNG[0],
"richtung": "ab" if abwaerts else "auf", "richtung": "ab" if abwaerts else "auf",
"status_optionen": status_optionen, "status_optionen": status_optionen,
@@ -125,7 +175,14 @@ class NeuheitenPlugin(BasePlugin):
), ),
"suchbegriffe": ", ".join(self._suchbegriffe) or "", "suchbegriffe": ", ".join(self._suchbegriffe) or "",
"sync_aktiv": self._scheduler is not None and self._scheduler.running, "sync_aktiv": self._scheduler is not None and self._scheduler.running,
"anzahl": len(eintraege), "anzahl": gesamt,
"hat_weitere": hat_weitere,
"naechste_seite": max(seite_nr, 1) + 1,
"quellen_zeilen": quellen_zeilen,
"quellen_sync_aktiv": (
os.environ.get("SPIELE_NEUHEITEN_QUELLEN_SYNC_AKTIV", "1").strip()
== "1"
),
} }
if request.headers.get("HX-Request") == "true": if request.headers.get("HX-Request") == "true":
return self.context.templates.TemplateResponse( return self.context.templates.TemplateResponse(
@@ -151,16 +208,188 @@ class NeuheitenPlugin(BasePlugin):
_logger.warning("Manueller BGG-Sync fehlgeschlagen: %s", exc) _logger.warning("Manueller BGG-Sync fehlgeschlagen: %s", exc)
ziel = f"/neuheiten?meldung={quote(f'Sync fehlgeschlagen: {exc}')}" ziel = f"/neuheiten?meldung={quote(f'Sync fehlgeschlagen: {exc}')}"
return RedirectResponse(ziel, status_code=303) return RedirectResponse(ziel, status_code=303)
ziel = f"/neuheiten?meldung={quote(f'Sync abgeschlossen: {ergebnis.als_text()}')}" if ergebnis.abbruch:
# z. B. fehlender Token oder HTTP 401 — klar benennbare Ursache
ziel = f"/neuheiten?fehler={quote(ergebnis.abbruch)}"
else:
ziel = f"/neuheiten?meldung={quote(f'Sync abgeschlossen: {ergebnis.als_text()}')}"
return RedirectResponse(ziel, status_code=303) return RedirectResponse(ziel, status_code=303)
@self.router.post("/neuheiten/quellen-sync")
def quellen_jetzt_synchronisieren(
user: User = Depends(require_roles(Role.ADMIN.value, Role.REDAKTEUR.value)),
quelle: str = Form(""),
):
"""Gesamt-Sync oder Einzel-Quellen-Sync (`quelle` = Quellname)."""
if quelle:
zeilen = self._quellen_sync_ausfuehren(nur_quellen={quelle})
ziel = f"/neuheiten?meldung={quote('Web-Quelle „' + quelle + '“ synchronisiert: ' + ' · '.join(z.als_text() for z in zeilen))}"
else:
zeilen = self._quellen_sync_ausfuehren()
text = " · ".join(z.als_text() for z in zeilen)
ziel = f"/neuheiten?meldung={quote(f'Web-Quellen-Sync abgeschlossen: {text}')}"
return RedirectResponse(ziel[:2000], status_code=303)
@self.router.post("/neuheiten/quellen/{quellen_name}/schalter")
def quellen_schalter(
quellen_name: str,
user: User = Depends(require_roles(Role.ADMIN.value, Role.REDAKTEUR.value)),
aktiv: str = Form(...),
):
"""An/Aus-Schalter einer Web-Quelle (übersteuert den Env-Standard)."""
if quellen_name not in {k.name for k in ADAPTER_KLASSEN}:
raise HTTPException(status_code=404, detail="Unbekannte Quelle")
assert self.context is not None and self.context.session_factory is not None
with self.context.session_factory() as db:
eintrag = db.scalar(
select(QuellenStatus).where(QuellenStatus.quelle == quellen_name)
)
if eintrag is None:
eintrag = QuellenStatus(quelle=quellen_name, url="")
db.add(eintrag)
eintrag.aktiv_schalter = aktiv == "1"
db.commit()
zustand = "eingeschaltet" if aktiv == "1" else "ausgeschaltet"
ziel = f"/neuheiten?meldung={quote(f'Quelle „{quellen_name}{zustand}.')}"
return RedirectResponse(ziel, status_code=303)
def _quellen_uebersicht(self, db) -> list[dict]:
"""Zeilen der Sync-Übersicht: Adapter + letzter Lauf pro Quelle."""
status_nach_quelle = {
zeile.quelle: zeile
for zeile in db.scalars(select(QuellenStatus)).all()
}
ansichten = []
for klasse in ADAPTER_KLASSEN:
status = status_nach_quelle.get(klasse.name)
schalter = status.aktiv_schalter if status is not None else None
aktiv = (
schalter
if schalter is not None
else quelle_aktiv(klasse.name)
)
ansichten.append(
{
"name": klasse.name,
"anzeigename": klasse.anzeigename,
"url": klasse.start_urls[0] if klasse.start_urls else "",
"aktiv": aktiv,
"letzte_laufzeit": status.letzte_laufzeit if status else None,
"dauer_sekunden": status.dauer_sekunden if status else None,
"neu": status.anzahl_neu if status else 0,
"aktualisiert": status.anzahl_aktualisiert if status else 0,
"gefiltert": status.anzahl_gefiltert if status else 0,
"fehleranzahl": status.anzahl_fehler if status else 0,
"fehlermeldung": status.fehlermeldung if status else None,
"unveraendert": status.unveraendert if status else False,
}
)
return ansichten
# ---------- Plugin-Vertrag ---------- # ---------- Plugin-Vertrag ----------
def migrations(self) -> list[Migration]: def migrations(self) -> list[Migration]:
def neuheiten_tabelle(conn) -> None: def neuheiten_tabelle(conn) -> None:
Neuheit.__table__.create(conn, checkfirst=True) Neuheit.__table__.create(conn, checkfirst=True)
return [Migration(version="0001_neuheiten_tabelle", up=neuheiten_tabelle)] def bgg_id_nullable(conn) -> None:
"""Migration 0002: Web-Quellen-Einträge ohne BGG-ID erlauben."""
if conn.dialect.name == "sqlite":
info = conn.exec_driver_sql("PRAGMA table_info(neuheiten)").fetchall()
bgg_spalte = next((z for z in info if z[1] == "bgg_id"), None)
if bgg_spalte is None or not bgg_spalte[3]:
# Spalte fehlt (frisch angelegt) oder ist bereits nullable.
return
# SQLite kennt kein ALTER COLUMN: Tabelle nach aktuellem Modell
# neu anlegen, Daten übernehmen, alte Tabelle verwerfen.
conn.exec_driver_sql("ALTER TABLE neuheiten RENAME TO neuheiten_alt_0002")
for ix in Neuheit.__table__.indexes:
conn.exec_driver_sql(f'DROP INDEX IF EXISTS "{ix.name}"')
Neuheit.__table__.create(conn, checkfirst=True)
spalten = ", ".join(f'"{c.name}"' for c in Neuheit.__table__.columns)
conn.exec_driver_sql(
f"INSERT INTO neuheiten ({spalten}) SELECT {spalten} FROM neuheiten_alt_0002"
)
conn.exec_driver_sql("DROP TABLE neuheiten_alt_0002")
else:
ist_nullable = conn.exec_driver_sql(
"SELECT is_nullable FROM information_schema.columns "
"WHERE table_name = 'neuheiten' AND column_name = 'bgg_id'"
).scalar()
if ist_nullable == "NO":
conn.exec_driver_sql(
"ALTER TABLE neuheiten ALTER COLUMN bgg_id DROP NOT NULL"
)
def quellen_status_tabelle(conn) -> None:
QuellenStatus.__table__.create(conn, checkfirst=True)
def bild_url_spalte(conn) -> None:
"""Migration 0004: Spalte bild_url für Coverbilder (nullable).
`ALTER TABLE … ADD COLUMN` mit nullable VARCHAR ohne Default ist
SQLite- und Postgres-portabel und erhält bestehende Zeilen.
"""
info = conn.exec_driver_sql("PRAGMA table_info(neuheiten)").fetchall()
vorhanden = any(zeile[1] == "bild_url" for zeile in info)
if conn.dialect.name == "sqlite" and vorhanden:
return
if conn.dialect.name != "sqlite":
existiert = conn.exec_driver_sql(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'neuheiten' AND column_name = 'bild_url'"
).scalar()
if existiert is not None:
return
conn.exec_driver_sql(
"ALTER TABLE neuheiten ADD COLUMN bild_url VARCHAR(500)"
)
def quellen_url_spalte(conn) -> None:
"""Migration 0005: Spalte quellen_url für den Link zur Quelle."""
info = conn.exec_driver_sql("PRAGMA table_info(neuheiten)").fetchall()
vorhanden = any(zeile[1] == "quellen_url" for zeile in info)
if conn.dialect.name == "sqlite" and vorhanden:
return
if conn.dialect.name != "sqlite":
existiert = conn.exec_driver_sql(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'neuheiten' AND column_name = 'quellen_url'"
).scalar()
if existiert is not None:
return
conn.exec_driver_sql(
"ALTER TABLE neuheiten ADD COLUMN quellen_url VARCHAR(500)"
)
def aktiv_schalter_spalte(conn) -> None:
"""Migration 0006: UI-Schalter pro Web-Quelle (nullable Boolean)."""
info = conn.exec_driver_sql(
"PRAGMA table_info(neuheiten_quellen_status)"
).fetchall()
vorhanden = any(zeile[1] == "aktiv_schalter" for zeile in info)
if conn.dialect.name == "sqlite" and vorhanden:
return
if conn.dialect.name != "sqlite":
existiert = conn.exec_driver_sql(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'neuheiten_quellen_status' "
"AND column_name = 'aktiv_schalter'"
).scalar()
if existiert is not None:
return
conn.exec_driver_sql(
"ALTER TABLE neuheiten_quellen_status ADD COLUMN aktiv_schalter BOOLEAN"
)
return [
Migration(version="0001_neuheiten_tabelle", up=neuheiten_tabelle),
Migration(version="0002_bgg_id_nullable", up=bgg_id_nullable),
Migration(version="0003_quellen_status", up=quellen_status_tabelle),
Migration(version="0004_bild_url", up=bild_url_spalte),
Migration(version="0005_quellen_url", up=quellen_url_spalte),
Migration(version="0006_aktiv_schalter", up=aktiv_schalter_spalte),
]
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/neuheiten")] return [NavEntry(label=self.title, url="/neuheiten")]
@@ -169,16 +398,30 @@ class NeuheitenPlugin(BasePlugin):
super().on_load(context) super().on_load(context)
import os import os
self._suchbegriffe = _umgebung_liste("SPIELE_BGG_SUCHBEGRIFFE", "brettspiel") self._suchbegriffe = _umgebung_liste(
"SPIELE_BGG_SUCHBEGRIFFE",
"brettspiel, gesellschaftsspiel, familienspiel, strategiespiel, "
"kartenspiel, würfelspiel",
)
try: try:
self._max_treffer_pro_suche = int( self._max_treffer_pro_suche = int(
os.environ.get("SPIELE_BGG_MAX_TREFFER_PRO_SUCHE", "25") os.environ.get("SPIELE_BGG_MAX_TREFFER_PRO_SUCHE", "50")
) )
except ValueError: except ValueError:
self._max_treffer_pro_suche = 25 self._max_treffer_pro_suche = 50
self._jahr_filter = (
os.environ.get("SPIELE_BGG_JAHR_FILTER", "1").strip() != "0"
)
self._deutscher_titel_filter = (
os.environ.get("SPIELE_BGG_DEUTSCHE_TITEL_FILTER", "1").strip() != "0"
)
if os.environ.get("SPIELE_BGG_SYNC_AKTIV", "1").strip() == "1": if os.environ.get("SPIELE_BGG_SYNC_AKTIV", "1").strip() == "1":
self._scheduler_starten(os.environ.get("SPIELE_BGG_SYNC_INTERVALL_STUNDEN")) self._scheduler_starten(os.environ.get("SPIELE_BGG_SYNC_INTERVALL_STUNDEN"))
if os.environ.get("SPIELE_NEUHEITEN_QUELLEN_SYNC_AKTIV", "1").strip() == "1":
self._quellen_scheduler_starten(
os.environ.get("SPIELE_NEUHEITEN_QUELLEN_SYNC_INTERVALL_STUNDEN")
)
def on_unload(self) -> None: def on_unload(self) -> None:
if self._scheduler is not None: if self._scheduler is not None:
@@ -188,17 +431,34 @@ class NeuheitenPlugin(BasePlugin):
# ---------- Internas ---------- # ---------- Internas ----------
def _aktives_bgg_token(self) -> str | None:
"""Liest den BGG-API-Token aus der Umgebung (leer = nicht gesetzt).
Bewusst pro Sync gelesen, damit ein rotierter Token ohne Neustart
greift.
"""
import os
return (os.environ.get("SPIELE_BGG_TOKEN") or "").strip() or None
def _neuer_client(self) -> BggClient: def _neuer_client(self) -> BggClient:
"""Fabrik für den BGG-Client; von Tests überschreibbar.""" """Fabrik für den BGG-Client; von Tests überschreibbar."""
return BggClient() return BggClient(token=self._aktives_bgg_token())
def _sync_ausfuehren(self, suchbegriffe: list[str]): def _sync_ausfuehren(self, suchbegriffe: list[str]):
if self._aktives_bgg_token() is None:
# Ohne Token lehnt die BGG-API jeden Request mit 401 ab —
# den Sync gar nicht erst starten.
_logger.warning(MELDUNG_OHNE_TOKEN)
return SyncErgebnis(abbruch=MELDUNG_OHNE_TOKEN)
client = self._neuer_client() client = self._neuer_client()
try: try:
service = SyncService( service = SyncService(
self.context.session_factory, self.context.session_factory,
client, client,
max_treffer_pro_suchbegriff=self._max_treffer_pro_suche, max_treffer_pro_suchbegriff=self._max_treffer_pro_suche,
jahr_filter=self._jahr_filter,
deutscher_titel_filter=self._deutscher_titel_filter,
) )
return service.synchronisiere(suchbegriffe) return service.synchronisiere(suchbegriffe)
finally: finally:
@@ -211,13 +471,53 @@ class NeuheitenPlugin(BasePlugin):
return return
try: try:
ergebnis = self._sync_ausfuehren(self._suchbegriffe) ergebnis = self._sync_ausfuehren(self._suchbegriffe)
_logger.info("BGG-Sync abgeschlossen: %s", ergebnis.als_text()) if ergebnis.abbruch:
_logger.warning("BGG-Sync nicht ausgeführt: %s", ergebnis.abbruch)
else:
_logger.info("BGG-Sync abgeschlossen: %s", ergebnis.als_text())
except Exception as exc: except Exception as exc:
_logger.warning("BGG-Sync fehlgeschlagen: %s", exc) _logger.warning("BGG-Sync fehlgeschlagen: %s", exc)
finally: finally:
self._sync_sperre.release() self._sync_sperre.release()
def _scheduler_starten(self, intervall_stunden_roh: str | None) -> None: def _quellen_sync_ausfuehren(
self, nur_quellen: set[str] | None = None
) -> list[QuellenLaufZeile]:
"""Ein Lauf über alle aktiven Web-Quellen (Fehler pro Quelle isoliert).
`nur_quellen`: optional nur diese Quellnamen (manueller Einzellauf,
ignoriert den An/Aus-Schalter der Quelle).
"""
registry = (
self.context.registry
if self.context is not None and self.context.registry is not None
else None
)
dedup = registry.get("dedup") if registry is not None else None
audit = registry.get("audit-log") if registry is not None else None
service = WebQuellenSyncService(
self.context.session_factory, dedup=dedup, audit=audit
)
return service.laufe(nur_quellen=nur_quellen)
def _quellen_sync_job(self) -> None:
"""Hintergrund-Job: holt regelmäßig die Web-Quellen ab."""
if not self._quellen_sperre.acquire(blocking=False):
_logger.info("Web-Quellen-Sync läuft bereits — Durchlauf übersprungen.")
return
try:
zeilen = self._quellen_sync_ausfuehren()
text = " · ".join(z.als_text() for z in zeilen)
if any(z.fehlermeldung for z in zeilen):
_logger.warning("Web-Quellen-Sync mit Fehlern: %s", text)
else:
_logger.info("Web-Quellen-Sync abgeschlossen: %s", text)
except Exception as exc:
_logger.warning("Web-Quellen-Sync fehlgeschlagen: %s", exc)
finally:
self._quellen_sperre.release()
def _quellen_scheduler_starten(self, intervall_stunden_roh: str | None) -> None:
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
try: try:
@@ -226,7 +526,40 @@ class NeuheitenPlugin(BasePlugin):
intervall_stunden = 24 intervall_stunden = 24
intervall_stunden = max(intervall_stunden, 1) intervall_stunden = max(intervall_stunden, 1)
scheduler = BackgroundScheduler() scheduler = self._scheduler_oder_neu()
erster_lauf = datetime.now(timezone.utc) + timedelta(seconds=15)
scheduler.add_job(
self._quellen_sync_job,
trigger="interval",
hours=intervall_stunden,
next_run_time=erster_lauf,
id="web-quellen-sync",
replace_existing=True,
)
_logger.info(
"Web-Quellen-Sync aktiv: alle %s h (%s)",
intervall_stunden,
", ".join(k.anzeigename for k in ADAPTER_KLASSEN),
)
def _scheduler_oder_neu(self):
"""Ein gemeinsamer BackgroundScheduler für alle Sync-Jobs des Plugins."""
from apscheduler.schedulers.background import BackgroundScheduler
if self._scheduler is None or not self._scheduler.running:
scheduler = BackgroundScheduler()
scheduler.start()
self._scheduler = scheduler
return self._scheduler
def _scheduler_starten(self, intervall_stunden_roh: str | None) -> None:
try:
intervall_stunden = int(intervall_stunden_roh or "24")
except ValueError:
intervall_stunden = 24
intervall_stunden = max(intervall_stunden, 1)
scheduler = self._scheduler_oder_neu()
erster_lauf = datetime.now(timezone.utc) + timedelta(seconds=10) erster_lauf = datetime.now(timezone.utc) + timedelta(seconds=10)
scheduler.add_job( scheduler.add_job(
self._sync_job, self._sync_job,
@@ -236,8 +569,6 @@ class NeuheitenPlugin(BasePlugin):
id="bgg-neuheiten-sync", id="bgg-neuheiten-sync",
replace_existing=True, replace_existing=True,
) )
scheduler.start()
self._scheduler = scheduler
_logger.info( _logger.info(
"BGG-Neuheiten-Sync aktiv: alle %s h, Suchbegriffe: %s", "BGG-Neuheiten-Sync aktiv: alle %s h, Suchbegriffe: %s",
intervall_stunden, intervall_stunden,

View File

@@ -11,6 +11,13 @@ Eigenschaften:
beschränkt; Erweiterungen (`boardgameexpansion`) werden zusätzlich auf beschränkt; Erweiterungen (`boardgameexpansion`) werden zusätzlich auf
Elementebene herausgefiltert. Elementebene herausgefiltert.
Authentifizierung: Die XML API2 verlangt einen Bearer-Token
(Authorization-Header, siehe https://boardgamegeek.com/thread/3602374);
der Token wird dem Client über den Parameter `token` übergeben (im Betrieb
aus `SPIELE_BGG_TOKEN`). Ohne Token lehnt BGG Anfragen mit HTTP 401 ab —
der Client wandelt das in eine klare `BggAuthFehler`-Meldung um und versucht
es nicht erneut.
Für Tests sind HTTP-Transport, Uhr und Schlaf-Funktion injizierbar — Für Tests sind HTTP-Transport, Uhr und Schlaf-Funktion injizierbar —
es gibt keine echten Netzwerk-Aufrufe in der Testsuite. es gibt keine echten Netzwerk-Aufrufe in der Testsuite.
""" """
@@ -35,6 +42,16 @@ class BggFehler(Exception):
"""Fehler bei der Kommunikation mit oder dem Parsen der BGG-API.""" """Fehler bei der Kommunikation mit oder dem Parsen der BGG-API."""
class BggAuthFehler(BggFehler):
"""BGG hat die Anfrage wegen fehlender/unültiger Authentifizierung abgelehnt."""
MELDUNG_401 = (
"BGG hat die Anfrage abgelehnt (401) — bitte gültigen API-Token "
"in SPIELE_BGG_TOKEN hinterlegen."
)
@dataclass(frozen=True) @dataclass(frozen=True)
class SuchTreffer: class SuchTreffer:
"""Ein Treffer aus der BGG-Suche.""" """Ein Treffer aus der BGG-Suche."""
@@ -53,9 +70,34 @@ class BggSpiel:
verlag: str | None verlag: str | None
autor: str | None autor: str | None
erscheinungsjahr: int | None erscheinungsjahr: int | None
#: URL des Coverbildes — bevorzugt das größere <image>, sonst das
#: <thumbnail>; None, wenn BGG keins liefert.
bild_url: str | None = None
typ: str = TYP_BRETTSPIEL typ: str = TYP_BRETTSPIEL
def _bild_url(element: ET.Element) -> str | None:
"""Extrahiert die Coverbild-URL aus einem Thing-Item.
Bevorzugt das große `<image>`, fällt zurück auf das kleinere
`<thumbnail>`. Protokoll-relative URLs (`//cf.geekdo-images.com/…`)
werden auf https normalisiert; leere/kaputte Angaben → None.
"""
for tag in ("image", "thumbnail"):
roh = element.findtext(tag)
if roh is None:
continue
url = roh.strip()
if not url:
continue
if url.startswith("//"):
url = f"https:{url}"
if url.startswith(("http://", "https://")):
return url
return None
return None
def _attribut_wert(element: ET.Element | None, tag: str) -> str | None: def _attribut_wert(element: ET.Element | None, tag: str) -> str | None:
"""Liest `<tag><value>…</value></tag>` tolerant aus.""" """Liest `<tag><value>…</value></tag>` tolerant aus."""
if element is None: if element is None:
@@ -144,6 +186,7 @@ def parse_things(xml_daten: bytes | str) -> list[BggSpiel]:
verlag=_verketten(element, "boardgamepublisher"), verlag=_verketten(element, "boardgamepublisher"),
autor=_verketten(element, "boardgamedesigner"), autor=_verketten(element, "boardgamedesigner"),
erscheinungsjahr=_jahr(_attribut_wert(element, "yearpublished")), erscheinungsjahr=_jahr(_attribut_wert(element, "yearpublished")),
bild_url=_bild_url(element),
typ=typ, typ=typ,
) )
) )
@@ -161,6 +204,7 @@ class BggClient:
def __init__( def __init__(
self, self,
*, *,
token: str | None = None,
transport: httpx.BaseTransport | None = None, transport: httpx.BaseTransport | None = None,
mindestabstand_sekunden: float = 1.0, mindestabstand_sekunden: float = 1.0,
max_versuche: int = 4, max_versuche: int = 4,
@@ -174,14 +218,20 @@ class BggClient:
self.max_versuche = max_versuche self.max_versuche = max_versuche
self.backoff_basis_sekunden = backoff_basis_sekunden self.backoff_basis_sekunden = backoff_basis_sekunden
self.backoff_maximum_sekunden = backoff_maximum_sekunden self.backoff_maximum_sekunden = backoff_maximum_sekunden
# Bearer-Token für die XML API2 (Pflicht seit BGG-Umstellung,
# https://boardgamegeek.com/thread/3602374); leer/None = nicht gesetzt.
self.token = token.strip() if isinstance(token, str) and token.strip() else None
self._schlaf = schlaf self._schlaf = schlaf
self._uhr = uhr self._uhr = uhr
self._letzter_request_um: float | None = None self._letzter_request_um: float | None = None
headers: dict[str, str] = {"User-Agent": USER_AGENT}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
self._http = httpx.Client( self._http = httpx.Client(
base_url=BASIS_URL, base_url=BASIS_URL,
timeout=timeout_sekunden, timeout=timeout_sekunden,
transport=transport, transport=transport,
headers={"User-Agent": USER_AGENT}, headers=headers,
) )
def schliessen(self) -> None: def schliessen(self) -> None:
@@ -214,6 +264,11 @@ class BggClient:
if antwort.status_code == 200: if antwort.status_code == 200:
return antwort.content return antwort.content
if antwort.status_code == 401:
# Fehlender oder ungültiger Token — dauerhaft, ein Retry
# hilft nicht (Token-Pflicht, siehe Thread 3602374).
raise BggAuthFehler(MELDUNG_401)
if antwort.status_code in (202, 429) or antwort.status_code >= 500: if antwort.status_code in (202, 429) or antwort.status_code >= 500:
# 202: BGG stellt die Anfrage in eine Warteschlange; # 202: BGG stellt die Anfrage in eine Warteschlange;
# 429/5xx: temporär nicht bedienbar → Backoff und erneut. # 429/5xx: temporär nicht bedienbar → Backoff und erneut.

View File

@@ -6,9 +6,10 @@ statt Duplikate anzulegen.
""" """
from __future__ import annotations from __future__ import annotations
import json
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from redaktionskern.db import Base from redaktionskern.db import Base
@@ -25,7 +26,14 @@ class Neuheit(Base):
autor: Mapped[str | None] = mapped_column(String(300)) autor: Mapped[str | None] = mapped_column(String(300))
# BoardGameGeek liefert nur das Erscheinungsjahr (kein genaues Datum). # BoardGameGeek liefert nur das Erscheinungsjahr (kein genaues Datum).
erscheinungsjahr: Mapped[int | None] = mapped_column(Integer) erscheinungsjahr: Mapped[int | None] = mapped_column(Integer)
bgg_id: Mapped[int] = mapped_column(Integer, unique=True, index=True) # Eindeutiges Merge-Kriterium der BGG-Syncs; Web-Quellen haben keine
# BGG-ID und speichern NULL (Migration 0002 macht die Spalte nullable,
# Duplikate werden dort über den Titel gematcht statt über die ID).
bgg_id: Mapped[int | None] = mapped_column(Integer, unique=True, index=True)
# URL des Coverbildes (BGG-Image/Thumbnail bzw. Bild der Web-Quelle);
# wird direkt verlinkt (Hotlinking), kein Download (Migration 0004).
bild_url: Mapped[str | None] = mapped_column(String(500))
quellen_url: Mapped[str | None] = mapped_column(String(500))
status: Mapped[str] = mapped_column(String(50), default=STATUS_NEUHEIT, index=True) status: Mapped[str] = mapped_column(String(50), default=STATUS_NEUHEIT, index=True)
quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek") quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek")
erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
@@ -34,4 +42,53 @@ class Neuheit(Base):
) )
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
return f"<Neuheit {self.bgg_id} {self.titel!r}>" return f"<Neuheit {self.bgg_id or '?'} {self.titel!r}>"
class QuellenStatus(Base):
"""Sync-Status einer Web-Quelle (Übersicht im UI + HTTP-Validatoren).
Pro Quelle eine Zeile: Ergebnis des letzten Laufes (Zeitpunkt, Dauer,
neu/aktualisiert/gefiltert/Fehler) plus die konditionalen
Request-Header (ETag/Last-Modified je abgerufener URL), damit 304er
auch über Neustarts hinweg respektiert werden.
"""
__tablename__ = "neuheiten_quellen_status"
id: Mapped[int] = mapped_column(primary_key=True)
quelle: Mapped[str] = mapped_column(String(100), unique=True, index=True)
url: Mapped[str | None] = mapped_column(String(500))
letzte_laufzeit: Mapped[datetime | None] = mapped_column(DateTime)
dauer_sekunden: Mapped[float] = mapped_column(Float, default=0.0)
anzahl_neu: Mapped[int] = mapped_column(Integer, default=0)
anzahl_aktualisiert: Mapped[int] = mapped_column(Integer, default=0)
anzahl_gefiltert: Mapped[int] = mapped_column(Integer, default=0)
anzahl_fehler: Mapped[int] = mapped_column(Integer, default=0)
fehlermeldung: Mapped[str | None] = mapped_column(Text)
unveraendert: Mapped[bool] = mapped_column(Boolean, default=False)
#: UI-Schalter: Quelle im Sync ein-/ausgeschaltet (None = Env-Standard).
#: Übersteuert den Env-Wert SPIELE_NEUHEITEN_QUELLE_<NAME>_AKTIV.
aktiv_schalter: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
#: JSON-Objekt URL → [etag, last_modified]
validatoren_json: Mapped[str | None] = mapped_column(Text)
@property
def validatoren(self) -> dict[str, tuple[str | None, str | None]]:
if not self.validatoren_json:
return {}
try:
roh = json.loads(self.validatoren_json)
except json.JSONDecodeError:
return {}
return {
str(url): (paar[0], paar[1]) if isinstance(paar, (list, tuple)) else (None, None)
for url, paar in roh.items()
}
@validatoren.setter
def validatoren(self, wert: dict[str, tuple[str | None, str | None]]) -> None:
self.validatoren_json = json.dumps(wert or {})
STATUS_UNVERAENDERT_HINWEIS = "unverändert (304) — übersprungen"

View File

@@ -0,0 +1,59 @@
"""Quellen-Adapter des Neuheiten-Plugins (Adapter-Pattern).
Ein Modul pro Quelle plus gemeinsame Basis (`basis.py`); die
Orchestrierung (alle aktiven Quellen, Fehler-Isolation, Dedup, Upsert,
Statusprotokoll) liegt in `quellen_sync.py` des Plugin-Pakets.
Neue Quelle hinzufügen:
1. Modul `meine_quelle.py` mit einer `QuellenAdapter`-Unterklasse
(Klassenattribute `name` und `anzeigename` setzen).
2. Klasse in `ADAPTER_KLASSEN` ergänzen — Env-Schalter, Statuszeile und
Sync-Übersicht ergeben sich automatisch aus dem `name`
(SPIELE_NEUHEITEN_QUELLE_<NAME_GROSS>_AKTIV).
"""
from __future__ import annotations
from .basis import (
USER_AGENT,
QuellenAdapter,
QuellenAntwort,
QuellenFehler,
QuellenTreffer,
SammelErgebnis,
WebQuellenClient,
jahr_aus_datumsangabe,
titel_aehnlichkeit,
titel_normalisieren,
bild_aus_element,
)
from .brettspielbox import BrettspielboxQuelle
from .cliquenabend import CliquenabendQuelle
from .spielessen import SpielEssenQuelle
from .spielbox import SpielboxQuelle
#: Alle verfügbaren Web-Quellen in fester Reihenfolge.
ADAPTER_KLASSEN: tuple[type[QuellenAdapter], ...] = (
SpielboxQuelle,
BrettspielboxQuelle,
SpielEssenQuelle,
CliquenabendQuelle,
)
__all__ = [
"ADAPTER_KLASSEN",
"USER_AGENT",
"QuellenAdapter",
"QuellenAntwort",
"QuellenFehler",
"QuellenTreffer",
"SammelErgebnis",
"WebQuellenClient",
"jahr_aus_datumsangabe",
"titel_aehnlichkeit",
"titel_normalisieren",
"bild_aus_element",
"SpielboxQuelle",
"BrettspielboxQuelle",
"SpielEssenQuelle",
"CliquenabendQuelle",
]

View File

@@ -0,0 +1,388 @@
"""Gemeinsame Basis für die Web-Quellen-Adapter des Neuheiten-Plugins.
Enthält:
- `USER_AGENT`: der freundliche Bot-User-Agent, den alle Quellen senden.
- `WebQuellenClient`: HTTP-Client mit Rate-Limit (max. 1 Request/Sekunde
je Domain), Retry/Backoff bei 5xx/429 und konditionalen Requests
(If-None-Match/If-Modified-Since; HTTP 304 → Quelle unverändert).
- `QuellenTreffer`: das gemeinsame Zwischenformat aller Parser-Adapter
(titel, verlag, autor, erscheinungsdatum_oder_quartal, quellen_url).
- `QuellenAdapter`: Basisklasse für einen Quellen-Adapter. Der Standard-
Ablauf crawlt alle Seiten einer Quelle (Pagination wird über die
Folge-Links jeder Seite entdeckt) und übergibt das Parsing an die
Unterklasse (`seite_verarbeiten`). JS-lastige Quellen überschreiben
`sammle()` und nutzen stattdessen ihren Daten-Endpunkt direkt.
- Hilfsfunktionen: Jahr aus einer Datums-/Quartalsangabe, Titel-
Normalisierung und Fuzzy-Titelähnlichkeit (rapidfuzz) für den
Duplikatsvergleich gegen bestehende Einträge (z. B. aus BGG).
Für Tests sind HTTP-Transport, Uhr und Schlaf-Funktion injizierbar —
die Testsuite führt keine echten Netzwerkaufrufe durch.
"""
from __future__ import annotations
import logging
import re
import time
import xml.etree.ElementTree # noqa: F401 (dokumentiert: keine XML-Nutzung hier)
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import ClassVar
import httpx
from rapidfuzz import fuzz
from .ki_hilfe import KiHilfe
_logger = logging.getLogger(__name__)
#: Freundlicher User-Agent für alle Redaktions-Crawler (Courtesy-Regeln).
USER_AGENT = "SpieleRedaktionBot/1.0 (Redaktions-Tool; Kontakt siehe Repo)"
JAHR_MUSTER = re.compile(r"\b(19|20)(\d{2})\b")
class QuellenFehler(Exception):
"""Fehler beim Abrufen oder Parsen einer Web-Quelle."""
@dataclass(frozen=True)
class QuellenTreffer:
"""Ein geparster Spieleintrag einer Web-Quelle (Zwischenformat).
`erscheinungsdatum_oder_quartal` ist die rohe Angabe der Quelle
(z. B. „24.07.2026“, „10/2026“, „Q3 2026“, „Herbst 2026“);
das Erscheinungsjahr für die Datenbank wird daraus abgeleitet.
"""
titel: str
quellen_url: str
verlag: str | None = None
autor: str | None = None
erscheinungsdatum_oder_quartal: str | None = None
#: Bild-URL der Quelle (img im Listenelement), sofern vorhanden; wird
#: direkt eingebettet (Hotlinking), nicht heruntergeladen.
bild_url: str | None = None
def _bild_url_normalisieren(url: str, basis_url: str | None) -> str | None:
"""Macht aus einer rohen Bildangabe eine absolute http(s)-URL.
Protokoll-relative URLs (`//host/bild.jpg`) erhalten https voran,
pfadrelative Angaben werden gegen die Seiten-URL aufgelöst; alles
andere (data:-URIs etc.) wird verworfen.
"""
url = url.strip()
if url.startswith("//"):
url = f"https:{url}"
elif not url.startswith(("http://", "https://")):
if basis_url is None:
return None
from urllib.parse import urljoin
url = urljoin(basis_url, url)
return url if url.startswith(("http://", "https://")) else None
def bild_aus_element(element, basis_url: str | None = None) -> str | None:
"""Liest die erste sinnvolle Bild-URL aus einem HTML-Element.
Akzeptiert `src` und gängige Lazy-Loading-Attribute (`data-src`,
`data-srcset`-Erster); protokoll- und pfadrelative URLs werden gegen
`basis_url` (die Seiten-URL) aufgelöst. Kein Bild → None.
"""
if element is None:
return None
bild = element.find("img") if hasattr(element, "find") else None
if bild is None:
return None
for attribut in ("src", "data-src"):
url = bild.get(attribut)
if url and url.strip():
return _bild_url_normalisieren(url, basis_url)
srcset = bild.get("data-srcset") or bild.get("srcset")
if srcset:
erster = srcset.split(",")[0].strip().split(" ")[0]
if erster:
return _bild_url_normalisieren(erster, basis_url)
return None
@dataclass
class QuellenAntwort:
"""Ergebnis eines konditionalen GETs."""
status_code: int
content: bytes = b""
etag: str | None = None
last_modified: str | None = None
@dataclass
class SammelErgebnis:
"""Ergebnis eines Adapter-Laufs über eine Quelle."""
treffer: list[QuellenTreffer] = field(default_factory=list)
seiten: int = 0
#: True = mindestens eine Seite kam per 304 als unverändert zurück;
#: der Lauf wurde dann abgekürzt (Quelle übersprungen, kein Fehler).
unveraendert: bool = False
#: KI-Feldkorrekturen dieses Laufs (Protokoll fürs Audit-Log); leer bei
#: rein regelbasiertem Lauf.
ki_eingriffe: list[dict] = field(default_factory=list)
#: Von der KI vorgeschlagene und tatsächlich verfolgte Folge-URLs
#: (Struktur-Erkennung); leer bei rein regelbasiertem Lauf.
ki_folge_urls: list[str] = field(default_factory=list)
def jahr_aus_datumsangabe(angabe: str | None) -> int | None:
"""Leitet das Erscheinungsjahr aus einer Datums-/Quartalsangabe ab."""
if not angabe:
return None
fund = JAHR_MUSTER.search(angabe)
if fund:
return int(fund.group(0))
return None
def titel_normalisieren(titel: str) -> str:
"""Vergleichsschlüssel für Titel: kleingeschrieben, ohne Satzzeichen."""
geklart = re.sub(r"[^\wäöüß ]+", " ", titel.lower(), flags=re.UNICODE)
return " ".join(geklart.split())
def titel_aehnlichkeit(a: str, b: str) -> float:
"""Fuzzy-Titelähnlichkeit (token_set_ratio, 0100) für Duplikatsprüfung."""
if not a or not b:
return 0.0
return float(fuzz.token_set_ratio(a, b))
class WebQuellenClient:
"""HTTP-Client für Web-Quellen mit Rate-Limit und konditionalen Requests.
- Mindestens `mindestabstand_sekunden` (Standard 1 s) zwischen zwei
Requests (Courtesy-Rate-Limit je Domain).
- Sendet bekannte Validatoren als If-None-Match/If-Modified-Since;
HTTP 304 wird als „unverändert“ gemeldet (kein Retry, kein Fehler).
- 5xx/429 werden mit exponentiellem Backoff erneut versucht,
sonstige Statuscodes erzeugen eine klare `QuellenFehler`.
"""
def __init__(
self,
*,
transport: httpx.BaseTransport | None = None,
mindestabstand_sekunden: float = 1.0,
max_versuche: int = 4,
backoff_basis_sekunden: float = 1.0,
backoff_maximum_sekunden: float = 8.0,
timeout_sekunden: float = 60.0,
schlaf: Callable[[float], None] = time.sleep,
uhr: Callable[[], float] = time.monotonic,
validatoren: dict[str, tuple[str | None, str | None]] | None = None,
) -> None:
self.mindestabstand_sekunden = mindestabstand_sekunden
self.max_versuche = max_versuche
self.backoff_basis_sekunden = backoff_basis_sekunden
self.backoff_maximum_sekunden = backoff_maximum_sekunden
self._schlaf = schlaf
self._uhr = uhr
self._letzter_request_um: float | None = None
#: URL → (etag, last_modified); persistierbar über die Sync-Läufe.
self.validatoren: dict[str, tuple[str | None, str | None]] = dict(
validatoren or {}
)
self._http = httpx.Client(
timeout=timeout_sekunden,
transport=transport,
follow_redirects=True,
headers={"User-Agent": USER_AGENT},
)
def schliessen(self) -> None:
self._http.close()
def _rate_limit_abwarten(self) -> None:
if self._letzter_request_um is None:
return
vergangen = self._uhr() - self._letzter_request_um
rest = self.mindestabstand_sekunden - vergangen
if rest > 0:
self._schlaf(rest)
def _konditionale_header(self, url: str) -> dict[str, str]:
header: dict[str, str] = {}
etag, last_modified = self.validatoren.get(url, (None, None))
if etag:
header["If-None-Match"] = etag
if last_modified:
header["If-Modified-Since"] = last_modified
return header
def hole(self, url: str) -> QuellenAntwort:
"""GET mit Rate-Limit, Backoff und konditionalen Headern."""
letzte_fehler: Exception | None = None
for versuch in range(self.max_versuche):
self._rate_limit_abwarten()
try:
antwort = self._http.get(url, headers=self._konditionale_header(url))
except httpx.HTTPError as exc:
letzte_fehler = exc
self._schlaf(self._backoff(versuch))
continue
self._letzter_request_um = self._uhr()
if antwort.status_code == 304:
return QuellenAntwort(304)
if antwort.status_code == 200:
etag = antwort.headers.get("ETag")
last_modified = antwort.headers.get("Last-Modified")
if etag or last_modified:
self.validatoren[url] = (etag, last_modified)
return QuellenAntwort(
200, antwort.content, etag=etag, last_modified=last_modified
)
if antwort.status_code == 429 or antwort.status_code >= 500:
retry_after = antwort.headers.get("Retry-After")
try:
wartezeit = float(retry_after) if retry_after else None
except ValueError:
wartezeit = None
self._schlaf(wartezeit if wartezeit is not None else self._backoff(versuch))
letzte_fehler = QuellenFehler(
f"HTTP {antwort.status_code} von {url} (Versuch {versuch + 1})."
)
continue
raise QuellenFehler(
f"Unerwartete HTTP-Antwort {antwort.status_code} für {url}."
)
raise QuellenFehler(
f"Quelle nach {self.max_versuche} Versuchen nicht erreichbar ({url}):"
f" {letzte_fehler}"
)
def _backoff(self, versuch: int) -> float:
return min(
self.backoff_basis_sekunden * (2**versuch),
self.backoff_maximum_sekunden,
)
class QuellenAdapter:
"""Basisklasse eines Quellen-Adapters.
Klassenattribute:
- `name`: stabiler Kurzname (= Env-Suffix, z. B. „spielbox“ für
SPIELE_NEUHEITEN_QUELLE_SPIELBOX_AKTIV) und Wert der Spalte `quelle`.
- `anzeigename`: deutscher Name für die Sync-Übersicht im UI.
- `start_urls`: Einstiegspunkte des Crawls.
- `max_seiten`: Sicherheitsgrenze gegen Endlos-Pagination.
HTML-Quellen implementieren `seite_verarbeiten`; JS-lastige Quellen
mit eigenem Daten-Endpunkt überschreiben stattdessen `sammle()`.
"""
name: ClassVar[str]
anzeigename: ClassVar[str]
start_urls: ClassVar[tuple[str, ...]] = ()
max_seiten: int = 100
def __init__(self, client: WebQuellenClient) -> None:
self.client = client
#: Optionale LLM-Hilfsstufe (`ki_hilfe.KiHilfe` oder gleiches Protokoll).
#: None (Standard) = rein regelbasiert; KI-Fehler blockieren den Lauf nie.
self.ki_hilfe: KiHilfe | None = None
def sammle(self) -> SammelErgebnis:
"""Crawlt die Quelle: Startseiten + gefundene Folge-Links (BFS).
Mit gesetzter `ki_hilfe`: Bei verdächtig leer geparsten Seiten darf
das LLM Pagination-/Filter-Folge-URLs vorschlagen (nur gleiche
Domain, max. 20), unsichere Treffer werden per LLM nachgebessert
(nur titel/verlag/autor, nie die URL). Jeder KI-Fehler fällt auf den
klassischen Befund zurück.
"""
ergebnis = SammelErgebnis()
warteschlange = list(self.start_urls)
besucht: set[str] = set()
ki_vorschlaege: set[str] = set()
while warteschlange and len(besucht) < self.max_seiten:
url = warteschlange.pop(0)
if url in besucht:
continue
besucht.add(url)
try:
antwort = self.client.hole(url)
except Exception:
if url in ki_vorschlaege:
# Eine nicht erreichbare KI-Suggestion darf den klassischen
# Lauf nie abbrechen — überspringen statt Fehler.
_logger.warning(
"KI-vorgeschlagene URL nicht erreichbar (%s) — übersprungen.",
url,
)
continue
raise
if antwort.status_code == 304:
# Unverändert → Quelle überspringen (kein Fehler).
ergebnis.unveraendert = True
break
treffer, folge_links = self.seite_verarbeiten(antwort.content, url)
ergebnis.treffer.extend(treffer)
ergebnis.seiten += 1
for link in folge_links:
if link not in besucht:
warteschlange.append(link)
if self.ki_hilfe is not None:
for link in self._ki_folge_urls(treffer, antwort.content, url, besucht):
if link not in besucht and link not in warteschlange:
warteschlange.append(link)
ki_vorschlaege.add(link)
ergebnis.ki_folge_urls.append(link)
if self.ki_hilfe is not None:
self._ki_nachbessern(ergebnis)
return ergebnis
def _ki_folge_urls(
self,
treffer: list[QuellenTreffer],
inhalt: bytes,
url: str,
besucht: set[str],
) -> list[str]:
"""Struktur-Erkennung der Hilfsstufe (best effort — wirft nicht)."""
try:
return self.ki_hilfe.folge_urls_bei_bedarf(
len(treffer), inhalt, url, besucht
)
except Exception:
_logger.exception(
"KI-Struktur-Erkennung fehlgeschlagen (%s) — klassischer Lauf bleibt.",
url,
)
return []
def _ki_nachbessern(self, ergebnis: SammelErgebnis) -> None:
"""Feld-Korrektur unsicherer Treffer (best effort — wirft nicht)."""
try:
korrigiert, eingriffe = self.ki_hilfe.treffer_nachbessern(ergebnis.treffer)
except Exception:
_logger.exception(
"KI-Feldkorrektur fehlgeschlagen — klassischer Parserbefund bleibt."
)
return
if korrigiert is not None:
ergebnis.treffer = korrigiert
ergebnis.ki_eingriffe = eingriffe or []
def seite_verarbeiten(
self, inhalt: bytes, url: str
) -> tuple[list[QuellenTreffer], list[str]]:
"""Parst eine Seite: liefert Treffer und Folge-Links (Pagination)."""
raise NotImplementedError(
f"{type(self).__name__} muss seite_verarbeiten oder sammle überschreiben."
)

View File

@@ -0,0 +1,66 @@
"""Adapter für die Brettspielbox-Messevorschau (A-Z-Liste).
Seite: …/brettspiel-neuheiten-spiel-2026-herbst-2026-a-z/ (WordPress).
Struktur (Stand Analyse August 2026): Die komplette A-Z-Liste steht auf
einer Seite in `div.wp-block-columns`-Blöcken — pro Buchstabe ein
Absatz (`p`) mit dem Buchstaben bzw. „0-9“ und eine `ul` mit einem
`li` pro Spiel im Format „Titel (Verlag)“; optionale Links im Eintrag
zeigen auf Vorstellungs-/Rezensionsartikel und dienen als quellen_url,
sonst gilt die Listenseite selbst.
"""
from __future__ import annotations
import re
from bs4 import BeautifulSoup
from .basis import QuellenAdapter, QuellenTreffer, bild_aus_element
TITEL_VERLAG_MUSTER = re.compile(r"^\s*(.+?)\s*\(([^()]*)\)\s*$")
class BrettspielboxQuelle(QuellenAdapter):
name = "brettspielbox"
anzeigename = "Brettspielbox Messevorschau (AZ)"
start_urls = (
"https://brettspielbox.de/brettspiel-neuheiten-spiel-2026-herbst-2026-a-z/",
)
def seite_verarbeiten(self, inhalt: bytes, url: str):
suppe = BeautifulSoup(inhalt, "html.parser")
treffer: list[QuellenTreffer] = []
for spalte in suppe.select(".wp-block-columns"):
for liste in spalte.find_all("ul"):
for eintrag in liste.find_all("li"):
text = eintrag.get_text(" ", strip=True)
titel, verlag = _zerlege_eintrag(text)
if not titel:
continue
link = eintrag.find("a", href=True)
from urllib.parse import urljoin
quellen_url = (
urljoin(url, link["href"]) if link is not None else url
)
treffer.append(
QuellenTreffer(
titel=titel,
verlag=verlag,
autor=None,
erscheinungsdatum_oder_quartal=None,
quellen_url=quellen_url,
bild_url=bild_aus_element(eintrag, url),
)
)
return treffer, []
def _zerlege_eintrag(text: str) -> tuple[str | None, str | None]:
"""„Titel (Verlag)“ → (Titel, Verlag); ohne Klammer nur Titel."""
fund = TITEL_VERLAG_MUSTER.match(text)
if fund is None:
return (text.strip() or None), None
verlag = fund.group(2).strip() or None
titel = fund.group(1).strip()
return (titel or None), verlag

View File

@@ -0,0 +1,186 @@
"""Adapter für cliquenabend.de Messe-Eventseiten (z. B. SPIEL 2025 Essen).
Seite: …/events/130000-SPIEL-2025-Essen.html (große Einzelseite, ~4 MB).
Struktur (Stand Analyse August 2026): Eine einzige Tabelle mit der
Kopfzeile Update | Verlag | Stand | Spiel | Autor; eine Zeile pro Spiel.
Titel steht in `a[href^="/spiele/"] > b`, Verlage in fett formatierten
`div`-Blöcken der Verlagsspalte, das Update-Datum in der Form TT.MM.JJ.
Die Seite ist nicht paginiert.
"""
from __future__ import annotations
import logging
import re
from urllib.parse import urljoin
from bs4 import BeautifulSoup
from bs4.element import Tag
from .basis import QuellenAdapter, QuellenTreffer, bild_aus_element
_logger = logging.getLogger(__name__)
DATUM_MUSTER = re.compile(r"\d{2}\.\d{2}\.(\d{2})\b")
BASIS_URL = "https://www.cliquenabend.de"
class CliquenabendQuelle(QuellenAdapter):
name = "cliquenabend"
anzeigename = "Cliquenabend Messe-Übersicht"
start_urls = ("https://www.cliquenabend.de/events/130000-SPIEL-2025-Essen.html",)
def seite_verarbeiten(self, inhalt: bytes, url: str):
suppe = BeautifulSoup(inhalt, "html.parser")
treffer: list[QuellenTreffer] = []
tabelle = suppe.find("table")
if tabelle is None:
return [], [] # Strukturänderung: nichts tun, kein Crash
for zeile in tabelle.find_all("tr"):
zellen = zeile.find_all("td")
if len(zellen) < 5:
continue
if zellen[0].get_text(strip=True).lower() == "update":
continue # Kopfzeile
treffer.append(self._zeile(zellen, url))
return [t for t in treffer if t is not None], []
def _detailseite(self, spiel_url: str):
"""Lädt eine Spieldetailseite und gibt das BeautifulSoup-Dokument zurück.
Fehler (404, Strukturänderung) führen zu None und blockieren nie.
"""
try:
antwort = self.client.hole(spiel_url)
except Exception:
_logger.warning("Cliquenabend: Detailseite nicht erreichbar (%s)", spiel_url)
return None
if antwort.status_code != 200:
return None
return BeautifulSoup(antwort.content, "html.parser")
def _release_jahr(self, spiel_url: str) -> str | None:
"""Liest das Release-Feld von der Spieldetailseite.
Die Detailseite (…/spiele/….html) enthält ein Feld
`<b>Release:</b><br> JAHR <br>` — meist nur das Jahr (z. B.
„2025“), gelegentlich ein genaues Datum. Liefert die rohe Angabe
zurück; `jahr_aus_datumsangabe` leitet daraus später das Jahr ab.
"""
suppe = self._detailseite(spiel_url)
if suppe is None:
return None
return self._release_jahr_aus_suppe(suppe)
def _release_jahr_aus_suppe(self, suppe) -> str | None:
fett = suppe.find(
"b", string=lambda s: isinstance(s, str) and s.strip() == "Release:"
)
if not isinstance(fett, Tag):
return None
# Wert steht direkt hinter dem Label (<br>-getrennt); der nächste
# String-Knoten nach dem Label-Element (nicht darin) ist der Wert.
text = fett.next_sibling
while isinstance(text, Tag):
text = text.next_sibling
while isinstance(text, str) and not text.strip():
text = text.next_sibling
while isinstance(text, Tag):
text = text.next_sibling
if not isinstance(text, str):
return None
wert = text.strip()
return wert or None
def _cover_bild(self, spiel_url: str) -> str | None:
"""Liest das Cover-Bild von der Spieldetailseite."""
suppe = self._detailseite(spiel_url)
if suppe is None:
return None
return self._cover_bild_aus_suppe(suppe)
def _cover_bild_aus_suppe(self, suppe) -> str | None:
"""Die Detailseite zeigt das Cover in der rechten Leiste als
`<a href="[diese Spielseite]"><img src="/images/db/ID_360x240.jpg">`.
Liefert die absolute Bild-URL oder None.
"""
for bild in suppe.find_all("img", src=True):
src = str(bild["src"])
if "/images/db/" not in src:
continue # Template-Grafiken (Logo, Pfeile etc.) auslassen
eltern_link = bild.find_parent("a", href=re.compile(r"^/spiele/"))
if eltern_link is not None:
return urljoin(BASIS_URL, src)
return None
def _zeile(self, zellen, url: str) -> QuellenTreffer | None:
spiel_zelle = zellen[3]
titel_link = spiel_zelle.find("a", href=re.compile(r"^/spiele/"))
titel = None
quellen_url = url
if titel_link is not None:
fett = titel_link.find("b")
titel = (fett or titel_link).get_text(" ", strip=True)
href = titel_link.get("href")
if href:
quellen_url = urljoin(BASIS_URL, href)
if not titel:
return None
verlag = _verlage(zellen[1])
autor = zellen[4].get_text(" ", strip=True) or None
datum = None
bild_url = bild_aus_element(spiel_zelle, quellen_url)
jahr_text = zellen[0].get_text(strip=True)
fund = DATUM_MUSTER.search(jahr_text)
if fund:
# Datum so übernehmen, wie es in der Quelle steht (z. B. „15.10.25“).
datum = fund.group(0)
# Release-Feld und Cover-Bild der Detailseite haben Vorrang: dort
# steht das echte Erscheinungsjahr/-datum bzw. das Cover statt des
# Update-Datums bzw. des Messe-Fotos aus der Tabelle.
if titel_link is not None:
href = titel_link.get("href")
if href:
detail_url = urljoin(BASIS_URL, href)
suppe_detail = self._detailseite(detail_url)
if suppe_detail is not None:
release = self._release_jahr_aus_suppe(suppe_detail)
if release:
datum = release
cover = self._cover_bild_aus_suppe(suppe_detail)
if cover:
bild_url = cover
return QuellenTreffer(
titel=titel,
verlag=verlag,
autor=autor,
erscheinungsdatum_oder_quartal=datum,
quellen_url=quellen_url,
bild_url=bild_url,
)
def _verlage(zelle) -> str | None:
"""Verlagsspalte: fett formatierte `div`-Blöcke enthalten die Namen."""
namen = [
div.get_text(" ", strip=True)
for div in zelle.find_all("div")
if div.get_text(" ", strip=True)
]
if not namen:
roh = zelle.get_text(" ", strip=True)
namen = [teil for teil in (roh.split(",") if roh else [])
if teil.strip() and teil.strip().lower() != "profil"]
bereinigt = []
for name in namen:
name = name.strip().rstrip(",").strip()
# „Piatnik Profil ,“ → Profil-Link-Text hängt am Namen; abschneiden.
if name.lower().endswith(" profil"):
name = name[: -len(" Profil")].strip()
if name and name.lower() != "profil":
bereinigt.append(name)
return ", ".join(bereinigt) if bereinigt else None

View File

@@ -0,0 +1,575 @@
"""Optionale LLM-Hilfsstufe für den Web-Quellen-Crawler des Neuheiten-Plugins.
Der Crawler (`basis.py` + Adapter) bleibt maßgeblich und arbeitet rein
regelbasiert. Das LLM wird ausschließlich in zwei Situationen eingeschaltet:
1. **Struktur-Erkennung:** Liefert der Regel-Parser keine oder verdächtig
wenige Einträge (0 Treffer, oder wenige Treffer auf einer sehr link-
reichen Seite), darf das LLM Pagination-/Filter-Folge-URLs aus dem
gelieferten HTML vorschlagen. Der Vorschlag wird im Code hart gefiltert:
nur URLs derselben Domain wie die geparste Seite, maximal 20 je Seite —
Schutz vor Rate-Limit-Überlastung und Domain-Ausbruch.
2. **Feld-Extraktion:** Ein einzelner Treffer ist *unsicher* (fehlender
Verlag oder verdächtiger Titel). Dann darf das LLM höchstens `titel`,
`verlag` und `autor` korrigieren — niemals die URL.
Konfiguration konsistent zur dedup-KI über Umgebungsvariablen:
| Variable | Bedeutung |
|----------|-----------|
| `SPIELE_NEUHEITEN_KI_AKTIV` | `1` = Hilfsstufe an (Standard `0`) |
| `SPIELE_NEUHEITEN_KI_BASIS_URL` | Basis-URL, z. B. `https://api.openai.com/v1` |
| `SPIELE_NEUHEITEN_KI_API_KEY` | API-Key (wird als Bearer gesendet) |
| `SPIELE_NEUHEITEN_KI_MODELL` | Modellname, z. B. `gpt-4o-mini` |
| `SPIELE_NEUHEITEN_KI_KONFIDENZ_MIN` | Mindest-Konfidenz (Standard `0.7`) |
| `SPIELE_NEUHEITEN_KI_TIMEOUT_SEKUNDEN` | Timeout je Aufruf (Standard `20`) |
**Fallback-Pflicht:** Ohne Konfiguration oder bei jedem Fehler läuft EXAKT
der klassische Crawler weiter — ein KI-Fehler blockiert den Sync nie.
Ergebnisse unterhalb der Mindest-Konfidenz werden verworfen; der klassische
Parser-Befund bleibt dann unverändert bestehen.
Schnittstelle: OpenAI-kompatible Chat-Completions-API via httpx;
Netzwerk-/Server-Fehler werden genau einmal wiederholt (insgesamt zwei
Versuche), Antwort-Parsing-Fehler nicht.
"""
from __future__ import annotations
import dataclasses
import json
import logging
import os
import re
from dataclasses import dataclass
from typing import Any, Mapping
from urllib.parse import urljoin, urlsplit
import httpx
_logger = logging.getLogger("plugins.neuheiten")
#: Standard-Timeout je Chat-Completions-Aufruf in Sekunden.
STANDARD_TIMEOUT = 20.0
#: Gesamtzahl der Versuche (erster Versuch + genau ein Retry).
VERSUCHE = 2
#: HTTP-Statuscodes, die einen Retry rechtfertigen (zeitweilige Störungen).
RETRY_STATUS = frozenset({408, 429, 500, 502, 503, 504})
#: Mindest-Konfidenz, ab der ein KI-Befund übernommen wird.
STANDARD_KONFIDENZ_MIN = 0.7
#: Maximalzahl der KI-vorgeschlagenen Folge-URLs je Seite (Rate-Limit-Schutz).
MAX_FOLGE_URLS = 20
#: Obergrenze des HTML-Ausschnitts, der pro Struktur-Anfrage ans LLM geht.
HTML_MAX_ZEICHEN = 12000
# ---------------- Verdachts-Heuristiken (wann hilft das LLM?) ----------------
#: Weniger Treffer als das gilt als „verdächtig wenig“ …
STRUKTUR_MIN_TREFFER = 3
#: … aber nur, wenn die Seite zugleich sehr linkreich ist (Indiz für eine
#: nicht erkannte Liste/Pagination statt einer wirklich leeren Seite).
STRUKTUR_LINK_SCHWELLE = 30
_LINK_MUSTER = re.compile(r"<a\s", re.IGNORECASE)
#: Generische Navigations-/Platzhaltertexte, die als Spieltitel verdächtig sind.
GENERISCHE_TITEL = frozenset(
{
"hier", "mehr", "mehr infos", "details", "weiterlesen", "weiter",
"klick", "klick hier", "link", "produkt", "neuheit", "unbekannt",
"titel", "n/a", "-", "",
}
)
def _als_text(inhalt: bytes | str) -> str:
if isinstance(inhalt, bytes):
return inhalt.decode("utf-8", errors="replace")
return inhalt
def titel_ist_verdaechtig(titel: str | None) -> bool:
"""True bei fehlendem, zu kurzem oder generischem Navigationstext."""
if not titel or not titel.strip():
return True
gekuerzt = titel.strip()
if len(gekuerzt) < 3:
return True
return gekuerzt.rstrip(".!? ").lower() in GENERISCHE_TITEL
def treffer_ist_unsicher(treffer: Any) -> bool:
"""Ein Treffer ist unsicher, wenn der Verlag fehlt oder der Titel verdächtig ist.
Nur solche Treffer gehen in die KI-Feld-Extraktion; sichere Treffer
kosten kein Token und bleiben garantiert unangetastet.
"""
verlag = getattr(treffer, "verlag", None)
if not verlag or not str(verlag).strip():
return True
return titel_ist_verdaechtig(getattr(treffer, "titel", None))
def struktur_verdaechtig(anzahl_treffer: int, inhalt: bytes | str) -> bool:
"""True, wenn der Parser keine oder verdächtig wenige Einträge lieferte.
0 Treffer sind immer verdächtig. 12 Treffer nur dann, wenn die Seite
sehr viele Links enthält — sonst ist sie schlicht klein/leer.
"""
if anzahl_treffer <= 0:
return True
if anzahl_treffer >= STRUKTUR_MIN_TREFFER:
return False
links = len(_LINK_MUSTER.findall(_als_text(inhalt)))
return links > STRUKTUR_LINK_SCHWELLE
# ---------------- Konfiguration ----------------
@dataclass(frozen=True)
class KiKonfiguration:
"""Effektive Konfiguration der KI-Hilfsstufe (aus Env gelesen)."""
aktiv: bool = False
basis_url: str = ""
api_key: str = ""
modell: str = ""
konfidenz_min: float = STANDARD_KONFIDENZ_MIN
timeout: float = STANDARD_TIMEOUT
@property
def vollstaendig(self) -> bool:
"""True, wenn Aktiv-Schalter und Zugangsdaten vollständig sind."""
return bool(self.aktiv and self.basis_url and self.api_key and self.modell)
def lade_konfiguration(quelle: Mapping[str, str] | None = None) -> KiKonfiguration:
"""Liest die KI-Konfiguration aus der Umgebung (fehlertolerant).
Kaputte Zahlenwerte führen nicht zum Fehler, sondern zum Standardwert —
die Hilfsstufe darf den Crawler niemals blockieren.
"""
env = os.environ if quelle is None else quelle
aktiv = env.get("SPIELE_NEUHEITEN_KI_AKTIV", "0").strip() == "1"
try:
konfidenz_min = float(env.get("SPIELE_NEUHEITEN_KI_KONFIDENZ_MIN", ""))
except ValueError:
konfidenz_min = STANDARD_KONFIDENZ_MIN
konfidenz_min = min(1.0, max(0.0, konfidenz_min))
try:
timeout = float(env.get("SPIELE_NEUHEITEN_KI_TIMEOUT_SEKUNDEN", ""))
except ValueError:
timeout = STANDARD_TIMEOUT
timeout = max(1.0, timeout)
return KiKonfiguration(
aktiv=aktiv,
basis_url=env.get("SPIELE_NEUHEITEN_KI_BASIS_URL", "").strip().rstrip("/"),
api_key=env.get("SPIELE_NEUHEITEN_KI_API_KEY", "").strip(),
modell=env.get("SPIELE_NEUHEITEN_KI_MODELL", "").strip(),
konfidenz_min=konfidenz_min,
timeout=timeout,
)
# ---------------- Antwort-Parsing (robust gegen Code-Fences u. Ä.) ----------------
_FENCE_MUSTER = re.compile(r"```(?:json|JSON)?\s*(.*?)\s*```", re.DOTALL)
def extrahiere_json(text: str) -> dict | None:
"""Extrahiert das erste JSON-Objekt aus einer LLM-Antwort.
Toleriert Code-Fences und begleitenden Text; None, wenn nichts
Sinnvolles übrig bleibt.
"""
if not isinstance(text, str):
return None
text = text.strip()
if not text:
return None
versuche = [text]
versuche.extend(m.group(1).strip() for m in _FENCE_MUSTER.finditer(text))
erstes, letztes = text.find("{"), text.rfind("}")
if 0 <= erstes < letztes:
versuche.append(text[erstes : letztes + 1])
for kandidat in versuche:
try:
daten = json.loads(kandidat)
except (ValueError, TypeError):
continue
if isinstance(daten, dict):
return daten
return None
def _normalisiere_konfidenz(daten: Mapping[str, Any]) -> float | None:
"""Konfidenz aus der Antwort; Prozent-Skalen werden toleriert."""
try:
konfidenz = float(daten.get("konfidenz"))
except (TypeError, ValueError):
return None
if 10.0 <= konfidenz <= 100.0: # z. B. 92 → 0.92
konfidenz = konfidenz / 100.0
if not 0.0 <= konfidenz <= 1.0: # 1 < x < 10 ist auf keiner Skala plausibel
return None
return round(konfidenz, 4)
def _optional_text(wert: Any) -> str | None:
"""Normalisiert ein nullable String-Feld der Antwort."""
if isinstance(wert, str):
wert = wert.strip()
return wert or None
return None
def validiere_url_vorschlaege(daten: Any, seiten_url: str) -> dict | None:
"""Prüft die Struktur-Antwort und filtert hart auf gleiche Domain + max 20.
Erwartet `{folge_urls: [...], konfidenz: float}`. Relative URLs werden
gegen die Seiten-URL aufgelöst; fremde Domains, Nicht-HTTP-Schemata und
Duplikate (inkl. Fragment-Unterschiede) werden verworfen — auch wenn das
LLM sie vorschlägt.
"""
if not isinstance(daten, dict):
return None
roh = daten.get("folge_urls")
if not isinstance(roh, list):
return None
konfidenz = _normalisiere_konfidenz(daten)
if konfidenz is None:
return None
basis_domain = urlsplit(seiten_url).netloc.lower()
if not basis_domain:
return None
gefiltert: list[str] = []
gesehen: set[str] = set()
for eintrag in roh:
if not isinstance(eintrag, str) or not eintrag.strip():
continue
absolut = urljoin(seiten_url, eintrag.strip())
teile = urlsplit(absolut)
if teile.scheme not in ("http", "https"):
continue
if teile.netloc.lower() != basis_domain:
continue
ohne_fragment = absolut.split("#", 1)[0]
if ohne_fragment in gesehen:
continue
gesehen.add(ohne_fragment)
gefiltert.append(ohne_fragment)
if len(gefiltert) >= MAX_FOLGE_URLS:
break
return {"folge_urls": gefiltert, "konfidenz": konfidenz}
def validiere_feld_antwort(daten: Any) -> dict | None:
"""Prüft die Feld-Antwort gegen das Schema (titel/verlag/autor/konfidenz).
Die URL ist bewusst kein Teil des Schemas — eine Korrektur kann sie
strukturell nie ändern.
"""
if not isinstance(daten, dict):
return None
titel = daten.get("titel")
if not isinstance(titel, str) or not titel.strip():
return None
konfidenz = _normalisiere_konfidenz(daten)
if konfidenz is None:
return None
begruendung = daten.get("begruendung")
if not isinstance(begruendung, str):
begruendung = ""
return {
"titel": titel.strip(),
"verlag": _optional_text(daten.get("verlag")),
"autor": _optional_text(daten.get("autor")),
"konfidenz": konfidenz,
"begruendung": begruendung.strip(),
}
# ---------------- Prompt ----------------
_SYSTEM_STRUKTUR = (
"Du hilfst einem Crawler einer Spielemagazin-Redaktion, Brettspiel-"
"Neuheitenlisten im Web zu finden. Der Regel-Parser hat die folgende "
"Seite fast leer geparst — vermutlich nutzt die Liste Pagination, "
"Archiv- oder Filter-Links. Identifiziere aus dem HTML die URLs, unter "
"denen weitere Neuheiten-Einträge zu erwarten sind. Antworte "
"AUSSCHLIESSLICH mit einem JSON-Objekt nach exakt diesem Schema:\n"
'{"folge_urls": ["<absolute oder relative URL>", …], '
'"konfidenz": <float 0.0-1.0>}\n'
f"Höchstens {MAX_FOLGE_URLS} URLs und nur Links derselben Domain wie die "
"Seiten-URL. Wenn du nichts Sinnvolles findest, liefere eine leere Liste "
"mit niedriger Konfidenz. Kein weiterer Text, keine Code-Fences."
)
_SYSTEM_FELDER = (
"Du assistierst einer Spielemagazin-Redaktion beim Parsen von Brettspiel-"
"Neuheiten. Der folgende Treffer eines regelbasierten Crawlers ist "
"unsicher. Bereinige die Felder: trenne „Verlag: Titel“-Muster, entferne "
"Navigationstext und ergänze den Verlag, falls er sicher erkennbar ist. "
"Korrigiere ausschließlich titel, verlag und autor — ändere niemals URLs "
"und erfinde nichts. Ein Feld, das du nicht sicher bestimmen kannst, "
"bleibt null. Antworte AUSSCHLIESSLICH mit einem JSON-Objekt nach exakt "
"diesem Schema:\n"
'{"titel": "<bereinigter Titel>", "verlag": "<Verlagsname oder null>", '
'"autor": "<Autor oder null>", "konfidenz": <float 0.0-1.0>, '
'"begruendung": "<kurzer deutscher Satz>"}\n'
"Kein weiterer Text, keine Code-Fences."
)
def baue_struktur_prompt(seiten_url: str, html_text: str) -> str:
"""User-Nachricht für die Struktur-Erkennung (gekürzter HTML-Ausschnitt)."""
ausschnitt = html_text[:HTML_MAX_ZEICHEN]
return (
f"Seiten-URL: {seiten_url}\n\n"
"HTML-Ausschnitt der geparsten Seite:\n"
f"{ausschnitt}"
)
def baue_feld_prompt(titel: str, verlag: str | None, autor: str | None) -> str:
"""User-Nachricht mit dem unsicheren Treffer (ohne URL — bleibt fix)."""
zeilen = [f"Unsicherer Treffer:", f"titel: {titel}"]
zeilen.append(f"verlag: {verlag if verlag else '(leer)'}")
zeilen.append(f"autor: {autor if autor else '(leer)'}")
zeilen.append(
"Gib die bereinigten Felder zurück; die quellen_url selbst bleibt "
"nicht veränderbar und ist nicht Teil der Aufgabe."
)
return "\n".join(zeilen)
# ---------------- Client ----------------
class KiHilfe:
"""OpenAI-kompatibler Chat-Completions-Client für die Crawler-Hilfsstufe.
Bewusst genauso defensiv wie der dedup-KI-Client: Alle öffentlichen
Methoden werfen nicht (Ausnahmen werden intern geschluckt) — ohne
Konfiguration oder bei jedem Problem greift exakt der klassische
Crawler-Befund. Transport injizierbar, damit Tests netzwerkfrei bleiben.
"""
def __init__(
self,
konfiguration: KiKonfiguration,
*,
transport: httpx.BaseTransport | None = None,
) -> None:
self._konfiguration = konfiguration
self._client = httpx.Client(
base_url=konfiguration.basis_url,
timeout=konfiguration.timeout,
transport=transport,
headers={
"Authorization": f"Bearer {konfiguration.api_key}",
"Content-Type": "application/json",
},
)
@property
def modell(self) -> str:
return self._konfiguration.modell
def schliessen(self) -> None:
self._client.close()
# ---------- Struktur-Erkennung ----------
def folge_urls_bei_bedarf(
self,
anzahl_treffer: int,
inhalt: bytes | str,
seiten_url: str,
bereits_geprueft: set[str] | None = None,
) -> list[str]:
"""Pagination-/Filter-URLs bei verdächtig leerem Parser-Ergebnis.
Prüft selbst die Verdachts-Heuristik (sonst []), fragt dann das LLM
und filtert dessen Vorschläge hart: gleiche Domain, max. 20, bereits
geplante/besuchte URLs raus, Konfidenz unter der Schwelle → alles
verwerfen. Fehler → [] (klassischer Lauf bleibt).
"""
try:
if not struktur_verdaechtig(anzahl_treffer, inhalt):
return []
if not self._konfiguration.vollstaendig:
return []
nutzernachricht = baue_struktur_prompt(seiten_url, _als_text(inhalt))
daten = self._chat(_SYSTEM_STRUKTUR, nutzernachricht)
ergebnis = validiere_url_vorschlaege(daten, seiten_url)
if ergebnis is None:
return []
if ergebnis["konfidenz"] < self._konfiguration.konfidenz_min:
_logger.info(
"neuheiten/KI: URL-Vorschläge verworfen (Konfidenz %.2f < %.2f).",
ergebnis["konfidenz"], self._konfiguration.konfidenz_min,
)
return []
bekannt = bereits_geprueft or set()
return [
url for url in ergebnis["folge_urls"] if url not in bekannt
]
except Exception:
_logger.exception(
"neuheiten/KI: Struktur-Erkennung fehlgeschlagen — klassischer Lauf bleibt."
)
return []
# ---------- Feld-Extraktion ----------
def treffer_nachbessern(
self, treffer_liste: list
) -> tuple[list, list[dict]]:
"""Korrigiert ausschließlich unsichere Treffer (titel/verlag/autor).
Sichere Treffer werden nicht angefasst und kosten kein Token. Die
quellen_url bleibt immer unangetastet (kein Teil des Antwort-Schemas).
Rückgabe: (neue Trefferliste, Eingriffs-Protokoll fürs Audit-Log mit
Modell, Konfidenz und den jeweils korrigierten Feldern).
"""
if not treffer_liste:
return [], []
if not self._konfiguration.vollstaendig:
return list(treffer_liste), []
neue_liste: list = []
eingriffe: list[dict] = []
for einzel in treffer_liste:
if not treffer_ist_unsicher(einzel):
neue_liste.append(einzel)
continue
ergebnis = self._einzeln_korrigieren(einzel)
if ergebnis is None:
neue_liste.append(einzel) # klassischer Befund bleibt
continue
korrigiert, antwort = ergebnis
if korrigiert is einzel: # nichts tatsächlich geändert
neue_liste.append(einzel)
continue
neue_liste.append(korrigiert)
eingriffe.append(self._eingriff_protokoll(einzel, korrigiert, antwort))
return neue_liste, eingriffe
def _einzeln_korrigieren(self, treffer: Any) -> tuple[Any, dict] | None:
"""Eine Feld-Korrektur; Rückgabe (neuer Treffer, validierte Antwort).
None bzw. der unveränderte Treffer bedeutet: klassischer Befund bleibt.
"""
try:
nutzernachricht = baue_feld_prompt(
getattr(treffer, "titel", "") or "",
getattr(treffer, "verlag", None),
getattr(treffer, "autor", None),
)
daten = self._chat(_SYSTEM_FELDER, nutzernachricht)
antwort = validiere_feld_antwort(daten)
if antwort is None:
return None
if antwort["konfidenz"] < self._konfiguration.konfidenz_min:
_logger.info(
"neuheiten/KI: Feldkorrektur verworfen (Konfidenz %.2f < %.2f).",
antwort["konfidenz"], self._konfiguration.konfidenz_min,
)
return None
felder: dict[str, str | None] = {}
for feld in ("titel", "verlag", "autor"):
alter_wert = getattr(treffer, feld, None)
alter_norm = (alter_wert or "").strip() or None
neu_norm = (antwort[feld] or "").strip() or None
if neu_norm != alter_norm:
felder[feld] = neu_norm
if not felder:
return treffer, antwort # nichts tatsächlich korrigiert
try:
return dataclasses.replace(treffer, **felder), antwort
except Exception:
return None
except Exception:
_logger.exception(
"neuheiten/KI: Feldkorrektur fehlgeschlagen — klassischer Befund bleibt."
)
return None
def _eingriff_protokoll(self, alt: Any, neu: Any, antwort: dict) -> dict:
"""Audit-Zeile: was wurde korrigiert, mit welchem Modell/welcher Konfidenz."""
korrigiert: dict[str, str | None] = {}
for feld in ("titel", "verlag", "autor"):
vorher = getattr(alt, feld, None)
nachher = getattr(neu, feld, None)
if vorher != nachher:
korrigiert[feld] = nachher
return {
"quellen_url": getattr(alt, "quellen_url", ""),
"modell": self.modell,
"konfidenz": antwort["konfidenz"],
"begruendung": antwort.get("begruendung", ""),
"korrigiert": korrigiert,
"vorher": {f: getattr(alt, f, None) for f in korrigiert},
}
# ---------- Gemeinsamer Chat-Completions-Aufruf ----------
def _chat(self, system: str, nutzer: str) -> dict | None:
"""POST /chat/completions mit genau einem Retry; None bei jedem Problem.
Netzwerk-/Timeout-Fehler und zeitweilige Server-Störungen werden
genau einmal wiederholt (insgesamt zwei Versuche); andere 4xx und
kaputte Antworten nicht — dort hilft kein Retry.
"""
payload = {
"model": self._konfiguration.modell,
"temperature": 0,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": nutzer},
],
}
for versuch in range(1, VERSUCHE + 1):
try:
antwort = self._client.post("/chat/completions", json=payload)
except httpx.RequestError as exc:
_logger.warning(
"neuheiten/KI: Versuch %d/%d fehlgeschlagen (%s)",
versuch, VERSUCHE, exc,
)
continue
if antwort.status_code in RETRY_STATUS:
_logger.warning(
"neuheiten/KI: Versuch %d/%d mit Status %d — wiederholt.",
versuch, VERSUCHE, antwort.status_code,
)
continue
try:
antwort.raise_for_status() # andere 4xx: kein Retry hilft
except httpx.HTTPStatusError as exc:
_logger.warning("neuheiten/KI: Anfrage abgelehnt (%s).", exc)
return None
try:
inhalte = antwort.json()["choices"][0]["message"]["content"]
except Exception as exc: # kaputtes Antwort-Layout
_logger.warning("neuheiten/KI: unbrauchbare Antwort (%s)", exc)
return None
return extrahiere_json(inhalte)
_logger.warning(
"neuheiten/KI: alle %d Versuche fehlgeschlagen — klassischer Befund.", VERSUCHE
)
return None

View File

@@ -0,0 +1,89 @@
"""Adapter für https://www.spielbox.de/neuheiten-neue-spiele (Joomla-Blog).
Seitenstruktur (Stand Analyse August 2026):
- Pro Beitrag ein `div.item` mit Titel-Link in `div.page-header h2 a`,
Beitragsdatum (TT.MM.JJJJ) im ersten Absatz (`p > strong`).
- Pagination über `.pagination`-Links der Form `?start=N` (6 Beiträge je
Seite) — alle Seiten werden durchlaufen, bis keine neue Seite mehr
gefunden wird.
- Verlag: Heuristik — Präfix des Titels vor dem Doppelpunkt
(z. B. „Pegasus Spiele: Next Station Berlin“); fehlt er, bleibt der
Verlag leer. Ein Autor wird auf dieser Liste nicht strukturiert
mitgeliefert.
"""
from __future__ import annotations
import re
from bs4 import BeautifulSoup
from .basis import QuellenAdapter, QuellenTreffer, bild_aus_element
DATUM_MUSTER = re.compile(r"(\d{2}\.\d{2}\.\d{4})")
class SpielboxQuelle(QuellenAdapter):
name = "spielbox"
anzeigename = "spielbox.de Neuheiten"
start_urls = ("https://www.spielbox.de/neuheiten-neue-spiele",)
def seite_verarbeiten(self, inhalt: bytes, url: str):
suppe = BeautifulSoup(inhalt, "html.parser")
treffer: list[QuellenTreffer] = []
for element in suppe.select("div.item"):
kopf_link = element.select_one("div.page-header h2 a") or element.find("h2")
if kopf_link is None:
continue # Strukturänderung: Beitrag überspringen, Lauf hält an
titel = kopf_link.get_text(" ", strip=True)
if not titel:
continue
href = kopf_link.get("href") if hasattr(kopf_link, "get") else None
quellen_url = _absolute_url(href, url) or url
datum = None
for absatz in element.find_all("p"):
strong = absatz.find("strong")
if strong is not None:
fund = DATUM_MUSTER.search(strong.get_text(strip=True))
if fund:
datum = fund.group(1)
break
verlag = _verlag_aus_titel(titel)
treffer.append(
QuellenTreffer(
titel=titel,
verlag=verlag,
autor=None,
erscheinungsdatum_oder_quartal=datum,
quellen_url=quellen_url,
bild_url=bild_aus_element(element, url),
)
)
folge_links: list[str] = []
for link in suppe.select(".pagination a[href]"):
ziel = _absolute_url(link["href"], url)
# Nur echte Paginierung dieser Liste folgen (?start=N),
# nicht den Druck-/Sprung-Links.
if ziel and "start=" in ziel:
folge_links.append(ziel)
return treffer, folge_links
def _verlag_aus_titel(titel: str) -> str | None:
"""„Verlag: Spielname“ → Verlag als Präfix vor dem Doppelpunkt."""
if ":" not in titel:
return None
praefix = titel.split(":", 1)[0].strip()
if 0 < len(praefix) <= 60:
return praefix
return None
def _absolute_url(href: str | None, basis_url: str) -> str | None:
if not href:
return None
from urllib.parse import urljoin
return urljoin(basis_url, href)

View File

@@ -0,0 +1,187 @@
"""Adapter für die Neuheitenliste der SPIEL Essen (spiel-essen.de).
Die Seite https://spiel-essen.de/de/die-spiel/neuheiten rendert ihre
Daten clientseitig mit einer Vue-App („eye-concat-product-list“) — HTML-
Parsing scheidet aus. Die App lädt ihre Daten aus einem stabilen
JSON-Endpunkt (im App-Bundle verdrahtet, Stand Analyse August 2026):
GET https://maps.eyeled-services.de/de/spiel{JJ}/products
?columns=["ID","INFO","S_ORDER","TITEL","FIRMA_ID","UNTERTITEL",
"BILDER","BILDER_VERSIONEN","BILDER_TEXTE"]
GET https://maps.eyeled-services.de/de/spiel{JJ}/exhibitors
?columns=["ID","NAME", …] (FIRMA_ID → Ausstellername)
Das Projekt-Kürzel (`spiel26`, `spiel27`, …) folgt dem aktuellen Jahr.
Antwort: `{"products": [{"ID", "TITEL", "UNTERTITEL", "FIRMA_ID",
"INFO", …}]}`; INFO ist eine HTML-Tabelle mit u. a. Autor, Verlag,
Vertrieb und Erscheinungsdatum (z. B. „10/2026“). Vor Veröffentlichung
der Liste liefert der Endpunkt eine leere Produktliste — das ist ein
normaler Lauf ohne Treffer, kein Fehler.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from urllib.parse import urlencode
from bs4 import BeautifulSoup
from .basis import QuellenAdapter, QuellenFehler, QuellenTreffer, SammelErgebnis
API_BASIS_URL = "https://maps.eyeled-services.de"
OEFFENTLICHE_SEITE = "https://spiel-essen.de/de/die-spiel/neuheiten"
PRODUKT_SPALTEN = [
"ID", "INFO", "S_ORDER", "TITEL", "FIRMA_ID", "UNTERTITEL",
"BILDER", "BILDER_VERSIONEN", "BILDER_TEXTE",
]
AUSSTELLER_SPALTEN = [
"ID", "NAME", "ADRESSE", "LAND", "LOGO", "PLZ", "STADT", "WEB",
"EMAIL", "INFO", "TELEFON", "S_ORDER", "STAND", "HALLE",
]
INFO_FELD_VERLAG = ("Verlag", "Vertrieb")
def _projekt_kuerzel(jahr: int | None = None) -> str:
"""API-Projektname der Messe (spiel25, spiel26, …)."""
jahr = jahr if jahr is not None else datetime.now(timezone.utc).year
return f"spiel{jahr % 100:02d}"
def _endpoint(pfad: str, spalten: list[str], jahr: int | None = None) -> str:
query = urlencode({"columns": json.dumps(spalten)})
return f"{API_BASIS_URL}/de/{_projekt_kuerzel(jahr)}/{pfad}?{query}"
class SpielEssenQuelle(QuellenAdapter):
name = "spielessen"
anzeigename = "SPIEL Essen Neuheiten (Produktliste)"
start_urls = (OEFFENTLICHE_SEITE,)
def __init__(self, client, jahr: int | None = None) -> None:
super().__init__(client)
self._jahr = jahr # für Tests überschreibbar (stabile Endpunkte)
def sammle(self) -> SammelErgebnis:
ergebnis = SammelErgebnis()
antwort = self.client.hole(
_endpoint("products", PRODUKT_SPALTEN, self._jahr)
)
if antwort.status_code == 304:
ergebnis.unveraendert = True
return ergebnis
ergebnis.seiten = 1
try:
daten = json.loads(antwort.content.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise QuellenFehler(f"Ungültiges JSON der SPIEL-Essen-Produktliste: {exc}") from exc
aussteller = self._lade_aussteller()
for produkt in daten.get("products") or []:
treffer = self._produkt(produkt, aussteller)
if treffer is not None:
ergebnis.treffer.append(treffer)
return ergebnis
def _lade_aussteller(self) -> dict[str, str]:
"""FIRMA_ID → Ausstellername; Fehler degradieren auf Untertitel-Fallback."""
try:
antwort = self.client.hole(
_endpoint("exhibitors", AUSSTELLER_SPALTEN, self._jahr)
)
except QuellenFehler:
return {}
if antwort.status_code == 304:
return {}
try:
daten = json.loads(antwort.content.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return {}
zuordnung: dict[str, str] = {}
for eintrag in daten.get("exhibitors") or []:
if eintrag.get("ID") is not None and eintrag.get("NAME"):
zuordnung[str(eintrag["ID"])] = str(eintrag["NAME"])
return zuordnung
def _produkt(self, produkt: dict, aussteller: dict[str, str]) -> QuellenTreffer | None:
titel = str(produkt.get("TITEL") or "").strip()
if not titel:
return None
felder = _parse_info(produkt.get("INFO") or "")
verlag = (
felder.get("Verlag")
or felder.get("Vertrieb")
or aussteller.get(str(produkt.get("FIRMA_ID") or ""))
or str(produkt.get("UNTERTITEL") or "").strip()
or None
)
return QuellenTreffer(
titel=titel,
verlag=verlag,
autor=felder.get("Autor"),
erscheinungsdatum_oder_quartal=felder.get("Erscheinungsdatum"),
quellen_url=OEFFENTLICHE_SEITE,
bild_url=_bild_aus_produkt(produkt),
)
def _bild_aus_produkt(produkt: dict) -> str | None:
"""Erste nutzbare Bild-URL aus dem BILDER-Feld (tolerant).
Das Feld ist je nach Messejahr eine Liste von URLs/Pfaden, ein Objekt
oder ein String; nur absolute bzw. protokoll-relative http(s)-URLs
werden übernommen, alles andere bleibt leer.
"""
def _aus_wert(wert) -> str | None:
if isinstance(wert, str):
url = wert.strip()
if url.startswith("//"):
url = f"https:{url}"
if url.startswith(("http://", "https://")):
return url
return None
if isinstance(wert, dict):
for schluessel in ("URL", "BILD", "SRC", "PATH", "PFAD"):
fund = _aus_wert(wert.get(schluessel))
if fund:
return fund
for unterwert in wert.values():
fund = _aus_wert(unterwert)
if fund:
return fund
return None
if isinstance(wert, list):
for element in wert:
fund = _aus_wert(element)
if fund:
return fund
return None
bilder = produkt.get("BILDER")
if isinstance(bilder, list):
for einzel in bilder:
fund = _aus_wert(einzel)
if fund:
return fund
return None
return _aus_wert(bilder)
def _parse_info(info_html: str) -> dict[str, str]:
"""Liest die INFO-Html-Tabelle (Zeilen „Feld: Wert“) tolerant aus."""
felder: dict[str, str] = {}
if not info_html.strip():
return felder
try:
suppe = BeautifulSoup(info_html, "html.parser")
except Exception: # kaputtes HTML darf den Lauf nicht abbrechen
return felder
for zeile in suppe.find_all("tr"):
zellen = zeile.find_all("td")
if len(zellen) != 2:
continue
schluessel = zellen[0].get_text(" ", strip=True).rstrip(":").strip()
wert = zellen[1].get_text(" ", strip=True).strip()
if schluessel and wert:
felder[schluessel] = wert
return felder

View File

@@ -0,0 +1,425 @@
"""Sync-Orchestrierung für die Web-Quellen des Neuheiten-Plugins.
Pro Quelle:
- Env-Schalter SPIELE_NEUHEITEN_QUELLE_<NAME>_AKTIV (Standard „1“ = an).
- Adapter-Lauf mit konditionalen Requests (ETag/Last-Modified werden in
der Statuszeile persistiert; 304 → Quelle übersprungen, kein Fehler).
- Fehler-Isolation: eine kaputte Quelle bricht den Gesamtdurchlauf nicht
ab; ihr Ergebnis wird als Fehler protokolliert, die anderen Quellen
laufen normal weiter.
- Duplikatvermeidung: Web-Quellen haben keine BGG-ID, daher wird jeder
Treffer vor dem Speichern fuzzy gegen die bestehende Liste geprüft
(Titel-Match, rapidfuzz) — Treffer gegen BGG-Einträge werden
übersprungen. Zusätzlich läuft jeder neue Treffer durch die öffentliche
dedup-Prüfung (via Plugin-Registry, sofern das Plugin geladen ist).
- Upsert: bereits vorhandene Treffer derselben Quelle (gleicher Titel)
werden aktualisiert statt doppelt angelegt; neue Einträge bekommen
quelle = '<name>' statt 'boardgamegeek' und keine BGG-ID.
Das Ergebnis jedes Laufes wird pro Quelle in `neuheiten_quellen_status`
protokolliert und im Plugin-UI unter /neuheiten angezeigt.
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from .quellen.ki_hilfe import KiHilfe, lade_konfiguration as lade_ki_konfiguration
from .models import STATUS_NEUHEIT, Neuheit, QuellenStatus
from .quellen import (
ADAPTER_KLASSEN,
QuellenAdapter,
QuellenTreffer,
WebQuellenClient,
jahr_aus_datumsangabe,
titel_aehnlichkeit,
titel_normalisieren,
)
_logger = logging.getLogger("plugins.neuheiten")
#: Schwelle für den Fuzzy-Titel-Match gegen bestehende Einträge (0100).
TITEL_FUZZY_SCHWELLE = 92.0
#: Zweite Regel: etwas lockererer Titel-Match, dafür gleicher/ähnlicher Verlag.
TITEL_FUZZY_MIT_VERLAG_SCHWELLE = 85.0
VERLAG_FUZZY_SCHWELLE = 85.0
ENV_SCHABLONE_AKTIV = "SPIELE_NEUHEITEN_QUELLE_{name}_AKTIV"
def quelle_aktiv(name: str, umgebung=None) -> bool:
"""Liest den Env-Schalter einer Quelle (Standard: an)."""
env = umgebung if umgebung is not None else os.environ
wert = env.get(ENV_SCHABLONE_AKTIV.format(name=name.upper()), "1")
return str(wert).strip() != "0"
@dataclass
class QuellenLaufZeile:
"""Ergebnis einer Quelle in einem Sync-Lauf (Grundlage der UI-Übersicht)."""
quelle: str
anzeigename: str
aktiv: bool = True
neu: int = 0
aktualisiert: int = 0
gefiltert: int = 0
fehleranzahl: int = 0
fehlermeldung: str | None = None
unveraendert: bool = False
seiten: int = 0
dauer_sekunden: float = 0.0
url: str | None = None
def als_text(self) -> str:
if not self.aktiv:
return f"{self.anzeigename}: ausgeschaltet"
if self.fehlermeldung:
return f"{self.anzeigename}: Fehler — {self.fehlermeldung}"
if self.unveraendert:
return f"{self.anzeigename}: unverändert (304) — übersprungen"
return (
f"{self.anzeigename}: {self.neu} neu, {self.aktualisiert} aktualisiert, "
f"{self.gefiltert} gefiltert (Duplikate)"
)
class WebQuellenSyncService:
"""Führt einen Sync-Lauf über alle Web-Quellen aus.
`dedup` ist die öffentliche API des dedup-Plugins (via Plugin-Registry)
oder None, wenn das Plugin nicht geladen ist — die Prüfung ist dann
optional und wird übersprungen. `client_fabrik` baut den HTTP-Client
(in Tests mit Fake-Transport überschrieben).
"""
def __init__(
self,
session_factory: sessionmaker,
*,
adapter_klassen: tuple[type[QuellenAdapter], ...] = ADAPTER_KLASSEN,
dedup=None,
client_fabrik: Callable[[dict], WebQuellenClient] | None = None,
umgebung=None,
ki_fabrik: Callable[[], KiHilfe] | None = None,
audit=None,
) -> None:
self.session_factory = session_factory
self.adapter_klassen = adapter_klassen
self.dedup = dedup
self.audit = audit
self.umgebung = umgebung if umgebung is not None else os.environ
self._client_fabrik = client_fabrik or self._standard_client_fabrik
# KI-Hilfsstufe: Fabrik für Tests; ohne vollständige Konfiguration wird
# nie ein Client gebaut (und damit nie ein LLM-Aufruf getätigt).
self._ki_fabrik = ki_fabrik
# ---------- Gesamtdurchlauf ----------
def laufe(self, nur_quellen: set[str] | None = None) -> list[QuellenLaufZeile]:
"""Führt den Web-Quellen-Sync aus.
`nur_quellen`: optionaler Filter — nur diese Quellnamen laufen
(manueller Einzel-Quellen-Sync aus dem UI). Der An/Aus-Schalter
der Quelle wird dabei ignoriert, ein explizit angeforderter
Einzellauf läuft immer.
"""
zeilen: list[QuellenLaufZeile] = []
ki = self._ki_hilfe()
for klasse in self.adapter_klassen:
if nur_quellen is not None and klasse.name not in nur_quellen:
continue
try:
zeile = self._eine_quelle(
klasse, ki, einzellauf=nur_quellen is not None
)
except Exception as exc: # letzte Verteidigungslinie pro Quelle
_logger.exception("Quellen-Sync fehlgeschlagen für %s", klasse.name)
zeile = QuellenLaufZeile(
klasse.name,
klasse.anzeigename,
fehleranzahl=1,
fehlermeldung=str(exc)[:300],
)
self._status_speichern(zeile)
zeilen.append(zeile)
return zeilen
def schalter_gesetzt(self, name: str, wert: bool) -> None:
"""Setzt den UI-An/Aus-Schalter einer Quelle (persistiert).
Der Schalter übersteuert den Env-Standard; der Hintergrund-Sync
überspringt ausgeschaltete Quellen.
"""
with self.session_factory() as db:
eintrag = db.scalar(
select(QuellenStatus).where(QuellenStatus.quelle == name)
)
if eintrag is None:
eintrag = QuellenStatus(quelle=name, url="")
db.add(eintrag)
eintrag.aktiv_schalter = wert
db.commit()
def quelle_laeuft(self, name: str) -> bool:
"""Effektiver Aktiv-Status einer Quelle (Schalter übersteuert Env)."""
with self.session_factory() as db:
eintrag = db.scalar(
select(QuellenStatus).where(QuellenStatus.quelle == name)
)
if eintrag is not None and eintrag.aktiv_schalter is not None:
return eintrag.aktiv_schalter
return quelle_aktiv(name, self.umgebung)
def gesamt_text(self, zeilen: list[QuellenLaufZeile]) -> str:
"""Kompakte deutschsprachige Zusammenfassung für Banner/Protokoll."""
teile = [zeile.als_text() for zeile in zeilen]
return " · ".join(teile) if teile else "Keine Web-Quellen konfiguriert."
# ---------- Eine Quelle ----------
def _ki_hilfe(self) -> KiHilfe | None:
"""KI-Hilfsstufe nur bei vollständiger Konfiguration (sonst None).
Die Aktivierungsprüfung gilt vor jeder injizierten Fabrik — ist die
Hilfsstufe aus oder unvollständig konfiguriert, wird nie ein Client
gebaut und damit nie ein LLM-Aufruf getätigt.
"""
try:
konfiguration = lade_ki_konfiguration(self.umgebung)
if not konfiguration.vollstaendig:
return None
if self._ki_fabrik is not None:
return self._ki_fabrik()
return KiHilfe(konfiguration)
except Exception:
# Auch ein kaputtes KI-Setup darf den Sync nie blockieren.
_logger.exception("neuheiten/KI: Hilfsstufe nicht verfügbar — klassischer Lauf.")
return None
def _eine_quelle(
self,
klasse: type[QuellenAdapter],
ki: KiHilfe | None = None,
einzellauf: bool = False,
) -> QuellenLaufZeile:
zeile = QuellenLaufZeile(klasse.name, klasse.anzeigename)
zeile.url = klasse.start_urls[0] if klasse.start_urls else None
# Einzellauf (nur_quellen) läuft immer; sonst entscheidet der Schalter.
if not einzellauf and not self.quelle_laeuft(klasse.name):
zeile.aktiv = False
return zeile
start = time.monotonic()
client = self._client_fabrik(self._validatoren_laden(klasse.name))
try:
adapter = klasse(client)
adapter.ki_hilfe = ki # None = rein regelbasiert (Fallback-Pflicht)
try:
ergebnis = adapter.sammle()
except Exception as exc:
_logger.warning("Quelle %s fehlgeschlagen: %s", klasse.name, exc)
zeile.fehleranzahl = 1
zeile.fehlermeldung = str(exc)[:300]
return zeile
zeile.seiten = ergebnis.seiten
self._ki_auditieren(klasse.name, ergebnis)
if ergebnis.unveraendert:
zeile.unveraendert = True
else:
self._treffer_verarbeiten(zeile, ergebnis.treffer, klasse.name)
finally:
client.schliessen()
zeile.dauer_sekunden = round(time.monotonic() - start, 2)
self._validatoren_speichern(klasse.name, zeile.url, client)
return zeile
def _treffer_verarbeiten(
self, zeile: QuellenLaufZeile, treffer: list[QuellenTreffer], quelle: str
) -> None:
asyncio.run(self._verarbeite_async(zeile, treffer, quelle))
async def _verarbeite_async(
self, zeile: QuellenLaufZeile, treffer: list[QuellenTreffer], quelle: str
) -> None:
with self.session_factory() as db:
try:
for einzel in treffer:
if not einzel.titel or not einzel.titel.strip():
zeile.gefiltert += 1
continue
if self._eigenen_eintrag_aktualisieren(db, einzel, quelle):
zeile.aktualisiert += 1
continue
if self._ist_duplikat(db, einzel):
zeile.gefiltert += 1
continue
if not await self._dedup_erklaert_kein_konflikt(einzel):
zeile.gefiltert += 1
continue
db.add(
Neuheit(
titel=einzel.titel.strip()[:300],
verlag=einzel.verlag,
autor=einzel.autor,
erscheinungsjahr=jahr_aus_datumsangabe(
einzel.erscheinungsdatum_oder_quartal
),
bgg_id=None,
bild_url=einzel.bild_url,
quellen_url=einzel.quellen_url,
status=STATUS_NEUHEIT,
quelle=quelle,
)
)
zeile.neu += 1
db.commit()
except Exception:
db.rollback()
raise
# ---------- Duplikate / Upsert ----------
def _eigenen_eintrag_aktualisieren(
self, db, treffer: QuellenTreffer, quelle: str
) -> bool:
"""Update statt Duplikat: gleicher Titel + gleiche Quelle."""
schluessel = titel_normalisieren(treffer.titel)
vorhanden = db.scalars(select(Neuheit).where(Neuheit.quelle == quelle)).all()
for eintrag in vorhanden:
if titel_normalisieren(eintrag.titel) == schluessel:
eintrag.verlag = treffer.verlag
eintrag.autor = treffer.autor
eintrag.erscheinungsjahr = jahr_aus_datumsangabe(
treffer.erscheinungsdatum_oder_quartal
)
# Coverbild + Quellen-URL mitpflegen; fehlen sie im Treffer,
# bleiben die bereits gespeicherten Werte erhalten.
if treffer.bild_url:
eintrag.bild_url = treffer.bild_url
if treffer.quellen_url:
eintrag.quellen_url = treffer.quellen_url
return True
return False
def _ist_duplikat(self, db, treffer: QuellenTreffer) -> bool:
"""Titel-Match gegen bestehende Einträge (z. B. aus BGG).
BGG-IDs haben Web-Quellen nicht — gematcht wird fuzzy auf den
Titel; bei Grenzfällen entscheidet ein ähnlicher Verlag mit.
"""
for eintrag in db.scalars(select(Neuheit)).all():
if titel_aehnlichkeit(treffer.titel, eintrag.titel) >= TITEL_FUZZY_SCHWELLE:
return True
if (
treffer.verlag
and eintrag.verlag
and titel_aehnlichkeit(treffer.titel, eintrag.titel)
>= TITEL_FUZZY_MIT_VERLAG_SCHWELLE
and titel_aehnlichkeit(treffer.verlag, eintrag.verlag)
>= VERLAG_FUZZY_SCHWELLE
):
return True
return False
async def _dedup_erklaert_kein_konflikt(self, treffer: QuellenTreffer) -> bool:
"""Öffentliche dedup-Prüfung (sofern Plugin geladen); Fehler tolerieren."""
if self.dedup is None:
return True
try:
ergebnis = await self.dedup.check_titel(treffer.titel, treffer.verlag, None)
except Exception as exc:
_logger.warning("dedup-Prüfung fehlgeschlagen (%s): %s", treffer.titel, exc)
return True
if ergebnis is None:
return True
return not ergebnis.hat_konflikte
# ---------- KI-Audit ----------
def _ki_auditieren(self, quelle: str, ergebnis) -> None:
"""KI-Eingriffe des Laufs ins audit-log schreiben (best effort).
Ein Eintrag je Quelle und Lauf mit Modell, Konfidenz, korrigierten
Feldern und den verfolgten KI-Folge-URLs. Ohne audit-log (Plugin
nicht geladen) oder bei Fehlern wird nur protokolliert — der Sync
wird nie blockiert.
"""
eingriffe = list(getattr(ergebnis, "ki_eingriffe", []) or [])
folge_urls = list(getattr(ergebnis, "ki_folge_urls", []) or [])
if not eingriffe and not folge_urls:
return
if self.audit is None:
_logger.info(
"Quelle %s: %d KI-Korrekturen, %d KI-Folge-URLs (kein audit-log geladen).",
quelle, len(eingriffe), len(folge_urls),
)
return
details = {
"quelle": quelle,
"modell": eingriffe[0].get("modell", "") if eingriffe else "",
"eingriffe": eingriffe,
"folge_urls": folge_urls,
}
try:
self.audit.log_sync(None, "korrigiert", "neuheiten_ki_crawler", quelle, details)
except Exception:
_logger.exception("neuheiten/KI: Audit-Eintrag konnte nicht geschrieben werden.")
# ---------- Statusprotokoll ----------
def _status_speichern(self, zeile: QuellenLaufZeile) -> None:
with self.session_factory() as db:
eintrag = db.scalar(
select(QuellenStatus).where(QuellenStatus.quelle == zeile.quelle)
)
if eintrag is None:
eintrag = QuellenStatus(quelle=zeile.quelle, url=zeile.url or "")
db.add(eintrag)
if zeile.url:
eintrag.url = zeile.url
eintrag.letzte_laufzeit = datetime.now(timezone.utc).replace(tzinfo=None)
eintrag.dauer_sekunden = zeile.dauer_sekunden
eintrag.anzahl_neu = zeile.neu
eintrag.anzahl_aktualisiert = zeile.aktualisiert
eintrag.anzahl_gefiltert = zeile.gefiltert
eintrag.anzahl_fehler = zeile.fehleranzahl
eintrag.fehlermeldung = zeile.fehlermeldung
eintrag.unveraendert = zeile.unveraendert
db.commit()
def _validatoren_laden(self, quelle: str) -> dict:
with self.session_factory() as db:
eintrag = db.scalar(
select(QuellenStatus).where(QuellenStatus.quelle == quelle)
)
return dict(eintrag.validatoren) if eintrag is not None else {}
def _validatoren_speichern(
self, quelle: str, url: str | None, client: WebQuellenClient
) -> None:
if not client.validatoren:
return
with self.session_factory() as db:
eintrag = db.scalar(
select(QuellenStatus).where(QuellenStatus.quelle == quelle)
)
if eintrag is None:
eintrag = QuellenStatus(quelle=quelle, url=url or "")
db.add(eintrag)
eintrag.validatoren = client.validatoren
db.commit()
# ---------- Hilfen ----------
@staticmethod
def _standard_client_fabrik(validatoren) -> WebQuellenClient:
return WebQuellenClient(validatoren=validatoren)

View File

@@ -3,21 +3,24 @@
- Update statt Duplikat: bestehende Einträge werden über die eindeutige - Update statt Duplikat: bestehende Einträge werden über die eindeutige
BGG-ID gefunden und aktualisiert (Status bleibt erhalten). BGG-ID gefunden und aktualisiert (Status bleibt erhalten).
- Erweiterungen werden im Client und hier auf Elementebene gefiltert. - Erweiterungen werden im Client und hier auf Elementebene gefiltert.
- Prototypen: Die BGG-XML-API2 liefert keinen verlässlichen Prototyp-Marker; - Prototypen: Titel mit Prototyp-Schlüsselwörtern werden ausgeschlossen.
als Heuristik werden Titel mit Prototyp-Schlüsselwörtern ausgeschlossen - Jahresfilter: optional nur Spiele mit Erscheinungsjahr ≥ aktuelles Jahr.
(dokumentierte Einschränkung, siehe README). - Deutsch-Filter: optional nur Spiele mit deutsch klingendem Titel.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import re import re
import unicodedata
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from .bgg import BggSpiel, BggClient from .bgg import BggAuthFehler, BggSpiel, BggClient
from .models import STATUS_NEUHEIT, Neuheit from .models import STATUS_NEUHEIT, Neuheit
_logger = logging.getLogger("plugins.neuheiten") _logger = logging.getLogger("plugins.neuheiten")
@@ -28,12 +31,58 @@ PROTOTYP_MUSTER = re.compile(
THING_BATCH_GROESSE = 20 THING_BATCH_GROESSE = 20
#: Funktionswörter, die stark auf einen deutschen Spieltitel hindeuten.
_DEUTSCHE_INDIKATOREN = frozenset({
"der", "die", "das", "den", "dem", "des",
"ein", "eine", "einen", "einem", "einer", "eines",
"und", "für", "von", "vom", "zu", "zum", "zur",
"im", "am", "beim", "auf", "mit", "aus",
})
#: Typische Begriffe deutscher Spieltitel (+2 Punkte je Treffer).
_DEUTSCHE_SPIELWOERTER = frozenset({
"abenteuer", "berg", "blitz", "burg", "dorf", "drache", "drachen",
"erde", "feuer", "gärten", "haus", "himmel", "insel", "jagd",
"kaiser", "könig", "könige", "königreich", "land", "länder",
"meister", "mond", "norden", "osten", "pfad", "reich", "ritter",
"schiff", "schloss", "see", "spiel", "spiele", "spielen",
"stadt", "stern", "süden", "turm", "wald", "weg", "welt",
"westen", "wiese", "wilder", "wüste", "zauber", "zeit",
})
#: Wörter, die auf einen englischen Titel hindeuten (1 Punkt je Treffer).
_ENGLISCHE_INDIKATOREN = frozenset({
"the", "of", "and", "game", "games", "card", "cards", "dice", "board",
})
_UM_LAUTE = frozenset("äöüßÄÖÜ")
_WORT_MUSTER = re.compile(r"[^\W\d_]+", re.UNICODE)
def ist_deutscher_titel(titel: str) -> bool:
"""Heuristik: > 0 spricht für einen deutschen Titel.
Umlaute/ß zählen am stärksten (+3), deutsche Funktionswörter +1,
typische deutsche Spielbegriffe +2, englische Indikatoren 1.
"""
if not titel:
return False
score = 0
if any(zeichen in _UM_LAUTE for zeichen in titel):
score += 3
woerter = {wort.casefold() for wort in _WORT_MUSTER.findall(titel)}
score += sum(1 for wort in woerter if wort in _DEUTSCHE_INDIKATOREN)
score += sum(2 for wort in woerter if wort in _DEUTSCHE_SPIELWOERTER)
score -= sum(1 for wort in woerter if wort in _ENGLISCHE_INDIKATOREN)
return score > 0
def ist_moeglicher_prototyp(titel: str) -> bool: def ist_moeglicher_prototyp(titel: str) -> bool:
"""Heuristik: Titel klingt nach Prototyp/Protoversion.""" """Heuristik: Titel klingt nach Prototyp/Protoversion."""
return bool(PROTOTYP_MUSTER.search(titel)) return bool(PROTOTYP_MUSTER.search(titel))
def aktuelles_jahr() -> int:
return date.today().year
@dataclass @dataclass
class SyncErgebnis: class SyncErgebnis:
"""Zusammenfassung eines Sync-Laufs.""" """Zusammenfassung eines Sync-Laufs."""
@@ -42,11 +91,16 @@ class SyncErgebnis:
aktualisiert: int = 0 aktualisiert: int = 0
gefiltert: int = 0 gefiltert: int = 0
fehler: list[str] = field(default_factory=list) fehler: list[str] = field(default_factory=list)
# Grund, warum der Lauf vorzeitig endete oder ganz übersprungen wurde
# (z. B. fehlender BGG-Token oder HTTP 401); None = normal gelaufen.
abbruch: str | None = None
def als_text(self) -> str: def als_text(self) -> str:
if self.abbruch:
return self.abbruch
text = ( text = (
f"{self.neu} neu, {self.aktualisiert} aktualisiert, " f"{self.neu} neu, {self.aktualisiert} aktualisiert, "
f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen)" f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen/Jahr/Sprache)"
) )
if self.fehler: if self.fehler:
text += f"; {len(self.fehler)} Fehler" text += f"; {len(self.fehler)} Fehler"
@@ -64,6 +118,7 @@ def speichere_oder_aktualisiere(db: Session, spiel: BggSpiel) -> bool:
autor=spiel.autor, autor=spiel.autor,
erscheinungsjahr=spiel.erscheinungsjahr, erscheinungsjahr=spiel.erscheinungsjahr,
bgg_id=spiel.bgg_id, bgg_id=spiel.bgg_id,
bild_url=spiel.bild_url,
status=STATUS_NEUHEIT, status=STATUS_NEUHEIT,
quelle="boardgamegeek", quelle="boardgamegeek",
) )
@@ -73,6 +128,10 @@ def speichere_oder_aktualisiere(db: Session, spiel: BggSpiel) -> bool:
vorhanden.verlag = spiel.verlag vorhanden.verlag = spiel.verlag
vorhanden.autor = spiel.autor vorhanden.autor = spiel.autor
vorhanden.erscheinungsjahr = spiel.erscheinungsjahr vorhanden.erscheinungsjahr = spiel.erscheinungsjahr
# Coverbild mitpflegen: BGG liefert es ggf. erst nachträglich; ein
# fehlendes Bild in der Antwort löscht kein bereits gespeichertes.
if spiel.bild_url:
vorhanden.bild_url = spiel.bild_url
return False return False
@@ -86,11 +145,16 @@ class SyncService:
*, *,
thing_batch_groesse: int = THING_BATCH_GROESSE, thing_batch_groesse: int = THING_BATCH_GROESSE,
max_treffer_pro_suchbegriff: int | None = None, max_treffer_pro_suchbegriff: int | None = None,
jahr_filter: bool = True,
deutscher_titel_filter: bool = True,
) -> None: ) -> None:
self.session_factory = session_factory self.session_factory = session_factory
self.client = client self.client = client
self.thing_batch_groesse = thing_batch_groesse self.thing_batch_groesse = thing_batch_groesse
self.max_treffer_pro_suchbegriff = max_treffer_pro_suchbegriff self.max_treffer_pro_suchbegriff = max_treffer_pro_suchbegriff
self.jahr_filter = jahr_filter
self.deutscher_titel_filter = deutscher_titel_filter
self._min_jahr = aktuelles_jahr()
def synchronisiere(self, suchbegriffe: Iterable[str]) -> SyncErgebnis: def synchronisiere(self, suchbegriffe: Iterable[str]) -> SyncErgebnis:
ergebnis = SyncErgebnis() ergebnis = SyncErgebnis()
@@ -101,6 +165,13 @@ class SyncService:
continue continue
try: try:
self._ein_suchbegriff(db, begriff, ergebnis) self._ein_suchbegriff(db, begriff, ergebnis)
except BggAuthFehler as exc:
# HTTP 401 trifft jede weitere Anfrage genauso — der Lauf
# wird abgebrochen, statt alle Begriffe einzeln scheitern
# zu lassen; bereits gespeicherte Treffer bleiben erhalten.
_logger.warning("BGG-Sync abgebrochen für %r: %s", begriff, exc)
ergebnis.abbruch = str(exc)
break
except Exception as exc: # ein Begriff darf den Lauf nicht abbrechen except Exception as exc: # ein Begriff darf den Lauf nicht abbrechen
_logger.warning("BGG-Sync fehlgeschlagen für %r: %s", begriff, exc) _logger.warning("BGG-Sync fehlgeschlagen für %r: %s", begriff, exc)
ergebnis.fehler.append(f"{begriff}: {exc}") ergebnis.fehler.append(f"{begriff}: {exc}")
@@ -122,6 +193,13 @@ class SyncService:
if ist_moeglicher_prototyp(spiel.titel): if ist_moeglicher_prototyp(spiel.titel):
ergebnis.gefiltert += 1 ergebnis.gefiltert += 1
continue continue
if self.jahr_filter and spiel.erscheinungsjahr is not None:
if spiel.erscheinungsjahr < self._min_jahr:
ergebnis.gefiltert += 1
continue
if self.deutscher_titel_filter and not ist_deutscher_titel(spiel.titel):
ergebnis.gefiltert += 1
continue
if speichere_oder_aktualisiere(db, spiel): if speichere_oder_aktualisiere(db, spiel):
ergebnis.neu += 1 ergebnis.neu += 1
else: else:

View File

@@ -0,0 +1,24 @@
{# Coverbild-Vorschau des Neuheiten-Plugins.
Erwartete Variablen: cover_bild (URL oder None), cover_titel,
cover_gross (optional: true für großformatige Ansichten).
Bilder werden direkt von der Quelle eingebettet (Hotlinking, kein
Download) — mit loading="lazy" und decoding="async". Ohne Bild zeigt
das Partial einen dezenten Platzhalter mit Spielicon-Zeichen. #}
{% set _gross = cover_gross | default(false) %}
{% if _gross %}
{% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async"
class="block h-auto w-auto max-w-[200px] max-h-[280px] rounded-lg border border-slate-200 bg-white shadow-sm">
{% else %}
<div aria-hidden="true" title="Kein Cover vorhanden"
class="flex h-[210px] w-[150px] max-w-[200px] items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 text-6xl text-slate-300 select-none">&#127922;</div>
{% endif %}
{% else %}
{% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" width="40" height="56"
class="block h-14 w-10 shrink-0 rounded border border-slate-200 bg-slate-50 object-cover">
{% else %}
<div aria-hidden="true" title="Kein Cover vorhanden"
class="flex h-14 w-10 shrink-0 items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 text-lg text-slate-400 select-none">&#127922;</div>
{% endif %}
{% endif %}

View File

@@ -1,9 +1,61 @@
<div id="neuheiten-liste"> <div id="neuheiten-liste">
<p class="text-xs text-slate-500 mb-2">{{ anzahl }} Eintr&auml;ge</p> <p class="text-xs text-slate-500 mb-2">{{ anzahl }} Eintr&auml;ge</p>
<div class="overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm">
<!-- Mobil (<768px): Karten-Ansicht — Cover links, Titel+Metadaten rechts, Status oben -->
<ul class="md:hidden space-y-3" role="list">
{% for eintrag in eintraege %}
<li class="bg-white rounded-xl border border-slate-200 shadow-sm p-3 flex gap-3">
<div class="pt-0.5">
{% set cover_bild = eintrag.bild_url %}
{% set cover_titel = eintrag.titel %}
{% include "neuheiten/_cover.html" %}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<h3 class="text-sm font-medium leading-snug break-words">
{{ eintrag.titel }}
{% if eintrag.bgg_id %}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
class="ml-0.5 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
{% elif eintrag.quelle and eintrag.quelle != 'boardgamegeek' %}
<a href="{{ eintrag.quellen_url or '#' }}" target="_blank" rel="noopener"
title="Zur Quelle ({{ eintrag.quelle }})"
class="ml-0.5 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }}&nbsp;</a>
{% endif %}
</h3>
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 text-xs">{{ eintrag.status }}</span>
</div>
<dl class="mt-1.5 space-y-0.5 text-xs text-slate-500">
{% if eintrag.verlag %}<div><dt class="inline font-medium text-slate-400">Verlag: </dt><dd class="inline">{{ eintrag.verlag }}</dd></div>{% endif %}
{% if eintrag.autor %}<div><dt class="inline font-medium text-slate-400">Autor: </dt><dd class="inline">{{ eintrag.autor }}</dd></div>{% endif %}
{% if eintrag.erscheinungsjahr %}<div><dt class="inline font-medium text-slate-400">Jahr: </dt><dd class="inline">{{ eintrag.erscheinungsjahr }}</dd></div>{% endif %}
<div>
<dt class="inline font-medium text-slate-400">Aktualisiert: </dt>
<dd class="inline">{{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }}</dd>
</div>
</dl>
{% if eintrag.status == 'neuheit' %}
<form method="post" action="/planung/uebernehmen/{{ eintrag.id }}" class="mt-2">
<button type="submit" title="In die Planungsliste verschieben (läuft durch die dedup-Prüfung)"
class="w-full min-h-[44px] inline-flex items-center justify-center gap-1 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-3 py-2 rounded-lg text-sm whitespace-nowrap">&#8594; Zur Planung</button>
</form>
{% endif %}
</div>
</li>
{% else %}
<li class="bg-white rounded-xl border border-slate-200 shadow-sm px-4 py-8 text-center text-sm text-slate-400">
Keine Eintr&auml;ge gefunden.
{% if ist_redaktion %}Nutze &bdquo;Jetzt synchronisieren&ldquo;, um die Liste aus BoardGameGeek zu befüllen.{% endif %}
</li>
{% endfor %}
</ul>
<!-- Desktop/Tablet (&ge;768px): Tabellenform -->
<div class="hidden md:block overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="bg-slate-50 text-left text-slate-500">
<tr> <tr>
<th class="px-4 py-3 font-medium">Cover</th>
{% set basis = '/neuheiten?q=' ~ (q | urlencode) ~ '&status=' ~ (status_filter | urlencode) %} {% set basis = '/neuheiten?q=' ~ (q | urlencode) ~ '&status=' ~ (status_filter | urlencode) %}
{% for key, label in [('titel', 'Spieltitel'), ('verlag', 'Verlag'), ('autor', 'Autor'), ('jahr', 'Erscheinungsjahr'), ('status', 'Status'), ('aktualisiert', 'Aktualisiert')] %} {% for key, label in [('titel', 'Spieltitel'), ('verlag', 'Verlag'), ('autor', 'Autor'), ('jahr', 'Erscheinungsjahr'), ('status', 'Status'), ('aktualisiert', 'Aktualisiert')] %}
<th class="px-4 py-3 font-medium whitespace-nowrap"> <th class="px-4 py-3 font-medium whitespace-nowrap">
@@ -21,10 +73,21 @@
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
{% for eintrag in eintraege %} {% for eintrag in eintraege %}
<tr class="hover:bg-slate-50"> <tr class="hover:bg-slate-50">
<td class="px-4 py-3">
{% set cover_bild = eintrag.bild_url %}
{% set cover_titel = eintrag.titel %}
{% include "neuheiten/_cover.html" %}
</td>
<td class="px-4 py-3 font-medium"> <td class="px-4 py-3 font-medium">
{{ eintrag.titel }} {{ eintrag.titel }}
{% if eintrag.bgg_id %}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener" <a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a> class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
{% elif eintrag.quelle and eintrag.quelle != 'boardgamegeek' %}
<a href="{{ eintrag.quellen_url or '#' }}" target="_blank" rel="noopener"
title="Zur Quelle ({{ eintrag.quelle }})"
class="ml-1 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }}&nbsp;</a>
{% endif %}
</td> </td>
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td> <td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
<td class="px-4 py-3">{{ eintrag.autor or '—' }}</td> <td class="px-4 py-3">{{ eintrag.autor or '—' }}</td>
@@ -39,14 +102,14 @@
{% if eintrag.status == 'neuheit' %} {% if eintrag.status == 'neuheit' %}
<form method="post" action="/planung/uebernehmen/{{ eintrag.id }}"> <form method="post" action="/planung/uebernehmen/{{ eintrag.id }}">
<button type="submit" title="In die Planungsliste verschieben (läuft durch die dedup-Prüfung)" <button type="submit" title="In die Planungsliste verschieben (läuft durch die dedup-Prüfung)"
class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2 py-1 rounded-lg whitespace-nowrap">&#8594; Zur Planung</button> class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2.5 py-2 rounded-lg whitespace-nowrap">&#8594; Zur Planung</button>
</form> </form>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="7" class="px-4 py-8 text-center text-slate-400"> <td colspan="8" class="px-4 py-8 text-center text-slate-400">
Keine Eintr&auml;ge gefunden. Keine Eintr&auml;ge gefunden.
{% if ist_redaktion %}Nutze &bdquo;Jetzt synchronisieren&ldquo;, um die Liste aus BoardGameGeek zu befüllen.{% endif %} {% if ist_redaktion %}Nutze &bdquo;Jetzt synchronisieren&ldquo;, um die Liste aus BoardGameGeek zu befüllen.{% endif %}
</td> </td>
@@ -56,3 +119,10 @@
</table> </table>
</div> </div>
</div> </div>
{% if hat_weitere %}
<div id="lade-mehr"
hx-get="/neuheiten?seite_nr={{ naechste_seite }}&q={{ q }}&status={{ status_filter }}&sort={{ sort }}&richtung={{ richtung }}"
hx-trigger="revealed delay:150ms" hx-swap="outerHTML">
<div class="py-6 text-center text-sm text-slate-400">Lade weitere Eintr&auml;ge&nbsp;&hellip;</div>
</div>
{% endif %}

View File

@@ -20,16 +20,16 @@
<div class="flex flex-wrap items-end gap-3 mb-4"> <div class="flex flex-wrap items-end gap-3 mb-4">
{% if ist_redaktion %} {% if ist_redaktion %}
<form method="post" action="/neuheiten/sync" class="flex items-end gap-2"> <form method="post" action="/neuheiten/sync" class="flex flex-col sm:flex-row items-stretch sm:items-end gap-2">
<div> <div class="sm:w-72">
<label for="suchbegriff" class="block text-xs font-medium text-slate-500 mb-1"> <label for="suchbegriff" class="block text-xs font-medium text-slate-500 mb-1">
Sofort auf BoardGameGeek suchen &amp; importieren (optional) Sofort auf BoardGameGeek suchen &amp; importieren (optional)
</label> </label>
<input type="text" id="suchbegriff" name="suchbegriff" placeholder="Spielname oder Suchbegriff …" <input type="text" id="suchbegriff" name="suchbegriff" placeholder="Spielname oder Suchbegriff …"
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"> class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<button type="submit" <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"> class="min-h-[44px] inline-flex items-center justify-center bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
&#8635; Jetzt synchronisieren &#8635; Jetzt synchronisieren
</button> </button>
</form> </form>
@@ -41,20 +41,105 @@
</p> </p>
</div> </div>
<div class="mb-6 bg-white rounded-xl border border-slate-200 shadow-sm p-4">
<div class="flex flex-wrap items-center gap-2 mb-3">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500 mr-auto">Web-Quellen</h2>
{% if ist_redaktion %}
<form method="post" action="/neuheiten/quellen-sync">
<button type="submit"
class="min-h-[44px] md:min-h-0 bg-sky-600 hover:bg-sky-700 active:bg-sky-800 text-white font-medium px-3 py-1.5 rounded-lg text-xs whitespace-nowrap">
&#8635; Web-Quellen synchronisieren
</button>
</form>
{% endif %}
</div>
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead class="text-left text-slate-400">
<tr>
<th class="px-2 py-1.5 font-medium">Quelle</th>
<th class="px-2 py-1.5 font-medium">Status</th>
<th class="px-2 py-1.5 font-medium">Letzter Lauf</th>
<th class="px-2 py-1.5 font-medium text-right">Neu</th>
<th class="px-2 py-1.5 font-medium text-right">Aktualisiert</th>
<th class="px-2 py-1.5 font-medium text-right">Gefiltert</th>
<th class="px-2 py-1.5 font-medium">Hinweis</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for quelle in quellen_zeilen %}
<tr>
<td class="px-2 py-1.5">
<a href="{{ quelle.url }}" target="_blank" rel="noopener noreferrer"
class="font-medium text-slate-700 hover:text-emerald-700">{{ quelle.anzeigename }}</a>
<span class="ml-1 text-[10px] text-slate-300">{{ quelle.name }}</span>
</td>
<td class="px-2 py-1.5">
<form method="post" action="/neuheiten/quellen/{{ quelle.name }}/schalter" class="inline-flex items-center gap-2">
{% if sync_aktiv %}
<input type="hidden" name="aktiv" value="{{ '0' if quelle.aktiv else '1' }}">
<button type="submit"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors {{ 'bg-emerald-500' if quelle.aktiv else 'bg-slate-300' }}"
title="{{ 'Quelle ausschalten' if quelle.aktiv else 'Quelle einschalten' }}">
<span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform {{ 'translate-x-4' if quelle.aktiv else 'translate-x-0.5' }}"></span>
</button>
{% else %}
<span class="text-xs text-slate-400">{{ 'an' if quelle.aktiv else 'aus' }} (Env)</span>
{% endif %}
</form>
<form method="post" action="/neuheiten/quellen-sync" class="inline">
<input type="hidden" name="quelle" value="{{ quelle.name }}">
<button type="submit"
class="ml-2 text-xs text-emerald-700 hover:text-emerald-900 underline decoration-dotted"
title="Nur diese Quelle jetzt synchronisieren">jetzt sync</button>
</form>
</td>
<td class="px-2 py-1.5 whitespace-nowrap text-slate-500">
{{ quelle.letzte_laufzeit.strftime('%d.%m.%Y %H:%M') if quelle.letzte_laufzeit else '—' }}
{% if quelle.dauer_sekunden %}<span class="text-slate-300">({{ '%.1f' | format(quelle.dauer_sekunden) }}&nbsp;s)</span>{% endif %}
</td>
<td class="px-2 py-1.5 text-right">{{ quelle.neu }}</td>
<td class="px-2 py-1.5 text-right">{{ quelle.aktualisiert }}</td>
<td class="px-2 py-1.5 text-right">{{ quelle.gefiltert }}</td>
<td class="px-2 py-1.5">
{% if quelle.fehlermeldung %}<span class="text-red-600">{{ quelle.fehlermeldung }}</span>
{% elif quelle.unveraendert %}<span class="text-slate-400">unverändert (304) — übersprungen</span>
{% elif not quelle.aktiv %}<span class="text-slate-300">abgeschaltet</span>
{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="mt-2 text-[11px] text-slate-400">
Quellen-Sync:
{% if quellen_sync_aktiv %}<span class="text-emerald-700 font-medium">täglich aktiv</span>{% else %}<span class="text-slate-500">ausgeschaltet</span>{% endif %}
&middot; max. 1 Anfrage/Sekunde je Quelle &middot; Duplikate gegen BGG werden per Titel-Match gefiltert
</p>
</div>
<form method="get" action="/neuheiten" <form method="get" action="/neuheiten"
hx-get="/neuheiten" hx-target="#neuheiten-liste" hx-swap="outerHTML" hx-get="/neuheiten" hx-target="#neuheiten-liste" hx-swap="outerHTML"
hx-trigger="input changed delay:300ms from:find input[name='q'], change from:find select[name='status']" hx-trigger="input changed delay:300ms from:find input[name='q'], change from:find select[name='status'], change from:find select[name='jahr']"
class="flex flex-wrap gap-3 mb-4"> class="grid grid-cols-1 sm:flex sm:flex-wrap gap-3 mb-4">
<input type="text" name="q" value="{{ q }}" placeholder="Suche in Titel, Verlag, Autor …" <input type="text" name="q" value="{{ q }}" placeholder="Suche in Titel, Verlag, Autor …"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-80 focus:outline-none focus:ring-2 focus:ring-emerald-500"> class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 sm:w-80">
<select name="status" class="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"> <select name="status" class="w-full sm:w-auto min-h-[44px] md:min-h-0 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white">
<option value="">Alle Status</option> <option value="">Alle Status</option>
{% for option in status_optionen %} {% for option in status_optionen %}
<option value="{{ option }}" {% if option == status_filter %}selected{% endif %}>{{ option }}</option> <option value="{{ option }}" {% if option == status_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %} {% endfor %}
</select> </select>
<select name="jahr" class="w-full sm:w-auto min-h-[44px] md:min-h-0 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white">
<option value="{{ jahr_standard }}">ab {{ jahr_standard }}</option>
{% for jahr in jahr_optionen %}
<option value="{{ jahr }}" {% if jahr|string == jahr_filter|string %}selected{% endif %}>{{ jahr }}</option>
{% endfor %}
<option value="" {% if not jahr_filter %}selected{% endif %}>Alle Jahre</option>
</select>
<noscript> <noscript>
<button type="submit" class="bg-slate-200 hover:bg-slate-300 px-4 py-2 rounded-lg text-sm">Filtern</button> <button type="submit" class="bg-slate-200 hover:bg-slate-300 px-4 py-2 rounded-lg text-sm min-h-[44px] md:min-h-0">Filtern</button>
</noscript> </noscript>
</form> </form>

View File

@@ -28,7 +28,7 @@ from urllib.parse import quote
from fastapi import Depends, Form, Request from fastapi import Depends, Form, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from sqlalchemy import select, update from sqlalchemy import func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from redaktionskern.auth.deps import AccessDenied, get_db, require_user from redaktionskern.auth.deps import AccessDenied, get_db, require_user
@@ -83,7 +83,53 @@ class PlanungPlugin(BasePlugin):
# ---------- Plugin-Vertrag ---------- # ---------- Plugin-Vertrag ----------
def migrations(self) -> list[Migration]: def migrations(self) -> list[Migration]:
return [Migration(version="0001_planungsliste", up=_planungs_tabelle)] def bild_url_spalte(conn) -> None:
"""Migration 0002: Spalte bild_url für Coverbilder (nullable).
Portabel (SQLite/Postgres): `ALTER TABLE … ADD COLUMN` mit
nullable VARCHAR ohne Default; bestehende Zeilen bleiben erhalten.
"""
if conn.dialect.name == "sqlite":
info = conn.exec_driver_sql(
"PRAGMA table_info(planungsliste)"
).fetchall()
if any(zeile[1] == "bild_url" for zeile in info):
return
else:
existiert = conn.exec_driver_sql(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'planungsliste' AND column_name = 'bild_url'"
).scalar()
if existiert is not None:
return
conn.exec_driver_sql(
"ALTER TABLE planungsliste ADD COLUMN bild_url VARCHAR(500)"
)
def quellen_url_spalte(conn) -> None:
"""Migration 0003: Spalte quellen_url (Link zur Herkunftsquelle)."""
if conn.dialect.name == "sqlite":
info = conn.exec_driver_sql(
"PRAGMA table_info(planungsliste)"
).fetchall()
if any(zeile[1] == "quellen_url" for zeile in info):
return
else:
existiert = conn.exec_driver_sql(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'planungsliste' AND column_name = 'quellen_url'"
).scalar()
if existiert is not None:
return
conn.exec_driver_sql(
"ALTER TABLE planungsliste ADD COLUMN quellen_url VARCHAR(500)"
)
return [
Migration(version="0001_planungsliste", up=_planungs_tabelle),
Migration(version="0002_bild_url", up=bild_url_spalte),
Migration(version="0003_quellen_url", up=quellen_url_spalte),
]
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/planung")] return [NavEntry(label=self.title, url="/planung")]
@@ -161,6 +207,7 @@ class PlanungPlugin(BasePlugin):
aktion: str, aktion: str,
versteckt: dict[str, str], versteckt: dict[str, str],
abbrechen_url: str = "/planung", abbrechen_url: str = "/planung",
bild_url: str | None = None,
): ):
"""Auswahl-Dialog bei Verlags-Konflikt: Welcher Verlag wird geführt?""" """Auswahl-Dialog bei Verlags-Konflikt: Welcher Verlag wird geführt?"""
return self.context.templates.TemplateResponse( return self.context.templates.TemplateResponse(
@@ -170,6 +217,7 @@ class PlanungPlugin(BasePlugin):
"user": user, "user": user,
"titel_headline": f"Dedup-Prüfung — {titel}", "titel_headline": f"Dedup-Prüfung — {titel}",
"spiel_titel": titel, "spiel_titel": titel,
"bild_url": bild_url,
"konflikte": ergebnis.konflikte, "konflikte": ergebnis.konflikte,
"konflikt_anzeige": KONFLIKT_ANZEIGE, "konflikt_anzeige": KONFLIKT_ANZEIGE,
"titel_empfehlung": ergebnis.titel_empfehlung, "titel_empfehlung": ergebnis.titel_empfehlung,
@@ -192,15 +240,22 @@ class PlanungPlugin(BasePlugin):
status: str = "", status: str = "",
meldung: str = "", meldung: str = "",
fehler: str = "", fehler: str = "",
seite_nr: int = 1,
): ):
"""Planungsliste: alle Einträge mit Zuordnung und Statuswechsel.""" """Planungsliste: Einträge paginiert (50/Seite) mit Zuordnung."""
pro_seite = 50
abfrage = select(Planungseintrag) abfrage = select(Planungseintrag)
if status in GUELTIGE_STATUS: if status in GUELTIGE_STATUS:
abfrage = abfrage.where(Planungseintrag.status == status) abfrage = abfrage.where(Planungseintrag.status == status)
gesamt = db.scalar(
select(func.count()).select_from(abfrage.subquery())
) or 0
eintraege = db.scalars( eintraege = db.scalars(
abfrage.order_by( abfrage.order_by(
Planungseintrag.aktualisiert_am.desc(), Planungseintrag.id.desc() Planungseintrag.aktualisiert_am.desc(), Planungseintrag.id.desc()
) )
.offset((max(seite_nr, 1) - 1) * pro_seite)
.limit(pro_seite)
).all() ).all()
namen = { namen = {
benutzer.id: (benutzer.display_name or benutzer.username) benutzer.id: (benutzer.display_name or benutzer.username)
@@ -221,7 +276,9 @@ class PlanungPlugin(BasePlugin):
"ist_redaktion": user.role in REDAKTION, "ist_redaktion": user.role in REDAKTION,
"meldung": meldung[:300], "meldung": meldung[:300],
"fehler": fehler[:300], "fehler": fehler[:300],
"anzahl": len(eintraege), "anzahl": gesamt,
"hat_weitere": max(seite_nr, 1) * pro_seite < gesamt,
"naechste_seite": max(seite_nr, 1) + 1,
}, },
) )
@@ -354,6 +411,7 @@ class PlanungPlugin(BasePlugin):
aktion=f"/planung/uebernehmen/{neuheit_id}", aktion=f"/planung/uebernehmen/{neuheit_id}",
versteckt={"neuheit_id": str(neuheit_id), "verlag_bestaetigt": "1"}, versteckt={"neuheit_id": str(neuheit_id), "verlag_bestaetigt": "1"},
abbrechen_url="/neuheiten", abbrechen_url="/neuheiten",
bild_url=zeile.bild_url,
) )
gewaehlter_verlag = (verlag_wahl.strip() or (zeile.verlag or "")) or None gewaehlter_verlag = (verlag_wahl.strip() or (zeile.verlag or "")) or None
@@ -362,6 +420,8 @@ class PlanungPlugin(BasePlugin):
verlag=gewaehlter_verlag, verlag=gewaehlter_verlag,
autor=zeile.autor, autor=zeile.autor,
bgg_id=zeile.bgg_id, bgg_id=zeile.bgg_id,
bild_url=zeile.bild_url,
quellen_url=zeile.quellen_url,
ausgabe="", ausgabe="",
rezensent_id=user.id, rezensent_id=user.id,
status="offen", status="offen",

View File

@@ -48,6 +48,9 @@ class Planungseintrag(Base):
autor: Mapped[str | None] = mapped_column(String(300)) autor: Mapped[str | None] = mapped_column(String(300))
#: BoardGameGeek-ID, sofern bekannt (Match-Kriterium der dedup-Prüfung). #: BoardGameGeek-ID, sofern bekannt (Match-Kriterium der dedup-Prüfung).
bgg_id: Mapped[int | None] = mapped_column(Integer) bgg_id: Mapped[int | None] = mapped_column(Integer)
#: URL des Coverbildes (aus der Neuheitenliste übernommen oder leer).
bild_url: Mapped[str | None] = mapped_column(String(500))
quellen_url: Mapped[str | None] = mapped_column(String(500))
#: Magazin-Ausgabe, z. B. „3/2025“ (mehrere Ausgaben parallel möglich). #: Magazin-Ausgabe, z. B. „3/2025“ (mehrere Ausgaben parallel möglich).
ausgabe: Mapped[str] = mapped_column(String(100), default="") ausgabe: Mapped[str] = mapped_column(String(100), default="")
rezensent_id: Mapped[int] = mapped_column(ForeignKey("users.id")) rezensent_id: Mapped[int] = mapped_column(ForeignKey("users.id"))

View File

@@ -0,0 +1,24 @@
{# Coverbild-Vorschau des Planung-Plugins.
Erwartete Variablen: cover_bild (URL oder None), cover_titel,
cover_gross (optional: true für großformatige Ansichten).
Bilder werden direkt von der Quelle eingebettet (Hotlinking, kein
Download) — mit loading="lazy" und decoding="async". Ohne Bild zeigt
das Partial einen dezenten Platzhalter mit Spielicon-Zeichen. #}
{% set _gross = cover_gross | default(false) %}
{% if _gross %}
{% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async"
class="block h-auto w-auto max-w-[200px] max-h-[280px] rounded-lg border border-slate-200 bg-white shadow-sm">
{% else %}
<div aria-hidden="true" title="Kein Cover vorhanden"
class="flex h-[210px] w-[150px] max-w-[200px] items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 text-6xl text-slate-300 select-none">&#127922;</div>
{% endif %}
{% else %}
{% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" width="40" height="56"
class="block h-14 w-10 shrink-0 rounded border border-slate-200 bg-slate-50 object-cover">
{% else %}
<div aria-hidden="true" title="Kein Cover vorhanden"
class="flex h-14 w-10 shrink-0 items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 text-lg text-slate-400 select-none">&#127922;</div>
{% endif %}
{% endif %}

View File

@@ -6,8 +6,16 @@
{% if user.id == eintrag.rezensent_id %}du{% else %}{{ user.display_name }}{% endif %} {% if user.id == eintrag.rezensent_id %}du{% else %}{{ user.display_name }}{% endif %}
</p> </p>
<div class="flex flex-col sm:flex-row gap-5 items-start mb-4">
<div class="shrink-0 mx-auto sm:mx-0">
{% set cover_bild = eintrag.bild_url %}
{% set cover_titel = eintrag.titel %}
{% set cover_gross = true %}
{% include "planung/_cover.html" %}
</div>
<form method="post" action="/planung/{{ eintrag.id }}/bearbeiten" <form method="post" action="/planung/{{ eintrag.id }}/bearbeiten"
class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4"> class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4 w-full">
<div> <div>
<label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label> <label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label>
<input type="text" id="titel" name="titel" required value="{{ eintrag.titel }}" <input type="text" id="titel" name="titel" required value="{{ eintrag.titel }}"
@@ -38,4 +46,5 @@
<a href="{{ abbrechen_url }}" class="text-sm text-slate-500 hover:text-slate-800 underline">Abbrechen</a> <a href="{{ abbrechen_url }}" class="text-sm text-slate-500 hover:text-slate-800 underline">Abbrechen</a>
</div> </div>
</form> </form>
</div>
{% endblock %} {% endblock %}

View File

@@ -17,29 +17,30 @@
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6"> <div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<h2 class="font-semibold mb-3">Titel h&auml;ndisch nachtragen</h2> <h2 class="font-semibold mb-3">Titel h&auml;ndisch nachtragen</h2>
<form method="post" action="/planung/neu" class="flex flex-wrap items-end gap-3"> <!-- Mobil: Felder untereinander vollbreit; ab sm: flexibel nebeneinander -->
<div> <form method="post" action="/planung/neu" class="grid grid-cols-1 gap-3 sm:flex sm:flex-wrap sm:items-end">
<div class="sm:w-64 lg:w-72">
<label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label> <label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label>
<input type="text" id="titel" name="titel" required <input type="text" id="titel" name="titel" required
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"> class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<div> <div class="sm:w-48 lg:w-52">
<label for="verlag" class="block text-xs font-medium text-slate-500 mb-1">Verlag</label> <label for="verlag" class="block text-xs font-medium text-slate-500 mb-1">Verlag</label>
<input type="text" id="verlag" name="verlag" <input type="text" id="verlag" name="verlag"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-52 focus:outline-none focus:ring-2 focus:ring-emerald-500"> class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<div> <div class="sm:w-32">
<label for="ausgabe" class="block text-xs font-medium text-slate-500 mb-1">Ausgabe</label> <label for="ausgabe" class="block text-xs font-medium text-slate-500 mb-1">Ausgabe</label>
<input type="text" id="ausgabe" name="ausgabe" placeholder="z. B. 3/2025" <input type="text" id="ausgabe" name="ausgabe" placeholder="z. B. 3/2025"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-32 focus:outline-none focus:ring-2 focus:ring-emerald-500"> class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<div> <div class="sm:w-32">
<label for="bgg_id" class="block text-xs font-medium text-slate-500 mb-1">BGG-ID (optional)</label> <label for="bgg_id" class="block text-xs font-medium text-slate-500 mb-1">BGG-ID (optional)</label>
<input type="text" id="bgg_id" name="bgg_id" <input type="text" id="bgg_id" name="bgg_id" inputmode="numeric"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-32 focus:outline-none focus:ring-2 focus:ring-emerald-500"> class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<button type="submit" <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"> class="w-full sm:w-auto min-h-[44px] inline-flex items-center justify-center bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
Pr&uuml;fen &amp; eintragen Pr&uuml;fen &amp; eintragen
</button> </button>
</form> </form>
@@ -47,22 +48,99 @@
<div class="flex items-center mb-3 gap-3"> <div class="flex items-center mb-3 gap-3">
<form method="get" action="/planung" class="flex items-center gap-2"> <form method="get" action="/planung" class="flex items-center gap-2">
<select name="status" class="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white" <select name="status" class="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white min-h-[44px] md:min-h-0"
onchange="this.form.submit()"> onchange="this.form.submit()">
<option value="">Alle Status</option> <option value="">Alle Status</option>
{% for wert in status_optionen %} {% for wert in status_optionen %}
<option value="{{ wert }}" {% if wert == status_filter %}selected{% endif %}>{{ status_anzeige[wert] }}</option> <option value="{{ wert }}" {% if wert == status_filter %}selected{% endif %}>{{ status_anzeige[wert] }}</option>
{% endfor %} {% endfor %}
</select> </select>
<noscript><button type="submit" class="bg-slate-200 hover:bg-slate-300 px-3 py-2 rounded-lg text-sm">Filtern</button></noscript> <noscript><button type="submit" class="bg-slate-200 hover:bg-slate-300 px-3 py-2 rounded-lg text-sm min-h-[44px] md:min-h-0">Filtern</button></noscript>
</form> </form>
<p class="text-xs text-slate-400 ml-auto">{{ anzahl }} Eintr&auml;ge</p> <p class="text-xs text-slate-400 ml-auto">{{ anzahl }} Eintr&auml;ge</p>
</div> </div>
<div class="overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm"> <!-- Mobil (<768px): Karten-Ansicht — Cover links, Titel+Metadaten rechts, Status oben -->
<ul class="md:hidden space-y-3" role="list">
{% for eintrag in eintraege %}
<li class="bg-white rounded-xl border border-slate-200 shadow-sm p-3 flex gap-3">
<div class="pt-0.5">
{% set cover_bild = eintrag.bild_url %}
{% set cover_titel = eintrag.titel %}
{% include "planung/_cover.html" %}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<h3 class="text-sm font-medium leading-snug break-words">
{{ eintrag.titel }}
{% if eintrag.bgg_id %}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
class="ml-0.5 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
{% elif eintrag.quellen_url %}
<a href="{{ eintrag.quellen_url }}" target="_blank" rel="noopener"
title="Zur Quelle ({{ eintrag.quelle }})"
class="ml-0.5 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }}&nbsp;</a>
{% endif %}
{% if eintrag.pruefung %}
<span class="ml-0.5 inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs align-middle"
title="Die dedup-Prüfung hatte beim Eintragen Treffer.">&#9888; Pr&uuml;fhinweis</span>
{% endif %}
</h3>
{% if eintrag.status == 'offen' %}
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-slate-200 text-slate-700 text-xs">{{ status_anzeige[eintrag.status] }}</span>
{% elif eintrag.status == 'in_bearbeitung' %}
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-sky-100 text-sky-800 text-xs">{{ status_anzeige[eintrag.status] }}</span>
{% else %}
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 text-xs">{{ status_anzeige[eintrag.status] }}</span>
{% endif %}
</div>
<dl class="mt-1.5 space-y-0.5 text-xs text-slate-500">
{% if eintrag.verlag %}<div><dt class="inline font-medium text-slate-400">Verlag: </dt><dd class="inline">{{ eintrag.verlag }}</dd></div>{% endif %}
{% if eintrag.ausgabe %}<div><dt class="inline font-medium text-slate-400">Ausgabe: </dt><dd class="inline">{{ eintrag.ausgabe }}</dd></div>{% endif %}
<div><dt class="inline font-medium text-slate-400">Rezensent: </dt><dd class="inline">{{ namen.get(eintrag.rezensent_id, '—') }}</dd></div>
<div>
<dt class="inline font-medium text-slate-400">Aktualisiert: </dt>
<dd class="inline">{{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }}</dd>
</div>
</dl>
{% if kann_bearbeiten(eintrag) %}
<div class="mt-2 grid grid-cols-1 gap-2">
{% if eintrag.status == 'offen' %}
<form method="post" action="/planung/{{ eintrag.id }}/status">
<input type="hidden" name="status" value="in_bearbeitung">
<button type="submit" class="w-full min-h-[44px] inline-flex items-center justify-center bg-sky-600 hover:bg-sky-700 active:bg-sky-800 text-white px-3 py-2 rounded-lg text-sm whitespace-nowrap">In Bearbeitung</button>
</form>
{% endif %}
{% if eintrag.status != 'abgeschlossen' %}
<form method="post" action="/planung/{{ eintrag.id }}/status">
<input type="hidden" name="status" value="abgeschlossen">
<button type="submit" class="w-full min-h-[44px] inline-flex items-center justify-center bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-3 py-2 rounded-lg text-sm whitespace-nowrap">Abgeschlossen</button>
</form>
{% endif %}
<a href="/planung/{{ eintrag.id }}/bearbeiten"
class="min-h-[44px] inline-flex items-center justify-center border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-3 py-2 rounded-lg text-sm whitespace-nowrap">Bearbeiten</a>
<form method="post" action="/planung/{{ eintrag.id }}/loeschen">
<button type="submit" class="w-full min-h-[44px] inline-flex items-center justify-center border border-red-200 text-red-600 hover:bg-red-50 px-3 py-2 rounded-lg text-sm whitespace-nowrap">L&ouml;schen</button>
</form>
</div>
{% else %}
<p class="mt-2 text-xs text-slate-400">nur {{ namen.get(eintrag.rezensent_id, '') }}</p>
{% endif %}
</div>
</li>
{% else %}
<li class="bg-white rounded-xl border border-slate-200 shadow-sm px-4 py-8 text-center text-sm text-slate-400">
Noch keine Eintr&auml;ge — schiebe einen Titel aus den Neuheiten hierher oder trage ihn oben h&auml;ndisch nach.
</li>
{% endfor %}
</ul>
<!-- Desktop/Tablet (&ge;768px): Tabellenform -->
<div class="hidden md:block overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="bg-slate-50 text-left text-slate-500">
<tr> <tr>
<th class="px-4 py-3 font-medium">Cover</th>
<th class="px-4 py-3 font-medium">Spieltitel</th> <th class="px-4 py-3 font-medium">Spieltitel</th>
<th class="px-4 py-3 font-medium">Verlag</th> <th class="px-4 py-3 font-medium">Verlag</th>
<th class="px-4 py-3 font-medium">Ausgabe</th> <th class="px-4 py-3 font-medium">Ausgabe</th>
@@ -75,11 +153,20 @@
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
{% for eintrag in eintraege %} {% for eintrag in eintraege %}
<tr class="hover:bg-slate-50"> <tr class="hover:bg-slate-50">
<td class="px-4 py-3">
{% set cover_bild = eintrag.bild_url %}
{% set cover_titel = eintrag.titel %}
{% include "planung/_cover.html" %}
</td>
<td class="px-4 py-3 font-medium"> <td class="px-4 py-3 font-medium">
{{ eintrag.titel }} {{ eintrag.titel }}
{% if eintrag.bgg_id %} {% if eintrag.bgg_id %}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener" <a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a> class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
{% elif eintrag.quellen_url %}
<a href="{{ eintrag.quellen_url }}" target="_blank" rel="noopener"
title="Zur Quelle ({{ eintrag.quelle }})"
class="ml-1 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }}&nbsp;</a>
{% endif %} {% endif %}
{% if eintrag.pruefung %} {% if eintrag.pruefung %}
<span class="ml-1 inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs" <span class="ml-1 inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs"
@@ -107,19 +194,19 @@
{% if eintrag.status == 'offen' %} {% if eintrag.status == 'offen' %}
<form method="post" action="/planung/{{ eintrag.id }}/status"> <form method="post" action="/planung/{{ eintrag.id }}/status">
<input type="hidden" name="status" value="in_bearbeitung"> <input type="hidden" name="status" value="in_bearbeitung">
<button type="submit" class="text-xs bg-sky-600 hover:bg-sky-700 text-white px-2 py-1 rounded-lg whitespace-nowrap">In Bearbeitung</button> <button type="submit" class="text-xs bg-sky-600 hover:bg-sky-700 text-white px-2.5 py-2 rounded-lg whitespace-nowrap">In Bearbeitung</button>
</form> </form>
{% endif %} {% endif %}
{% if eintrag.status != 'abgeschlossen' %} {% if eintrag.status != 'abgeschlossen' %}
<form method="post" action="/planung/{{ eintrag.id }}/status"> <form method="post" action="/planung/{{ eintrag.id }}/status">
<input type="hidden" name="status" value="abgeschlossen"> <input type="hidden" name="status" value="abgeschlossen">
<button type="submit" class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2 py-1 rounded-lg whitespace-nowrap">Abgeschlossen</button> <button type="submit" class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2.5 py-2 rounded-lg whitespace-nowrap">Abgeschlossen</button>
</form> </form>
{% endif %} {% endif %}
<a href="/planung/{{ eintrag.id }}/bearbeiten" <a href="/planung/{{ eintrag.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> class="text-xs border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-2.5 py-2 rounded-lg whitespace-nowrap">Bearbeiten</a>
<form method="post" action="/planung/{{ eintrag.id }}/loeschen"> <form method="post" action="/planung/{{ eintrag.id }}/loeschen">
<button type="submit" class="text-xs border border-red-200 text-red-600 hover:bg-red-50 px-2 py-1 rounded-lg whitespace-nowrap">L&ouml;schen</button> <button type="submit" class="text-xs border border-red-200 text-red-600 hover:bg-red-50 px-2.5 py-2 rounded-lg whitespace-nowrap">L&ouml;schen</button>
</form> </form>
</div> </div>
{% else %} {% else %}
@@ -129,7 +216,7 @@
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="7" class="px-4 py-8 text-center text-slate-400"> <td colspan="8" class="px-4 py-8 text-center text-slate-400">
Noch keine Eintr&auml;ge — schiebe einen Titel aus den Neuheiten hierher oder trage ihn oben h&auml;ndisch nach. Noch keine Eintr&auml;ge — schiebe einen Titel aus den Neuheiten hierher oder trage ihn oben h&auml;ndisch nach.
</td> </td>
</tr> </tr>
@@ -137,4 +224,11 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% if hat_weitere %}
<div id="planung-lade-mehr"
hx-get="/planung?seite_nr={{ naechste_seite }}&status={{ status_filter }}"
hx-trigger="revealed delay:150ms" hx-swap="outerHTML">
<div class="py-6 text-center text-sm text-slate-400">Lade weitere Eintr&auml;ge&nbsp;&hellip;</div>
</div>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -7,7 +7,14 @@
der Eintrag angelegt wird. der Eintrag angelegt wird.
</p> </p>
<div class="bg-amber-50 border border-amber-300 rounded-xl p-4 mb-6 text-sm"> <div class="flex flex-col sm:flex-row gap-4 items-start">
<div class="shrink-0 mx-auto sm:mx-0">
{% set cover_bild = bild_url %}
{% set cover_titel = spiel_titel %}
{% set cover_gross = true %}
{% include "planung/_cover.html" %}
</div>
<div class="bg-amber-50 border border-amber-300 rounded-xl p-4 mb-0 sm:mb-6 text-sm w-full">
<p class="font-medium text-amber-800 mb-2">&#9888; {{ konflikte|length }} Treffer f&uuml;r „{{ spiel_titel }}“</p> <p class="font-medium text-amber-800 mb-2">&#9888; {{ konflikte|length }} Treffer f&uuml;r „{{ spiel_titel }}“</p>
<ul class="list-disc list-inside space-y-1 text-slate-700"> <ul class="list-disc list-inside space-y-1 text-slate-700">
{% for konflikt in konflikte %} {% for konflikt in konflikte %}
@@ -27,6 +34,7 @@
<p class="mt-2 text-slate-600">Empfohlener Titel: <span class="font-medium">{{ titel_empfehlung }}</span></p> <p class="mt-2 text-slate-600">Empfohlener Titel: <span class="font-medium">{{ titel_empfehlung }}</span></p>
{% endif %} {% endif %}
</div> </div>
</div>
<form method="post" action="{{ aktion }}" class="bg-white border border-slate-200 rounded-xl shadow-sm p-4"> <form method="post" action="{{ aktion }}" class="bg-white border border-slate-200 rounded-xl shadow-sm p-4">
{% for name, wert in versteckt.items() %} {% for name, wert in versteckt.items() %}

View File

@@ -2,6 +2,7 @@
name = "spiele-redaktion" name = "spiele-redaktion"
version = "0.1.0" version = "0.1.0"
description = "KI-Assistenz für Spielemagazin-Redaktionen — modularer Kern mit Plugin-Architektur" description = "KI-Assistenz für Spielemagazin-Redaktionen — modularer Kern mit Plugin-Architektur"
license = "MIT"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -14,13 +15,16 @@ dependencies = [
"argon2-cffi>=23.1", "argon2-cffi>=23.1",
"apscheduler>=3.11,<4", "apscheduler>=3.11,<4",
"httpx>=0.27", "httpx>=0.27",
"beautifulsoup4>=4.12",
"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]

View File

@@ -16,6 +16,7 @@ from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -82,6 +83,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:
@@ -108,6 +110,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
redoc_url=None, redoc_url=None,
openapi_url=None, openapi_url=None,
) )
# Statische Kern-Dateien (z. B. „Powered by BGG“-Logo, Pflicht für
# öffentliche BGG-XML-API-Anwendungen).
statisch = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=statisch), name="statisch")
app.state.settings = settings app.state.settings = settings
app.state.engine = engine app.state.engine = engine
app.state.session_factory = session_factory app.state.session_factory = session_factory

View File

@@ -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:

View File

@@ -11,6 +11,7 @@ from __future__ import annotations
import importlib.metadata import importlib.metadata
import importlib.util import importlib.util
import sys import sys
import types
from importlib.metadata import entry_points from importlib.metadata import entry_points
from pathlib import Path from pathlib import Path
@@ -81,6 +82,19 @@ def _aus_verzeichnis(verzeichnis: Path) -> list[BasePlugin]:
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
raise PluginError(f"Plugin '{eintrag.name}' kann nicht geladen werden.") raise PluginError(f"Plugin '{eintrag.name}' kann nicht geladen werden.")
modul = importlib.util.module_from_spec(spec) modul = importlib.util.module_from_spec(spec)
# Eltern-Paketnamen registrieren, damit relative Imports aus
# Plugin-Unterpackages (z. B. `from .basis import …` in
# neuheiten/quellen/) aufgelöst werden können.
eltern = modulname.rpartition(".")[0]
if eltern and eltern not in sys.modules:
# Das Eltern-Paket (Namensraum `spiele_redaktion_plugins`) hat
# kein __init__.py — spec_from_file_location liefert dafür None.
# Ein leeres Namespace-Modul mit __path__ genügt, damit relative
# Imports aus Plugin-Unterpackages (z. B. neuheiten/quellen/)
# aufgelöst werden können.
eltern_modul = types.ModuleType(eltern)
eltern_modul.__path__ = [str(eintrag.parent)]
sys.modules[eltern] = eltern_modul
sys.modules[modulname] = modul sys.modules[modulname] = modul
spec.loader.exec_module(modul) spec.loader.exec_module(modul)
if not hasattr(modul, "plugin"): if not hasattr(modul, "plugin"):

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -9,16 +9,32 @@
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
</head> </head>
<body class="bg-slate-100 text-slate-800 min-h-screen flex flex-col"> <body class="bg-slate-100 text-slate-800 min-h-screen flex flex-col">
<header class="bg-white border-b border-slate-200"> <header class="bg-white border-b border-slate-200" x-data="{ menue: false }">
<nav class="max-w-6xl mx-auto px-4 py-3 flex items-center gap-5" aria-label="Hauptnavigation"> <nav class="max-w-6xl mx-auto px-4 py-2 md:py-3 flex items-center gap-5" aria-label="Hauptnavigation">
<a href="/" class="font-bold text-emerald-700 whitespace-nowrap">&#127923; {{ app_name }}</a> <a href="/" class="font-bold text-emerald-700 whitespace-nowrap">&#127923; {{ app_name }}</a>
{% for eintrag in plugin_nav %} <!-- Desktop: horizontale Navigationsleiste -->
<a href="{{ eintrag.url }}" class="text-sm text-slate-600 hover:text-emerald-700">{{ eintrag.label }}</a> <span class="hidden md:flex items-center gap-5 flex-1">
{% endfor %} {% for eintrag in plugin_nav %}
<div class="ml-auto relative" x-data="{ offen: false }"> <a href="{{ eintrag.url }}" class="text-sm text-slate-600 hover:text-emerald-700">{{ eintrag.label }}</a>
{% endfor %}
</span>
<!-- Mobil: Burger-Button (Alpine.js), Touch-Ziel &ge;&ge; 44px -->
<button type="button" @click="menue = !menue" :aria-expanded="menue.toString()"
aria-controls="hauptmenue-mobil" aria-label="Navigation umschalten"
class="md:hidden ml-auto inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-lg text-slate-600 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500">
<svg x-show="!menue" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
<svg x-show="menue" x-cloak xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<div class="relative" x-data="{ offen: false }">
{% if user %} {% if user %}
<button type="button" @click="offen = !offen" <button type="button" @click="offen = !offen"
class="text-sm font-medium px-2 py-1 rounded hover:bg-slate-100"> class="text-sm font-medium px-2 py-1 rounded hover:bg-slate-100 inline-flex items-center min-h-[44px] md:min-h-0">
{{ user.display_name }} &#9662; {{ user.display_name }} &#9662;
</button> </button>
<div x-show="offen" x-cloak @click.outside="offen = false" <div x-show="offen" x-cloak @click.outside="offen = false"
@@ -33,16 +49,36 @@
</form> </form>
</div> </div>
{% else %} {% else %}
<a href="/login" class="text-sm font-medium text-emerald-700">Anmelden</a> <a href="/login" class="text-sm font-medium text-emerald-700 inline-flex items-center min-h-[44px] md:min-h-0 px-2">Anmelden</a>
{% endif %} {% endif %}
</div> </div>
</nav> </nav>
<!-- Mobil: ausklappbare Navigation (Burger-Menü) -->
<div id="hauptmenue-mobil" x-show="menue" x-cloak @click.outside="menue = false"
class="md:hidden border-t border-slate-200 bg-white px-4 pb-3 pt-2">
{% for eintrag in plugin_nav %}
<a href="{{ eintrag.url }}" @click="menue = false"
class="block py-3 text-sm font-medium text-slate-700 hover:text-emerald-700 border-b border-slate-100 last:border-0">
{{ eintrag.label }}
</a>
{% endfor %}
{% if not user %}
<a href="/login" @click="menue = false"
class="block py-3 text-sm font-medium text-emerald-700 border-t border-slate-100 mt-1">Anmelden</a>
{% endif %}
</div>
</header> </header>
<main class="flex-1 max-w-6xl w-full mx-auto px-4 py-6"> <main class="flex-1 max-w-6xl w-full mx-auto px-4 py-6">
{% block inhalt %}{% endblock %} {% block inhalt %}{% endblock %}
</main> </main>
<footer class="text-center text-xs text-slate-400 py-4"> <footer class="text-center text-xs text-slate-400 py-4">
{{ app_name }} &middot; Plugin-Anzahl: {{ geladene_plugins|length }} {{ app_name }} &middot; Plugin-Anzahl: {{ geladene_plugins|length }}
<div class="mt-2">
<a href="https://boardgamegeek.com" target="_blank" rel="noopener noreferrer">
<img src="/static/powered-by-bgg.png" alt="Powered by BoardGameGeek"
width="120" height="35" class="inline-block opacity-80 hover:opacity-100">
</a>
</div>
</footer> </footer>
</body> </body>
</html> </html>

View File

@@ -33,19 +33,27 @@
<div> <div>
<label for="role" class="block text-sm font-medium mb-1">Rolle</label> <label for="role" class="block text-sm font-medium mb-1">Rolle</label>
{% if ziel and ziel.id == user.id %}
<select id="role" name="role" aria-disabled="true" title="Eigene Rolle kann nicht ge&auml;ndert werden"
class="w-full border border-slate-300 rounded-lg px-3 py-2 bg-white opacity-60 cursor-not-allowed">
<option value="{{ form.role }}" selected>{{ rollen_anzeige.get(form.role, form.role) }}</option>
</select>
<input type="hidden" name="role" value="{{ form.role }}">
{% else %}
<select id="role" name="role" <select id="role" name="role"
{% if ziel and ziel.id == user.id %}disabled title="Eigene Rolle kann nicht ge&auml;ndert werden"{% endif %}
class="w-full border border-slate-300 rounded-lg px-3 py-2 bg-white"> class="w-full border border-slate-300 rounded-lg px-3 py-2 bg-white">
{% for rollen_wert, rollen_text in rollen_anzeige.items() %} {% for rollen_wert, rollen_text in rollen_anzeige.items() %}
<option value="{{ rollen_wert }}" {% if form.role == rollen_wert %}selected{% endif %}>{{ rollen_text }}</option> <option value="{{ rollen_wert }}" {% if form.role == rollen_wert %}selected{% endif %}>{{ rollen_text }}</option>
{% endfor %} {% endfor %}
</select> </select>
{% endif %}
</div> </div>
{% if aktion == 'bearbeiten' %} {% if aktion == 'bearbeiten' %}
<label class="flex items-center gap-2 text-sm"> <label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="active" {% if ziel.active %}checked{% endif %} <input type="checkbox" name="active" {% if ziel.active %}checked{% endif %}
{% if ziel and ziel.id == user.id %}disabled{% endif %}> {% if ziel and ziel.id == user.id %}aria-disabled="true" title="Eigener Status kann nicht ge&auml;ndert werden" class="opacity-60 cursor-not-allowed" onclick="return false;"{% endif %}>
{% if ziel and ziel.id == user.id %}<input type="hidden" name="active" value="on">{% endif %}
Aktiv Aktiv
</label> </label>
{% endif %} {% endif %}
@@ -58,13 +66,13 @@
class="w-full border border-slate-300 rounded-lg px-3 py-2"> class="w-full border border-slate-300 rounded-lg px-3 py-2">
</div> </div>
<div class="flex gap-3 pt-2"> <div class="flex flex-col sm:flex-row gap-3 pt-2">
<button type="submit" <button type="submit"
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-lg px-4 py-2"> class="min-h-[44px] inline-flex items-center justify-center bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium rounded-lg px-4 py-2">
Speichern Speichern
</button> </button>
<a href="/admin/benutzer" <a href="/admin/benutzer"
class="border border-slate-300 rounded-lg px-4 py-2 text-sm hover:bg-slate-50">Abbrechen</a> class="min-h-[44px] inline-flex items-center justify-center border border-slate-300 rounded-lg px-4 py-2 text-sm hover:bg-slate-50">Abbrechen</a>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -4,7 +4,7 @@
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold">Benutzerverwaltung</h1> <h1 class="text-2xl font-bold">Benutzerverwaltung</h1>
<a href="/admin/benutzer/neu" <a href="/admin/benutzer/neu"
class="bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg px-3 py-2 text-sm font-medium"> class="bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white rounded-lg px-3 py-2 text-sm font-medium min-h-[44px] inline-flex items-center">
+ Neuer Benutzer + Neuer Benutzer
</a> </a>
</div> </div>

View File

@@ -27,10 +27,16 @@
class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500"> class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<button type="submit" <button type="submit"
class="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-lg px-3 py-2"> class="w-full min-h-[44px] bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium rounded-lg px-3 py-2">
Anmelden Anmelden
</button> </button>
</form> </form>
</div> </div>
<div class="mt-6 text-center">
<a href="https://boardgamegeek.com" target="_blank" rel="noopener noreferrer">
<img src="/static/powered-by-bgg.png" alt="Powered by BoardGameGeek"
width="120" height="35" class="inline-block opacity-80 hover:opacity-100">
</a>
</div>
</body> </body>
</html> </html>

View File

@@ -28,6 +28,8 @@ models = _neuheiten.models
BggClient = bgg.BggClient BggClient = bgg.BggClient
BggFehler = bgg.BggFehler BggFehler = bgg.BggFehler
BggAuthFehler = bgg.BggAuthFehler
MELDUNG_401 = bgg.MELDUNG_401
SuchTreffer = bgg.SuchTreffer SuchTreffer = bgg.SuchTreffer
BggSpiel = bgg.BggSpiel BggSpiel = bgg.BggSpiel
parse_suche = bgg.parse_suche parse_suche = bgg.parse_suche
@@ -40,3 +42,48 @@ speichere_oder_aktualisiere = sync_modul.speichere_oder_aktualisiere
Neuheit = models.Neuheit Neuheit = models.Neuheit
STATUS_NEUHEIT = models.STATUS_NEUHEIT STATUS_NEUHEIT = models.STATUS_NEUHEIT
# ---------- Web-Quellen (Adapter + Sync-Orchestrierung) ----------
quellen_basis = _neuheiten.quellen.basis
quellen_sync_modul = _neuheiten.quellen_sync
models = _neuheiten.models # neu mit QuellenStatus
USER_AGENT = quellen_basis.USER_AGENT
QuellenAdapter = quellen_basis.QuellenAdapter
QuellenFehler = quellen_basis.QuellenFehler
QuellenTreffer = quellen_basis.QuellenTreffer
SammelErgebnis = quellen_basis.SammelErgebnis
WebQuellenClient = quellen_basis.WebQuellenClient
jahr_aus_datumsangabe = quellen_basis.jahr_aus_datumsangabe
titel_aehnlichkeit = quellen_basis.titel_aehnlichkeit
titel_normalisieren = quellen_basis.titel_normalisieren
ki_hilfe_modul = _neuheiten.quellen.ki_hilfe
KiHilfe = ki_hilfe_modul.KiHilfe
KiKonfiguration = ki_hilfe_modul.KiKonfiguration
lade_ki_konfiguration = ki_hilfe_modul.lade_konfiguration
extrahiere_ki_json = ki_hilfe_modul.extrahiere_json
struktur_verdaechtig = ki_hilfe_modul.struktur_verdaechtig
treffer_ist_unsicher = ki_hilfe_modul.treffer_ist_unsicher
titel_ist_verdaechtig = ki_hilfe_modul.titel_ist_verdaechtig
validiere_url_vorschlaege = ki_hilfe_modul.validiere_url_vorschlaege
validiere_feld_antwort = ki_hilfe_modul.validiere_feld_antwort
MAX_FOLGE_URLS = ki_hilfe_modul.MAX_FOLGE_URLS
SpielboxQuelle = _neuheiten.quellen.spielbox.SpielboxQuelle
BrettspielboxQuelle = _neuheiten.quellen.brettspielbox.BrettspielboxQuelle
SpielEssenQuelle = _neuheiten.quellen.spielessen.SpielEssenQuelle
CliquenabendQuelle = _neuheiten.quellen.cliquenabend.CliquenabendQuelle
ADAPTER_KLASSEN = quellen_sync_modul.ADAPTER_KLASSEN
WebQuellenSyncService = quellen_sync_modul.WebQuellenSyncService
QuellenLaufZeile = quellen_sync_modul.QuellenLaufZeile
quelle_aktiv = quellen_sync_modul.quelle_aktiv
TITEL_FUZZY_SCHWELLE = quellen_sync_modul.TITEL_FUZZY_SCHWELLE
QuellenStatus = models.QuellenStatus
def sys_modules_plugin():
"""Die geladene NeuheitenPlugin-Instanz (für Migrationen im Test)."""
return sys.modules["spiele_redaktion_plugins.neuheiten"].plugin

View File

@@ -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,8 +23,16 @@ 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")
# Web-Quellen-Sync (spielbox/brettspielbox/…) ebenfalls: keine Threads.
monkeypatch.setenv("SPIELE_NEUHEITEN_QUELLEN_SYNC_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")
# Die LLM-Zweitprüfung des dedup-Plugins bleibt in Tests aus; einzelne
# Tests schalten sie explizit per SPIELE_DEDUP_LLM_AKTIV=1 ein.
monkeypatch.setenv("SPIELE_DEDUP_LLM_AKTIV", "0")
@pytest.fixture @pytest.fixture
@@ -66,6 +75,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
View File

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

482
tests/test_covers.py Normal file
View File

@@ -0,0 +1,482 @@
"""Tests für die Coverbilder-Erweiterung (bild_url).
Abgedeckt:
- Migrationen: Spalte bild_url existiert in allen vier Tabellen
(neuheiten, planungsliste, archiv_neuheiten, archiv_planung);
bestehende Zeilen bleiben beim ALTER TABLE erhalten.
- BGG-Parsing: `<image>` wird bevorzugt, sonst `<thumbnail>`;
protokoll-relative URLs werden auf https normalisiert.
- Sync-Upsert: bild_url wird angelegt und beim Update mitgepflegt;
ein fehlendes Bild in der Antwort löscht kein gespeichertes.
- Web-Quellen: img im Listenelement wird übernommen und gespeichert.
- Templates: img-Tag mit loading="lazy"/decoding="async" bei Bild,
Platzhalter-Div ohne Bild; große Ansicht in Detailseiten.
Kein echter Netzwerkverkehr — XML/HTML sind gemockt.
"""
from __future__ import annotations
import sys
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from redaktionskern.db import Base
from redaktionskern.migrationen import run_migrations
from tests._neuheiten import (
BggSpiel,
Neuheit,
QuellenTreffer,
SpielboxQuelle,
WebQuellenClient,
WebQuellenSyncService,
parse_things,
)
from tests.conftest import melde_an
# ---------- Hilfsbausteine ----------
class FakeUhr:
def __init__(self) -> None:
self.zeit = 0.0
def __call__(self) -> float:
return self.zeit
@pytest.fixture
def session_factory():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
fabrik = sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
yield fabrik
engine.dispose()
def _spalten(conn, tabelle: str) -> set[str]:
info = conn.exec_driver_sql(f"PRAGMA table_info({tabelle})").fetchall()
return {zeile[1] for zeile in info}
def plugin_modul(app, name: str):
"""Das Modul des geladenen Plugins (Registry bevorzugt)."""
plugin_instanz = app.state.registry.get(name) or sys.modules.get(
f"spiele_redaktion_plugins.{name}"
)
assert plugin_instanz is not None, f"Plugin {name} nicht geladen"
return sys.modules[type(plugin_instanz).__module__]
# ---------- Migrationen ----------
def test_migrations_legen_bild_url_in_allen_tabellen_an(app):
with app.state.engine.connect() as conn:
for tabelle in ("neuheiten", "planungsliste", "archiv_neuheiten",
"archiv_planung"):
assert "bild_url" in _spalten(conn, tabelle), \
f"bild_url fehlt in {tabelle}"
def test_bild_url_migration_erhaelt_bestehende_daten(app, tmp_path):
"""ALTER-TABLE-Pfad: alte DB ohne bild_url → Migration fügt die Spalte
hinzu und erhält alle bestehenden Zeilen."""
engine = create_engine(f"sqlite:///{tmp_path / 'mig.db'}")
# Alte Migrationen (ohne die bild_url-Schritte) je Plugin ausführen.
alte_schritte = {
"neuheiten": [m for m in app.state.registry.get("neuheiten").migrations()
if not m.version.startswith("0004")],
"planung": [m for m in app.state.registry.get("planung").migrations()
if not m.version.startswith("0002")],
"archiv": [m for m in app.state.registry.get("archiv").migrations()
if not m.version.startswith("0002")],
}
with engine.begin() as conn:
for plugin_name, schritte in alte_schritte.items():
run_migrations(conn, plugin_name, schritte)
# Alten Stand simulieren: die aktuellen Modelle erzeugen die Spalte
# schon mit, daher für den Test wieder entfernen.
for tabelle in ("neuheiten", "planungsliste",
"archiv_neuheiten", "archiv_planung"):
conn.exec_driver_sql(f"ALTER TABLE {tabelle} DROP COLUMN bild_url")
assert "bild_url" not in _spalten(conn, tabelle)
# Bestehende Zeilen einfügen (NOT-NULL-Pflichtspalten gesetzt; die
# Defaults der Modelle sind Python-seitig, daher hier explizit).
with engine.begin() as conn:
conn.exec_driver_sql(
"INSERT INTO neuheiten (titel, status, quelle)"
" VALUES ('Alter BGG-Titel', 'neuheit', 'boardgamegeek')"
)
conn.exec_driver_sql(
"INSERT INTO planungsliste"
" (titel, rezensent_id, status, quelle, ausgabe)"
" VALUES ('Alter Plan', 1, 'offen', 'manuell', '')"
)
conn.exec_driver_sql(
"INSERT INTO archiv_neuheiten"
" (quell_id, titel, status, quelle, archiviert_von, grund)"
" VALUES (1, 'Alt Neuheit', 'neuheit', 'boardgamegeek', 'System', '')"
)
conn.exec_driver_sql(
"INSERT INTO archiv_planung"
" (quell_id, titel, rezensent_id, status, quelle, ausgabe,"
" archiviert_von, grund)"
" VALUES (2, 'Alt Planung', 1, 'offen', 'manuell', '', 'System', '')"
)
# Neue bild_url-Migrationen anwenden (nur diese sind noch offen).
neue_schritte = {
"neuheiten": [m for m in app.state.registry.get("neuheiten").migrations()
if m.version == "0004_bild_url"],
"planung": [m for m in app.state.registry.get("planung").migrations()
if m.version == "0002_bild_url"],
"archiv": [m for m in app.state.registry.get("archiv").migrations()
if m.version == "0002_bild_url"],
}
with engine.begin() as conn:
for plugin_name, schritte in neue_schritte.items():
run_migrations(conn, plugin_name, schritte)
# Spalte vorhanden, Daten vollständig erhalten.
with engine.connect() as conn:
for tabelle in ("neuheiten", "planungsliste",
"archiv_neuheiten", "archiv_planung"):
assert "bild_url" in _spalten(conn, tabelle)
titel = {
zeile[0]
for zeile in conn.exec_driver_sql(
"SELECT titel FROM neuheiten"
).fetchall()
}
assert titel == {"Alter BGG-Titel"}
assert conn.exec_driver_sql(
"SELECT COUNT(*) FROM planungsliste"
).scalar() == 1
assert conn.exec_driver_sql(
"SELECT COUNT(*) FROM archiv_neuheiten"
).scalar() == 1
assert conn.exec_driver_sql(
"SELECT COUNT(*) FROM archiv_planung"
).scalar() == 1
# ---------- BGG-Parsing ----------
THING_MIT_BEIDEN = """<?xml version="1.0"?>
<items termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
<item type="boardgame" id="13">
<thumbnail>https://cf.geekdo-images.com/klein_thumb.jpg</thumbnail>
<image>https://cf.geekdo-images.com/gross.png</image>
<name type="primary" value="Catan"/>
</item>
</items>
"""
THING_NUR_THUMBNAIL = """<?xml version="1.0"?>
<items termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
<item type="boardgame" id="13">
<thumbnail>https://cf.geekdo-images.com/nur_thumb.jpg</thumbnail>
<name type="primary" value="Catan"/>
</item>
</items>
"""
THING_PROTOKOLL_RELATIV = """<?xml version="1.0"?>
<items termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
<item type="boardgame" id="13">
<image>//cf.geekdo-images.com/relativ.jpg</image>
<name type="primary" value="Catan"/>
</item>
</items>
"""
THING_OHNE_BILD = """<?xml version="1.0"?>
<items termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
<item type="boardgame" id="13">
<name type="primary" value="Catan"/>
</item>
</items>
"""
def test_parse_things_bevorzugt_grosses_image():
spiele = parse_things(THING_MIT_BEIDEN)
assert len(spiele) == 1
assert spiele[0].bild_url == "https://cf.geekdo-images.com/gross.png"
def test_parse_things_faellt_auf_thumbnail_zurueck():
spiele = parse_things(THING_NUR_THUMBNAIL)
assert spiele[0].bild_url == "https://cf.geekdo-images.com/nur_thumb.jpg"
def test_parse_things_normalisiert_protokoll_relative_urls():
spiele = parse_things(THING_PROTOKOLL_RELATIV)
assert spiele[0].bild_url == "https://cf.geekdo-images.com/relativ.jpg"
def test_parse_things_ohne_bild_liefert_none():
spiele = parse_things(THING_OHNE_BILD)
assert spiele[0].bild_url is None
# ---------- Sync-Upsert pflegt bild_url ----------
def test_upsert_legt_und_pflegt_bild_url(session_factory):
from tests._neuheiten import speichere_oder_aktualisiere
spiel = BggSpiel(
bgg_id=13, titel="Catan", verlag="KOSMOS", autor="Klaus Teuber",
erscheinungsjahr=1995, bild_url="https://cf.geekdo-images.com/a.jpg",
)
with session_factory() as db:
assert speichere_oder_aktualisiere(db, spiel) is True
db.commit()
# Update: neues Bild wird übernommen …
update = BggSpiel(
bgg_id=13, titel="Catan", verlag="KOSMOS", autor="Klaus Teuber",
erscheinungsjahr=1995, bild_url="https://cf.geekdo-images.com/b.jpg",
)
assert speichere_oder_aktualisiere(db, update) is False
db.commit()
eintrag = db.scalar(select(Neuheit).where(Neuheit.bgg_id == 13))
assert eintrag.bild_url == "https://cf.geekdo-images.com/b.jpg"
# … ein fehlendes Bild in der Antwort löscht das gespeicherte nicht.
ohne = BggSpiel(
bgg_id=13, titel="Catan", verlag="KOSMOS", autor="Klaus Teuber",
erscheinungsjahr=1995, bild_url=None,
)
speichere_oder_aktualisiere(db, ohne)
db.commit()
db.expire_all()
eintrag = db.scalar(select(Neuheit).where(Neuheit.bgg_id == 13))
assert eintrag.bild_url == "https://cf.geekdo-images.com/b.jpg"
# ---------- Web-Quellen: Bild aus Listenelement übernehmen ----------
SPIELBOX_MIT_BILD = b"""
<div class="blog">
<div class="item column-1">
<div class="page-header"><h2><a href="/beitrag/x">Pegasus Spiele: Next Station Berlin</a></h2></div>
<img src="/media/next-station.jpg" alt="Next Station Berlin">
<p><strong>24.07.2026</strong> - Vorstellung.</p>
</div>
<div class="item column-2">
<div class="page-header"><h2><a href="/beitrag/y">MICROMACRO Kids</a></h2></div>
<p><strong>02.03.2026</strong> - Neu.</p>
</div>
</div>
"""
def test_spielbox_adapter_uebernimmt_img_url():
quelle = SpielboxQuelle(WebQuellenClient(schlaf=lambda s: None, uhr=FakeUhr()))
treffer, folge_links = quelle.seite_verarbeiten(
SPIELBOX_MIT_BILD, "https://www.spielbox.de/neuheiten-neue-spiele"
)
assert [t.titel for t in treffer] == [
"Pegasus Spiele: Next Station Berlin", "MICROMACRO Kids",
]
# Erstes Element liefert ein Bild, das zweite keins → leer lassen.
assert treffer[0].bild_url == "https://www.spielbox.de/media/next-station.jpg"
assert treffer[1].bild_url is None
assert folge_links == []
class _StatischerAdapter:
"""Adapter-Ersatz mit fester Trefferliste (für Service-Tests)."""
name = "spielbox"
anzeigename = "spielbox Test"
start_urls = ("https://spielbox.example.org/liste",)
def __init__(self, treffer):
self._treffer = treffer
def __call__(self, client):
return self
def sammle(self):
from tests._neuheiten import SammelErgebnis
return SammelErgebnis(treffer=list(self._treffer), seiten=1)
def test_quellen_sync_speichert_und_pflegt_bild_url(session_factory):
quelle = _StatischerAdapter([
QuellenTreffer(
titel="Ringträger", verlag="Schmidt Spiele",
quellen_url="https://x.example.org/r",
bild_url="https://bilder.example.org/ringtraeger.jpg",
),
])
service = WebQuellenSyncService(
session_factory,
adapter_klassen=(quelle,),
client_fabrik=lambda validatoren: WebQuellenClient(
schlaf=lambda s: None, uhr=FakeUhr(), validatoren=validatoren
),
)
zeilen = service.laufe()
assert zeilen[0].neu == 1
with session_factory() as db:
eintrag = db.query(Neuheit).one()
assert eintrag.quelle == "spielbox"
assert eintrag.bild_url == "https://bilder.example.org/ringtraeger.jpg"
# Zweiter Lauf aktualisiert das Bild statt es zu duplizieren.
quelle._treffer = [
QuellenTreffer(
titel="Ringträger", verlag="Schmidt Spiele",
quellen_url="https://x.example.org/r",
bild_url="https://bilder.example.org/ringtraeger-neu.jpg",
),
]
zeilen = service.laufe()
assert zeilen[0].aktualisiert == 1 and zeilen[0].neu == 0
with session_factory() as db:
assert db.query(Neuheit).count() == 1
assert db.query(Neuheit).one().bild_url == \
"https://bilder.example.org/ringtraeger-neu.jpg"
def test_spielessen_extrahiert_bild_aus_bilder_feld(app):
klasse = plugin_modul(app, "neuheiten").quellen.spielessen.SpielEssenQuelle
adapter = klasse(client=None) # Parsing-Hilfen brauchen keinen Client
produkt = {
"TITEL": "-60°",
"BILDER": [{"URL": "//maps.eyeled-services.de/media/bild.jpg"}],
}
treffer = adapter._produkt(produkt, {})
assert treffer is not None
assert treffer.bild_url == "https://maps.eyeled-services.de/media/bild.jpg"
treffer_ohne = adapter._produkt({"TITEL": "X", "BILDER": []}, {})
assert treffer_ohne is not None
assert treffer_ohne.bild_url is None
# ---------- Planung: Übernahme trägt das Bild mit ----------
def test_uebernehmen_kopiert_bild_url_in_die_planung(app, client):
with app.state.session_factory() as db:
db.add(
Neuheit(
titel="Cascadia",
bgg_id=295947,
status="neuheit",
bild_url="https://cf.geekdo-images.com/cascadia.jpg",
)
)
db.commit()
melde_an(client)
antwort = client.post("/planung/uebernehmen/1", follow_redirects=False)
assert antwort.status_code in (303, 200)
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
eintrag = db.scalars(select(Planungseintrag)).one()
assert eintrag.titel == "Cascadia"
assert eintrag.bild_url == "https://cf.geekdo-images.com/cascadia.jpg"
# Die Neuheit selbst ist jetzt „planung“ und behält ihr Bild.
neuheit = db.scalar(select(Neuheit).where(Neuheit.titel == "Cascadia"))
assert neuheit.status == "planung"
assert neuheit.bild_url == "https://cf.geekdo-images.com/cascadia.jpg"
# ---------- Template-Darstellung ----------
def test_neuheitenliste_rendert_img_mit_lazy_loading(app, client):
with app.state.session_factory() as db:
db.add(
Neuheit(
titel="Catan", bgg_id=13, status="neuheit",
bild_url="https://cf.geekdo-images.com/catan.jpg",
)
)
db.commit()
melde_an(client)
antwort = client.get("/neuheiten")
assert antwort.status_code == 200
text = antwort.text
assert '<img src="https://cf.geekdo-images.com/catan.jpg"' in text
assert 'loading="lazy"' in text
assert 'decoding="async"' in text
assert 'alt="Cover: Catan"' in text
# Fallback-Zeichen darf daneben nicht auftauchen, wenn Bilder da sind …
# (Doch: andere Einträge könnten bildlos sein — hier nur einer.)
# Mobil-Kartenansicht und Desktop-Tabelle sind beide im Markup.
assert 'md:hidden' in text
assert 'hidden md:block' in text
def test_neuheitenliste_rendert_platzhalter_ohne_bild(app, client):
with app.state.session_factory() as db:
db.add(Neuheit(titel="Ohne Bild", status="neuheit"))
db.commit()
melde_an(client)
antwort = client.get("/neuheiten")
assert antwort.status_code == 200
liste = antwort.text.split('<div id="neuheiten-liste"')[1].split("</main>")[0]
assert "<img" not in liste
assert "&#127922;" in antwort.text # Spielicon-Zeichen im Platzhalter-Div
def test_planungsliste_zeigt_cover(app, client):
from redaktionskern.auth.models import User
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
benutzer = db.query(User).filter_by(username="admin").first()
db.add(
Planungseintrag(
titel="Cascadia",
rezensent_id=benutzer.id,
status="offen",
bild_url="https://cf.geekdo-images.com/cascadia.jpg",
)
)
db.commit()
melde_an(client)
antwort = client.get("/planung")
assert antwort.status_code == 200
assert 'src="https://cf.geekdo-images.com/cascadia.jpg"' in antwort.text
assert 'loading="lazy"' in antwort.text
assert 'decoding="async"' in antwort.text
def test_archiv_zeigt_cover(app, client):
ArchivNeuheit = plugin_modul(app, "archiv").ArchivNeuheit
with app.state.session_factory() as db:
db.add(
ArchivNeuheit(
quell_id=1, titel="Altspiel",
bild_url="https://cf.geekdo-images.com/altspiel.jpg",
)
)
db.commit()
melde_an(client)
antwort = client.get("/archiv")
assert antwort.status_code == 200
assert 'src="https://cf.geekdo-images.com/altspiel.jpg"' in antwort.text
assert 'loading="lazy"' in antwort.text

View File

@@ -389,3 +389,25 @@ def test_bgg_pruefclient_toleroert_fehler():
assert client.vorgaenger(99) == [] assert client.vorgaenger(99) == []
finally: finally:
client.schliessen() client.schliessen()
def test_bgg_pruefclient_sendet_token_header():
from plugins.dedup.bgg import BggPruefClient
gesehen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
gesehen.append(request)
return httpx.Response(200, content=THING_XML.encode())
client = BggPruefClient(
mindestabstand_sekunden=0.0,
token="mein-token",
transport=httpx.MockTransport(handler),
)
try:
assert client.alternativen(99) == ["Catan Expansion", "Siedler-Erweiterung"]
finally:
client.schliessen()
assert gesehen
assert gesehen[0].headers["Authorization"] == "Bearer mein-token"

527
tests/test_dedup_ki.py Normal file
View File

@@ -0,0 +1,527 @@
"""Tests: KI-Zweitprüfung des Plugins „dedup“.
Alle LLM-Antworten werden über httpx.MockTransport bzw. Fake-Clients gestellt —
in CI läuft niemals ein echter LLM-Call. Getestet werden: Konfiguration,
JSON-Parsing-Robustheit (Code-Fences, Zusatztext), Retry-/Fallback-Verhalten,
Konfidenz-Schwelle und die Rückführung bestätigter Treffer in die Regelprüfung.
"""
from __future__ import annotations
import asyncio
import json
import httpx
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from redaktionskern.app import create_app
from plugins.dedup.ki_pruefung import (
VERSUCHE,
KiKonfiguration,
KiPruefClient,
extrahiere_json,
lade_konfiguration,
validiere_antwort,
)
from tests.conftest import lege_benutzer_an
from tests.test_dedup import benutzer_objekt, neuheit_anlegen, planung_anlegen, plugin_modul
# ---------------- Hilfen ----------------
def gueltige_llm_antwort(inhalt: str, status: int = 200) -> httpx.Response:
"""Wrappt einen Assistant-Text in eine Chat-Completions-Antwort."""
body = {"choices": [{"message": {"role": "assistant", "content": inhalt}}]}
return httpx.Response(status, content=json.dumps(body).encode())
def mock_transport(antworten: list) -> tuple[httpx.MockTransport, dict]:
"""Transport mit gestaffelten Antworten: str = Assistant-Inhalt,
int = HTTP-Status, Exception = wird geworfen. Zählt die Aufrufe mit."""
zaehler = {"aufrufe": 0}
def handler(request: httpx.Request) -> httpx.Response:
index = min(zaehler["aufrufe"], len(antworten) - 1)
zaehler["aufrufe"] += 1
spezifikation = antworten[index]
if isinstance(spezifikation, Exception):
raise spezifikation
if isinstance(spezifikation, int):
return httpx.Response(spezifikation, json={"error": "kaputt"})
return gueltige_llm_antwort(spezifikation)
return httpx.MockTransport(handler), zaehler
class FakeKiClient:
"""Ersetzt den KI-Client komplett: feste Antwort, zählt Aufrufe."""
modell = "fake-modell"
def __init__(self, antwort=None):
self.antwort = antwort
self.aufrufe: list[dict] = []
def bewerte(self, **kwargs):
self.aufrufe.append(kwargs)
return self.antwort
def schliessen(self):
pass
GLEICHES_SPIEL_HOCH = {
"ist_gleiches_spiel": True, "konfidenz": 0.9,
"begruendung": "Beides El Grande-Jubiläumsausgaben.",
"empfohlener_verlag": None, "deutscher_titel": None,
}
def app_mit_ki(settings, monkeypatch, *, konfidenz_min=None):
"""App, deren Plugin-Umgebung die KI-Zweitprüfung aktiviert."""
monkeypatch.setenv("SPIELE_DEDUP_LLM_AKTIV", "1")
monkeypatch.setenv("SPIELE_DEDUP_LLM_BASIS_URL", "https://llm.example.test/v1")
monkeypatch.setenv("SPIELE_DEDUP_LLM_API_KEY", "test-key")
monkeypatch.setenv("SPIELE_DEDUP_LLM_MODELL", "test-modell")
if konfidenz_min is not None:
monkeypatch.setenv("SPIELE_DEDUP_LLM_KONFIDENZ_MIN", str(konfidenz_min))
return create_app(settings)
def pruefe_mit_ki(app, antworten, *args, **kwargs):
"""check_titel mit per Fabrik eingehängtem Mock-LLM (Antwort-Stafette)."""
transport, zaehler = mock_transport(antworten)
dedup = app.state.registry.get("dedup")
dedup.ki_client_fabrik = lambda: KiPruefClient(
KiKonfiguration(
aktiv=True, basis_url="https://llm.example.test/v1",
api_key="test-key", modell="test-modell",
konfidenz_min=dedup.ki_konfiguration.konfidenz_min,
),
transport=transport,
)
try:
ergebnis = asyncio.run(dedup.check_titel(*args, **kwargs))
finally:
dedup.ki_client_fabrik = None
return ergebnis, zaehler
# ---------------- Konfiguration ----------------
def test_konfiguration_standard_aus():
k = lade_konfiguration({})
assert not k.aktiv
assert not k.vollstaendig
assert k.konfidenz_min == 0.7
assert k.timeout >= 1.0
def test_konfiguration_aus_env_und_fehlertolerant():
k = lade_konfiguration({
"SPIELE_DEDUP_LLM_AKTIV": "1",
"SPIELE_DEDUP_LLM_BASIS_URL": "https://api.example.test/v1/",
"SPIELE_DEDUP_LLM_API_KEY": " sk-123 ",
"SPIELE_DEDUP_LLM_MODELL": " gpt-test ",
# Kaputte Zahlen fallen auf die Standards zurück:
"SPIELE_DEDUP_LLM_KONFIDENZ_MIN": "abc",
"SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN": "-5",
})
assert k.vollstaendig
assert k.basis_url == "https://api.example.test/v1"
assert k.api_key == "sk-123"
assert k.modell == "gpt-test"
assert k.konfidenz_min == 0.7 # Standard bei Unsinn
assert k.timeout >= 1.0
def test_konfiguration_schwellenwerte_werden_geklemmt():
k = lade_konfiguration({"SPIELE_DEDUP_LLM_KONFIDENZ_MIN": "1.5"})
assert k.konfidenz_min == 1.0
k = lade_konfiguration({"SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN": "3.5"})
assert k.timeout == 3.5
# ---------------- JSON-Parsing-Robustheit ----------------
def test_extrahiere_json_plain():
assert extrahiere_json('{"ist_gleiches_spiel": true}') == {"ist_gleiches_spiel": True}
def test_extrahiere_json_code_fence():
text = 'Hier die Bewertung:\n\n```json\n{"ist_gleiches_spiel": false}\n```\n\nViele Grüße'
assert extrahiere_json(text) == {"ist_gleiches_spiel": False}
def test_extrahiere_json_text_um_json():
text = 'Klar! {"ist_gleiches_spiel": true, "konfidenz": 0.8} — hoffe das hilft.'
assert extrahiere_json(text)["konfidenz"] == 0.8
def test_extrahiere_json_unbrauchbar():
assert extrahiere_json("Ich weiß es leider nicht.") is None
assert extrahiere_json("[1, 2, 3]") is None # kein Objekt
assert extrahiere_json("") is None
assert extrahiere_json(None) is None
def test_validiere_antwort_normalisiert():
roh = {
"ist_gleiches_spiel": "ja", # Text statt Bool
"konfidenz": 92, # Prozent-Skala
"begruendung": " Gleiche Edition. ",
"empfohlener_verlag": " Kosmos ",
"deutscher_titel": "",
}
sauber = validiere_antwort(roh)
assert sauber == {
"ist_gleiches_spiel": True,
"konfidenz": 0.92,
"begruendung": "Gleiche Edition.",
"empfohlener_verlag": "Kosmos",
"deutscher_titel": None,
}
@pytest.mark.parametrize("roh", [
{"ist_gleiches_spiel": True}, # konfidenz fehlt
{"ist_gleiches_spiel": "vielleicht", "konfidenz": 0.9},
{"ist_gleiches_spiel": True, "konfidenz": "hoch"},
{"ist_gleiches_spiel": True, "konfidenz": 7}, # weder 01 noch 0100
["kein", "objekt"],
])
def test_validiere_antwort_lehnt_unbrauchbares_ab(roh):
assert validiere_antwort(roh) is None
# ---------------- Client: Retry, Fehler, Header ----------------
KONFIG = KiKonfiguration(
aktiv=True, basis_url="https://llm.example.test/v1",
api_key="geheim", modell="test-modell",
)
ANTWORT_JSON = '{"ist_gleiches_spiel": true, "konfidenz": 0.9, "begruendung": "b", ' \
'"empfohlener_verlag": null, "deutscher_titel": null}'
def test_client_sendet_modell_und_key():
gesehen = {}
def handler(request: httpx.Request) -> httpx.Response:
gesehen["url"] = str(request.url)
gesehen["auth"] = request.headers.get("Authorization")
gesehen["body"] = json.loads(request.content.decode())
return gueltige_llm_antwort(ANTWORT_JSON)
client = KiPruefClient(KONFIG, transport=httpx.MockTransport(handler))
try:
ergebnis = client.bewerte(titel_a="A", titel_b="B")
finally:
client.schliessen()
assert ergebnis["ist_gleiches_spiel"] is True
assert gesehen["url"].endswith("/v1/chat/completions")
assert gesehen["auth"] == "Bearer geheim"
assert gesehen["body"]["model"] == "test-modell"
def test_client_retryt_genau_einmal_bei_serverstoerung():
transport, zaehler = mock_transport([500, ANTWORT_JSON])
client = KiPruefClient(KONFIG, transport=transport)
try:
ergebnis = client.bewerte(titel_a="A", titel_b="B")
finally:
client.schliessen()
assert ergebnis is not None
assert zaehler["aufrufe"] == 2 # erster Versuch + genau ein Retry
def test_client_gibt_nach_zweitem_fehler_auf():
transport, zaehler = mock_transport([503, 503])
client = KiPruefClient(KONFIG, transport=transport)
try:
assert client.bewerte(titel_a="A", titel_b="B") is None
finally:
client.schliessen()
assert zaehler["aufrufe"] == VERSUCHE
def test_client_timeout_wird_genau_einmal_wiederholt():
transport, zaehler = mock_transport([
httpx.ReadTimeout("zu langsam"), httpx.ReadTimeout("weiter zu langsam"),
])
client = KiPruefClient(KONFIG, transport=transport)
try:
assert client.bewerte(titel_a="A", titel_b="B") is None
finally:
client.schliessen()
assert zaehler["aufrufe"] == 2
def test_client_kein_retry_bei_clientfehler_oder_kaputtem_json():
# 404 ist nicht wiederholbar:
transport, zaehler = mock_transport([404, 404])
client = KiPruefClient(KONFIG, transport=transport)
try:
assert client.bewerte(titel_a="A", titel_b="B") is None
finally:
client.schliessen()
assert zaehler["aufrufe"] == 1
# Kaputtes JSON hilft kein Retry:
transport, zaehler = mock_transport(["Leider weiß ich das nicht."])
client = KiPruefClient(KONFIG, transport=transport)
try:
assert client.bewerte(titel_a="A", titel_b="B") is None
finally:
client.schliessen()
assert zaehler["aufrufe"] == 1
def test_client_ohne_vollstaendige_konfig_ruft_nichts():
transport, zaehler = mock_transport([ANTWORT_JSON])
leer = KiKonfiguration(aktiv=True, basis_url="", api_key="", modell="")
client = KiPruefClient(leer, transport=transport)
try:
assert client.bewerte(titel_a="A", titel_b="B") is None
finally:
client.schliessen()
assert zaehler["aufrufe"] == 0
# ---------------- Integration: Fallback & Verhalten ----------------
def test_ohne_ki_verhaelt_sich_alles_wie_bisher(settings, monkeypatch):
"""Standard (aktiv=0): Grenzfälle bleiben regelbasiert, kein LLM-Aufruf."""
app = create_app(settings)
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_objekt(app, "rez1")
planung_anlegen(app, "El Grande Big Box", rez1.id)
dedup = app.state.registry.get("dedup")
def aufschrei(): # dürfte nie aufgerufen werden
raise AssertionError("KI war deaktiviert, wurde aber aufgerufen!")
dedup.ki_client_fabrik = lambda: aufschrei()
try:
ergebnis = asyncio.run(dedup.check_titel("El Grande: 25 Jahre"))
finally:
dedup.ki_client_fabrik = None
assert not ergebnis.hat_konflikte
assert ergebnis.ki_befunde == []
# Empfehlung bleibt beim eigenen Titel (Heuristik ohne Varianten):
assert ergebnis.titel_empfehlung == "El Grande: 25 Jahre"
def test_aktiv_ohne_key_faellt_auf_regeln_zurueck(settings, monkeypatch):
"""AKTIV=1, aber kein API-Key → Zweitprüfung bleibt aus, kein Fehler."""
monkeypatch.setenv("SPIELE_DEDUP_LLM_AKTIV", "1")
monkeypatch.setenv("SPIELE_DEDUP_LLM_BASIS_URL", "https://llm.example.test/v1")
# bewusst KEIN SPIELE_DEDUP_LLM_API_KEY / MODELL:
app = create_app(settings)
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_objekt(app, "rez1")
planung_anlegen(app, "El Grande Big Box", rez1.id)
dedup = app.state.registry.get("dedup")
dedup.ki_client_fabrik = lambda: (_ for _ in ()).throw(
AssertionError("Ohne Key darf kein KI-Client gebaut werden.")
)
try:
ergebnis = asyncio.run(dedup.check_titel("El Grande: 25 Jahre"))
finally:
dedup.ki_client_fabrik = None
assert not ergebnis.hat_konflikte
assert ergebnis.ki_befunde == []
def test_llm_fehler_blockiert_nie(settings, monkeypatch):
"""Netzwerk-/Server-Probleme: Ergebnis identisch zur reinen Regelprüfung."""
app = app_mit_ki(settings, monkeypatch)
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_objekt(app, "rez1")
planung_anlegen(app, "El Grande Big Box", rez1.id, verlag="Rio Grande Games")
ergebnis, zaehler = pruefe_mit_ki(
app,
[httpx.ConnectError("offline")] * 6,
"El Grande: 25 Jahre", "Devir", None,
)
assert zaehler["aufrufe"] >= 1
# Ohne funktionierende Zweitprüfung bleibt es beim Regelbefund (hier leer —
# die Grenzfall-Paarung matchen die Regeln ja gerade nicht):
assert not ergebnis.hat_konflikte
assert all(b["entscheidung"] == "fehler_verworfen" for b in ergebnis.ki_befunde)
# Exakt dasselbe Ergebnis wie ohne KI:
dedup = app.state.registry.get("dedup")
referenz = asyncio.run(dedup.check_titel("El Grande: 25 Jahre", "Devir", None))
assert referenz.als_liste() == ergebnis.als_liste()
assert referenz.titel_empfehlung == ergebnis.titel_empfehlung
assert referenz.verlags_optionen == ergebnis.verlags_optionen
# ---------------- Integration: Grenzfälle & Schwelle ----------------
def test_grenzfall_wird_durch_ki_bestaetigt(settings, monkeypatch):
"""Big Box vs. Jubiläumsausgabe: Regeln finden nichts, das LLM schon."""
app = app_mit_ki(settings, monkeypatch)
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_objekt(app, "rez1")
planung_anlegen(app, "El Grande Big Box", rez1.id)
antwort = dict(GLEICHES_SPIEL_HOCH)
antwort["begruendung"] = "Beides sind El Grande-Ausgaben der Jubiläumsreihe."
ergebnis, _ = pruefe_mit_ki(app, [json.dumps(antwort)], "El Grande: 25 Jahre")
arten = [k.art for k in ergebnis.konflikte]
assert "planung" in arten
planungs_konflikt = next(k for k in ergebnis.konflikte if k.art == "planung")
assert planungs_konflikt.details["ki_bestaetigt"] is True
assert planungs_konflikt.details["ki_konfidenz"] == 0.9
assert ergebnis.ki_befunde[0]["entscheidung"] == "gleiches_spiel"
assert ergebnis.ki_befunde[0]["modell"] == "test-modell"
assert ergebnis.ki_befunde[0]["konfidenz"] == 0.9
def test_grenzfall_unter_konfidenzschwelle_wird_verworfen(settings, monkeypatch):
"""Gleicher Befund, aber Konfidenz 0.5 < 0.7 → nur der Regelbefund zählt."""
app = app_mit_ki(settings, monkeypatch)
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_objekt(app, "rez1")
planung_anlegen(app, "El Grande Big Box", rez1.id)
antwort = dict(GLEICHES_SPIEL_HOCH, konfidenz=0.5)
ergebnis, _ = pruefe_mit_ki(app, [json.dumps(antwort)], "El Grande: 25 Jahre")
assert not ergebnis.hat_konflikte
assert ergebnis.ki_befunde[0]["entscheidung"] == "verworfen_unter_schwelle"
def test_konfidenzschwelle_laesst_sich_per_env_verschieben(settings, monkeypatch):
app = app_mit_ki(settings, monkeypatch, konfidenz_min=0.95)
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_objekt(app, "rez1")
planung_anlegen(app, "El Grande Big Box", rez1.id)
antwort = dict(GLEICHES_SPIEL_HOCH) # 0.9 reicht jetzt nicht mehr
ergebnis, _ = pruefe_mit_ki(app, [json.dumps(antwort)], "El Grande: 25 Jahre")
assert not ergebnis.hat_konflikte
assert ergebnis.ki_befunde[0]["entscheidung"] == "verworfen_unter_schwelle"
def test_llm_sagt_verschiedene_spiele(settings, monkeypatch):
app = app_mit_ki(settings, monkeypatch)
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_objekt(app, "rez1")
planung_anlegen(app, "El Grande Big Box", rez1.id)
antwort = dict(GLEICHES_SPIEL_HOCH, ist_gleiches_spiel=False, begruendung="Big Box ≠ Jubiläum.")
ergebnis, _ = pruefe_mit_ki(app, [json.dumps(antwort)], "El Grande: 25 Jahre")
assert not ergebnis.hat_konflikte
assert ergebnis.ki_befunde[0]["entscheidung"] == "verschiedene_spiele"
def test_uebersetzungsgrenzfall_kriegt_deutschen_titel(settings, monkeypatch):
"""Catan Fast Edition vs. Catan Das schnelle Spiel: KI liefert den deutschen Titel."""
app = app_mit_ki(settings, monkeypatch)
neuheit_anlegen(app, "El Grande: 25 Jahre", verlag="Hans im Glück", bgg_id=111)
antwort = dict(GLEICHES_SPIEL_HOCH, deutscher_titel="El Grande: 25 Jahre")
ergebnis, _ = pruefe_mit_ki(app, [json.dumps(antwort)], "El Grande Big Box")
# Ohne KI wäre hier nichts gefunden worden (beide Titel heuristisch neutral);
# mit KI gibt es eine Titel-Variante samt deutscher Empfehlung:
assert "titel" in [k.art for k in ergebnis.konflikte]
assert ergebnis.titel_empfehlung == "El Grande: 25 Jahre"
dedup = app.state.registry.get("dedup")
referenz = asyncio.run(dedup.check_titel("El Grande Big Box"))
assert not referenz.hat_konflikte
# Ohne KI bleibt die Empfehlung beim eigenen Titel:
assert referenz.titel_empfehlung == "El Grande Big Box"
def test_verlagskonflikt_kriegt_ki_empfehlung(settings, monkeypatch):
"""Verlags-Alias: Regelprüfung findet den Konflikt, das LLM den richtigen Verlag."""
app = app_mit_ki(settings, monkeypatch)
neuheit_anlegen(app, "El Grande", verlag="Hans im Glück Verlag", bgg_id=21)
antwort = dict(GLEICHES_SPIEL_HOCH, empfohlener_verlag="Hans im Glück Verlag")
ergebnis, _ = pruefe_mit_ki(app, [json.dumps(antwort)], "El Grande", "Hans im Glück", 21)
assert ergebnis.hat_verlagskonflikt
verlags_konflikt = next(k for k in ergebnis.konflikte if k.art == "verlag")
assert verlags_konflikt.details["ki_empfohlener_verlag"] == "Hans im Glück Verlag"
assert ergebnis.ki_befunde[-1]["typ"] == "verlag"
# ---------------- Integration: Budget & Audit ----------------
def test_ki_budget_begrenzt_anzahl_aufrufe(settings, monkeypatch):
from plugins.dedup.pruefung import MAX_KI_PAARUNGEN
app = app_mit_ki(settings, monkeypatch)
for nr in range(MAX_KI_PAARUNGEN + 3):
neuheit_anlegen(app, f"Catan Ausgabe Nummer {nr}", bgg_id=200 + nr)
fake = FakeKiClient(dict(GLEICHES_SPIEL_HOCH))
dedup = app.state.registry.get("dedup")
dedup.ki_client_fabrik = lambda: fake
try:
ergebnis = asyncio.run(dedup.check_titel("Catan Neuauflage"))
finally:
dedup.ki_client_fabrik = None
assert len(fake.aufrufe) <= MAX_KI_PAARUNGEN
assert len(ergebnis.ki_befunde) == len(fake.aufrufe)
def test_ki_befunde_landen_im_audit_log(settings, monkeypatch):
app = app_mit_ki(settings, monkeypatch)
lege_benutzer_an(app, "rez1", "rezensent") # fremder Eintrag → echter Konflikt
lege_benutzer_an(app, "anna", "redakteur") # prüfende Person
rez1 = benutzer_objekt(app, "rez1")
anna = benutzer_objekt(app, "anna")
planung_anlegen(app, "El Grande Big Box", rez1.id)
fake = FakeKiClient(dict(GLEICHES_SPIEL_HOCH))
dedup = app.state.registry.get("dedup")
dedup.ki_client_fabrik = lambda: fake
try:
ergebnis = asyncio.run(dedup.check_titel("El Grande: 25 Jahre", user=anna))
finally:
dedup.ki_client_fabrik = None
assert ergebnis.hat_konflikte
AuditEintrag = plugin_modul(app, "audit-log").AuditEintrag
with app.state.session_factory() as db:
eintraege = db.scalars(
select(AuditEintrag).where(AuditEintrag.objekt_typ == "dedup_ki")
).all()
assert len(eintraege) == 1
eintrag = eintraege[0]
assert eintrag.action == "geprüft"
assert eintrag.actor_name == "anna"
assert eintrag.details["modell"] == "fake-modell"
befaelle = eintrag.details["befunde"]
assert befaelle[0]["entscheidung"] == "gleiches_spiel"
assert befaelle[0]["konfidenz"] == 0.9
assert befaelle[0]["titel_a"] == "El Grande: 25 Jahre"
def test_keine_ki_befunde_heissen_kein_audit_eintrag(settings, monkeypatch):
"""Rein regelbasierte Prüfung (KI aus) schreibt keinen dedup_ki-Eintrag."""
app = create_app(settings)
asyncio.run(app.state.registry.get("dedup").check_titel("Beliebiges Spiel"))
AuditEintrag = plugin_modul(app, "audit-log").AuditEintrag
with app.state.session_factory() as db:
eintraege = db.scalars(
select(AuditEintrag).where(AuditEintrag.objekt_typ == "dedup_ki")
).all()
assert eintraege == []

550
tests/test_erinnerung.py Normal file
View File

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

319
tests/test_export.py Normal file
View File

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

View File

@@ -8,7 +8,14 @@ from __future__ import annotations
import httpx import httpx
import pytest import pytest
from tests._neuheiten import BggClient, BggFehler, parse_suche, parse_things from tests._neuheiten import (
BggAuthFehler,
BggClient,
BggFehler,
MELDUNG_401,
parse_suche,
parse_things,
)
SUCHE_XML = """<?xml version="1.0" encoding="utf-8"?> SUCHE_XML = """<?xml version="1.0" encoding="utf-8"?>
<items total="3" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> <items total="3" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
@@ -61,9 +68,11 @@ class AufzeichnenderTransport(httpx.BaseTransport):
self.antworten = list(antworten) self.antworten = list(antworten)
self.uhr = uhr self.uhr = uhr
self.anfragen: list[tuple[str, float]] = [] self.anfragen: list[tuple[str, float]] = []
self.auth_header: list[str | None] = [] # Authorization je Request
def handle_request(self, request: httpx.Request) -> httpx.Response: def handle_request(self, request: httpx.Request) -> httpx.Response:
self.anfragen.append((str(request.url), self.uhr())) self.anfragen.append((str(request.url), self.uhr()))
self.auth_header.append(request.headers.get("Authorization"))
element = self.antworten.pop(0) element = self.antworten.pop(0)
if isinstance(element, Exception): if isinstance(element, Exception):
raise element raise element
@@ -157,6 +166,39 @@ def test_suche_sendet_type_boardgame():
assert "query=catan" in url assert "query=catan" in url
def test_token_wird_als_bearer_header_gesendet():
uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort(SUCHE_XML), _antwort(THING_XML)], uhr)
client = BggClient(
transport=transport,
token="geheim-123",
schlaf=lambda s: None,
uhr=uhr,
)
client.suche("catan")
client.details([13])
# Authorization-Header auf allen Requests (Suche und Thing):
assert transport.auth_header == ["Bearer geheim-123", "Bearer geheim-123"]
def test_leerer_token_gilt_als_nicht_konfiguriert():
uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort(SUCHE_XML)], uhr)
client = BggClient(
transport=transport,
token=" ",
schlaf=lambda s: None,
uhr=uhr,
)
assert client.token is None
client.suche("catan")
assert transport.auth_header == [None]
def test_details_sendet_thing_mit_ids_und_filtert_erweiterungen(): def test_details_sendet_thing_mit_ids_und_filtert_erweiterungen():
uhr = FakeUhr() uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort(THING_XML)], uhr) transport = AufzeichnenderTransport([_antwort(THING_XML)], uhr)
@@ -266,6 +308,34 @@ def test_4xx_fuehrt_zu_fehler_ohne_weitere_versuche():
assert wartezeiten == [] assert wartezeiten == []
def test_401_fuehrt_zu_klarer_auth_fehlermeldung_ohne_retry():
uhr = FakeUhr()
wartezeiten: list[float] = []
transport = AufzeichnenderTransport(
[_antwort("unauthorized", status_code=401)], uhr
)
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
with pytest.raises(BggAuthFehler) as aufgetreten:
client.suche("catan")
assert str(aufgetreten.value) == MELDUNG_401
assert MELDUNG_401.startswith("BGG hat die Anfrage abgelehnt (401)")
assert len(transport.anfragen) == 1 # kein Retry bei dauerhaftem 401
assert wartezeiten == []
def test_401_beim_thing_request_gleiche_meldung():
uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort("nope", status_code=401)], uhr)
client = _client(transport, uhr, lambda s: None)
with pytest.raises(BggAuthFehler) as aufgetreten:
client.details([13])
assert str(aufgetreten.value) == MELDUNG_401
def test_max_versuche_erschoepft_mit_fehler(): def test_max_versuche_erschoepft_mit_fehler():
uhr = FakeUhr() uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort("kaputt", status_code=500)], uhr) transport = AufzeichnenderTransport([_antwort("kaputt", status_code=500)], uhr)

View File

@@ -0,0 +1,544 @@
"""Tests: Optionale LLM-Hilfsstufe des Web-Quellen-Crawlers (neuheiten).
Alle HTTP- und LLM-Antworten sind gemockt (httpx.MockTransport bzw.
Fake-Transports) — es gibt niemals echte Netzwerk- oder LLM-Aufrufe.
Abgedeckt: Fallback-Fälle (aktiv=0, fehlender Key, Timeout mit genau einem
Retry, kaputtes JSON), Konfidenz-Schwelle, Struktur-Erkennung nur mit
gleiche-Domain-URLs (max. 20), Feld-Korrektur ausschließlich unsicherer
Treffer (URL bleibt fix) und die Audit-Protokollierung der KI-Eingriffe.
"""
from __future__ import annotations
import json
import httpx
import pytest
from tests._neuheiten import (
MAX_FOLGE_URLS,
KiHilfe,
KiKonfiguration,
Neuheit,
QuellenFehler,
QuellenTreffer,
SpielboxQuelle,
SammelErgebnis,
WebQuellenClient,
WebQuellenSyncService,
extrahiere_ki_json,
lade_ki_konfiguration,
struktur_verdaechtig,
titel_ist_verdaechtig,
treffer_ist_unsicher,
)
BASIS_URL = "https://www.spielbox.de/neuheiten-neue-spiele"
KONFIG = dict(
aktiv=True,
basis_url="https://llm.example.test/v1",
api_key="test-key",
modell="test-modell",
)
class FakeUhr:
def __init__(self) -> None:
self.zeit = 0.0
def __call__(self) -> float:
return self.zeit
# ---------------- Gemockte Seiten ----------------
SEITE_LEER_VIELE_LINKS = (
"<html><body><nav>"
+ "".join(
f'<a href="/rubrik/{i}">Rubrik {i}</a> ' for i in range(40)
)
+ "</nav><p>Keine Neuheiten erkannt.</p></body></html>"
)
SEITE_MIT_TREFFERN = """
<div class="item">
<div class="page-header"><h2><a href="/x/1">MICROMACRO Kids</a></h2></div>
<p><strong>02.03.2026</strong> - kurz.</p>
</div>
"""
def _antwort(inhalt: bytes | str) -> httpx.Response:
return httpx.Response(
200,
content=inhalt.encode("utf-8") if isinstance(inhalt, str) else inhalt,
request=httpx.Request("GET", BASIS_URL),
)
def _crawler_client(seiten: dict[str, bytes | str]) -> tuple[WebQuellenClient, KartenTransport, FakeUhr]:
uhr = FakeUhr()
transport = KartenTransport({u: _antwort(i) for u, i in seiten.items()}, uhr)
client = WebQuellenClient(
transport=transport, schlaf=lambda s: None, uhr=uhr
)
return client, transport, uhr
class KartenTransport(httpx.BaseTransport):
"""Antworten je URL; zeichnet angefragte URLs auf."""
def __init__(self, antworten: dict[str, httpx.Response], uhr: FakeUhr):
self.antworten = antworten
self.anfragen: list[str] = []
def handle_request(self, request: httpx.Request) -> httpx.Response:
url = str(request.url)
self.anfragen.append(url)
antwort = self.antworten.get(url)
if antwort is None and "?" in url:
antwort = self.antworten.get(url.split("?")[0])
if antwort is None:
raise QuellenFehler(f"Keine gemockte Antwort für {url}")
return antwort
# ---------------- LLM-Mocks ----------------
def llm_antwort(objekt: dict, status: int = 200) -> httpx.Response:
body = {"choices": [{"message": {"role": "assistant", "content": json.dumps(objekt)}}]}
return httpx.Response(status, content=json.dumps(body).encode())
class LlmTransport(httpx.BaseTransport):
"""Gestaffelte Antworten für Chat-Completions; zählt Aufrufe.
Elemente: dict → JSON-Inhalt, int → HTTP-Status, Exception → wird geworfen.
"""
def __init__(self, antworten: list):
self.antworten = list(antworten)
self.aufrufe = 0
self.prompts: list[dict] = []
def handle_request(self, request: httpx.Request) -> httpx.Response:
index = min(self.aufrufe, len(self.antworten) - 1)
spezifikation = self.antworten[index]
self.aufrufe += 1
self.prompts.append(json.loads(request.content.decode()))
if isinstance(spezifikation, Exception):
raise spezifikation
if isinstance(spezifikation, int):
return httpx.Response(spezifikation, json={"error": "kaputt"})
return llm_antwort(spezifikation)
def ki_hilfe(transport: httpx.BaseTransport, konfidenz_min: float = 0.7) -> KiHilfe:
return KiHilfe(
KiKonfiguration(konfidenz_min=konfidenz_min, **KONFIG),
transport=transport,
)
TREFFER_UNSICHER = lambda: QuellenTreffer( # noqa: E731
titel="Pegasus Spiele: Next Station Berlin",
quellen_url="https://www.spielbox.de/x/1",
)
TREFFER_SICHER = lambda: QuellenTreffer( # noqa: E731
titel="Galactic Cruise", verlag="Dranda Games",
quellen_url="https://www.spielbox.de/x/2",
)
GUTE_FELDANTWORT = {
"titel": "Next Station Berlin",
"verlag": "Pegasus Spiele",
"autor": None,
"konfidenz": 0.9,
"begruendung": "Verlag stand als Präfix im Titel.",
}
# ---------------- Heuristiken ----------------
def test_struktur_verdaechtig_ohne_treffer_immmer():
assert struktur_verdaechtig(0, "<p>kurz</p>") is True
def test_struktur_verdaechtig_wenige_treffer_nur_bei_linkreicher_seite():
viele_links = "".join('<a href="/x">x</a>' for _ in range(40))
assert struktur_verdaechtig(2, viele_links) is True
assert struktur_verdaechtig(2, "<a href='/x'>x</a>") is False
assert struktur_verdaechtig(5, viele_links) is False
def test_unsichere_treffer_fehlender_verlag_oder_generischer_titel():
assert treffer_ist_unsicher(QuellenTreffer(titel="X", quellen_url="u")) is True
assert treffer_ist_unsicher(TREFFER_UNSICHER()) is True # kein Verlag
assert treffer_ist_unsicher(TREFFER_SICHER()) is False
assert titel_ist_verdaechtig("Mehr") is True
assert titel_ist_verdaechtig("ab") is True
assert titel_ist_verdaechtig("Fadenfroh") is False
def test_extrahiere_json_toleriert_code_fences():
text = "Antwort:\n```json\n{\"folge_urls\": []}\n```\nEnde."
assert extrahiere_ki_json(text) == {"folge_urls": []}
assert extrahiere_ki_json("völlig kaputt") is None
# ---------------- Konfiguration & Fallback ----------------
def test_konfiguration_standard_aus():
k = lade_ki_konfiguration({})
assert not k.aktiv and not k.vollstaendig
assert k.konfidenz_min == 0.7
assert k.timeout >= 1.0
def test_service_ohne_konfiguration_baut_keinen_ki_client(session_factory, monkeypatch):
"""Fallback aktiv=0: kein KI-Client, exakt der klassische Crawler."""
monkeypatch.setenv("SPIELE_NEUHEITEN_KI_AKTIV", "0")
class Streng:
def __call__(self): # pragma: no cover — darf nie aufgerufen werden
raise AssertionError("KI-Client ohne Aktivierung gebaut")
def schliessen(self): pass
quelle = StatischeQuelle("spielbox", [TREFFER_SICHER()])
service = WebQuellenSyncService(
session_factory, adapter_klassen=(quelle,),
client_fabrik=lambda v: _ohne_netz_client(),
ki_fabrik=Streng(),
)
zeilen = service.laufe()
assert zeilen[0].neu == 1 and zeilen[0].fehlermeldung is None
with session_factory() as db:
assert db.query(Neuheit).count() == 1
def test_service_mit_aktiv_ohne_key_laeuft_klassisch(session_factory, monkeypatch):
"""Fallback: AKTIV=1, aber Key fehlt → vollständige Konfiguration fehlt."""
monkeypatch.setenv("SPIELE_NEUHEITEN_KI_AKTIV", "1")
monkeypatch.setenv("SPIELE_NEUHEITEN_KI_BASIS_URL", "https://llm.example.test/v1")
monkeypatch.delenv("SPIELE_NEUHEITEN_KI_API_KEY", raising=False)
monkeypatch.setenv("SPIELE_NEUHEITEN_KI_MODELL", "test-modell")
class Streng:
def __call__(self): # pragma: no cover
raise AssertionError("KI-Client ohne Key gebaut")
def schliessen(self): pass
quelle = StatischeQuelle("spielbox", [TREFFER_SICHER()])
service = WebQuellenSyncService(
session_factory, adapter_klassen=(quelle,),
client_fabrik=lambda v: _ohne_netz_client(),
ki_fabrik=Streng(),
)
zeilen = service.laufe()
assert zeilen[0].neu == 1
def test_timeout_genau_ein_retry_dann_klassischer_befund():
"""Timeout: insgesamt zwei Versuche, danach klassischer Treffer unverändert."""
transport = LlmTransport([httpx.ReadTimeout("zu lang"), httpx.ReadTimeout("nochmal")])
hilfe = ki_hilfe(transport)
treffer_liste, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
assert transport.aufrufe == 2 # erster Versuch + genau ein Retry
assert eingriffe == []
original = treffer_liste[0]
assert original.verlag is None # klassischer Befund bleibt
assert original.quellen_url == "https://www.spielbox.de/x/1"
def test_kaputtes_json_kein_retry_kein_eingriff():
transport = LlmTransport(["das ist {kaputtes JSON ohne Sinn"])
hilfe = ki_hilfe(transport)
treffer_liste, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
assert transport.aufrufe == 1 # Parsing-Fehler werden nicht wiederholt
assert eingriffe == []
assert treffer_liste[0].verlag is None
def test_serverfehler_nach_retry_faellt_zurueck():
transport = LlmTransport([503, 503])
hilfe = ki_hilfe(transport)
_, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
folge_urls = hilfe.folge_urls_bei_bedarf(0, SEITE_LEER_VIELE_LINKS, BASIS_URL, set())
assert transport.aufrufe == 4 # je Aufrufart zwei Versuche
assert eingriffe == [] and folge_urls == []
# ---------------- Konfidenz-Schwelle ----------------
def test_feldkorrektur_unter_konfidenz_min_wird_verworfen():
schwach = dict(GUTE_FELDANTWORT, konfidenz=0.5)
transport = LlmTransport([schwach])
hilfe = ki_hilfe(transport, konfidenz_min=0.7)
treffer_liste, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
assert transport.aufrufe == 1
assert eingriffe == []
assert treffer_liste[0].titel == "Pegasus Spiele: Next Station Berlin"
assert treffer_liste[0].verlag is None
def test_url_vorschlaege_unter_konfidenz_min_werden_verworfen():
vorschlaege = {
"folge_urls": ["/neuheiten-neue-spiele?start=6"],
"konfidenz": 0.3,
}
transport = LlmTransport([vorschlaege])
hilfe = ki_hilfe(transport)
urls = hilfe.folge_urls_bei_bedarf(0, SEITE_LEER_VIELE_LINKS, BASIS_URL, set())
assert urls == []
# ---------------- Struktur-Erkennung ----------------
def test_struktur_erkennung_nur_gleiche_domain_urls_werden_verfolgt():
"""Fremde Domains und Nicht-HTTP-Schemata werden hart herausgefiltert."""
gemischt = {
"folge_urls": [
"/neuheiten-neue-spiele?start=6", # gleiche Domain ✓
"https://www.spielbox.de/neuheiten?page=2", # gleiche Domain ✓
"https://boese.example.org/neuheiten", # fremde Domain ✗
"ftp://www.spielbox.de/liste", # Schema ✗
"/neuheiten-neue-spiele?start=6#oben", # Duplikat ✗
"javascript:zeigeListe()", # Schema ✗
],
"konfidenz": 0.9,
}
crawler_client, transport, _ = _crawler_client({BASIS_URL: SEITE_LEER_VIELE_LINKS})
transport.antworten["https://www.spielbox.de/neuheiten-neue-spiele?start=6"] = _antwort(SEITE_MIT_TREFFERN)
transport.antworten["https://www.spielbox.de/neuheiten?page=2"] = _antwort(SEITE_MIT_TREFFERN)
adapter = SpielboxQuelle(crawler_client)
adapter.ki_hilfe = ki_hilfe(LlmTransport([gemischt]))
ergebnis = adapter.sammle()
gefolgt = [u for u in transport.anfragen if u != BASIS_URL]
assert "https://www.spielbox.de/neuheiten-neue-spiele?start=6" in gefolgt
assert all(u.startswith("https://www.spielbox.de") for u in gefolgt)
assert not any("boese" in u or u.startswith("ftp") for u in gefolgt)
assert len(ergebnis.treffer) == 2 # aus den beiden nachgeschobenen Seiten
assert ergebnis.ki_folge_urls == gefolgt
def test_struktur_erkennung_maximal_20_folge_urls():
"""Rate-Limit-Schutz: auch bei 30 Vorschlägen werden höchstens 20 verfolgt."""
vorschlaege = {
"folge_urls": [f"/liste?seite={i}" for i in range(30)],
"konfidenz": 0.9,
}
crawler_client, transport, _ = _crawler_client({BASIS_URL: SEITE_LEER_VIELE_LINKS})
for i in range(30):
transport.antworten[f"https://www.spielbox.de/liste?seite={i}"] = _antwort(SEITE_MIT_TREFFERN)
adapter = SpielboxQuelle(crawler_client)
adapter.ki_hilfe = ki_hilfe(LlmTransport([vorschlaege]))
adapter.sammle()
gefolgt = [u for u in transport.anfragen if u != BASIS_URL]
assert len(gefolgt) <= MAX_FOLGE_URLS
assert len(gefolgt) == 20
assert "https://www.spielbox.de/liste?seite=25" not in gefolgt
def test_struktur_erkennung_greift_nicht_bei_normalen_ergebnissen():
"""Drei sichere Treffer → keine Verdachts-Heuristik, kein LLM-Aufruf."""
seite = SEITE_MIT_TREFFERN * 3
crawler_client, transport, _ = _crawler_client({BASIS_URL: seite})
adapter = SpielboxQuelle(crawler_client)
adapter.ki_hilfe = ki_hilfe(LlmTransport([])) # würde bei Aufruf zählen
ergebnis = adapter.sammle()
assert len(ergebnis.treffer) == 3
assert len(transport.anfragen) == 1 # nur die Startseite
# ---------------- Feld-Korrektur ----------------
def test_feldkorrektur_nur_bei_unsicheren_treffern_url_bleibt_fix():
"""Sichere Treffer kosten kein Token; korrigiert wird nie die URL."""
transport = LlmTransport([GUTE_FELDANTWORT])
hilfe = ki_hilfe(transport)
sicher = TREFFER_SICHER()
unsicher = TREFFER_UNSICHER()
treffer_liste, eingriffe = hilfe.treffer_nachbessern([sicher, unsicher])
assert transport.aufrufe == 1 # nur der unsichere Treffer ging ans LLM
assert treffer_liste[0] is sicher # unangetastet
korrigiert = treffer_liste[1]
assert korrigiert.titel == "Next Station Berlin"
assert korrigiert.verlag == "Pegasus Spiele"
assert korrigiert.quellen_url == unsicher.quellen_url # URL bleibt fix
assert len(eingriffe) == 1
protokoll = eingriffe[0]
assert protokoll["modell"] == "test-modell"
assert protokoll["konfidenz"] == 0.9
assert protokoll["korrigiert"] == {
"titel": "Next Station Berlin", "verlag": "Pegasus Spiele",
}
assert protokoll["vorher"]["titel"] == "Pegasus Spiele: Next Station Berlin"
def test_feldkorrektur_ohne_aenderung_erzeugt_keinen_eingriff():
identisch = {
"titel": "Pegasus Spiele: Next Station Berlin",
"verlag": None, "autor": None, "konfidenz": 0.9, "begruendung": "passt",
}
transport = LlmTransport([identisch])
hilfe = ki_hilfe(transport)
treffer_liste, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
assert eingriffe == []
assert treffer_liste[0].quellen_url == "https://www.spielbox.de/x/1"
# ---------------- Sync-Integration + Audit ----------------
class StatischeQuelle:
"""Adapter-Ersatz mit fester Trefferliste (Service-Tests ohne Netz)."""
def __init__(self, name: str = "spielbox", treffer: list[QuellenTreffer] | None = None):
self.name = name
self.anzeigename = name.title()
self.start_urls = (f"https://{name}.example.org/liste",)
self._treffer = treffer if treffer is not None else []
def __call__(self, client):
return self
def sammle(self) -> SammelErgebnis:
return SammelErgebnis(treffer=list(self._treffer), seiten=1)
def _ohne_netz_client() -> WebQuellenClient:
return WebQuellenClient(transport=None, schlaf=lambda s: None, uhr=FakeUhr())
class FakeAudit:
def __init__(self):
self.eintraege: list[tuple] = []
def log_sync(self, actor, action, objekt_typ, objekt_id, details, **kwargs):
self.eintraege.append((actor, action, objekt_typ, objekt_id, details))
def test_sync_auditert_ki_eingriffe_mit_modell_und_konfidenz():
"""End-to-End: Crawler-Seite mit unsicherem Treffer → KI-Korrektur → Audit.
„Ringträger“ ohne Doppelpunkt: der Parser findet keinen Verlag → Treffer
ist unsicher → das LLM darf titel/verlag korrigieren.
"""
seite = (
'<div class="item"><div class="page-header"><h2>'
'<a href="/x/1">Ringträger</a></h2></div>'
"<p><strong>15.08.2026</strong> - Text.</p></div>"
)
crawler_client, _, _ = _crawler_client({BASIS_URL: seite})
audit = FakeAudit()
fabrik = _session_factory()
service = WebQuellenSyncService(
fabrik,
adapter_klassen=(SpielboxQuelle,),
client_fabrik=lambda validatoren: crawler_client,
ki_fabrik=lambda: ki_hilfe(LlmTransport([GUTE_FELDANTWORT])),
umgebung={
"SPIELE_NEUHEITEN_KI_AKTIV": "1",
"SPIELE_NEUHEITEN_KI_BASIS_URL": "https://llm.example.test/v1",
"SPIELE_NEUHEITEN_KI_API_KEY": "k",
"SPIELE_NEUHEITEN_KI_MODELL": "m",
},
audit=audit,
)
zeilen = service.laufe()
assert zeilen[0].neu == 1
with fabrik() as db:
eintrag = db.query(Neuheit).one()
assert eintrag.titel == "Next Station Berlin"
assert eintrag.verlag == "Pegasus Spiele"
assert eintrag.erscheinungsjahr == 2026
assert len(audit.eintraege) == 1
actor, action, objekt_typ, objekt_id, details = audit.eintraege[0]
assert action == "korrigiert" and objekt_typ == "neuheiten_ki_crawler"
assert objekt_id == "spielbox"
assert details["modell"] == "test-modell"
assert details["eingriffe"][0]["konfidenz"] == 0.9
assert details["eingriffe"][0]["korrigiert"]["verlag"] == "Pegasus Spiele"
def _session_factory():
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from redaktionskern.db import Base
engine = create_engine("sqlite://", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
fabrik = sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
fabrik.engine_zum_aufraeumen = engine # type: ignore[attr-defined]
return fabrik
def test_sync_ohne_ki_eingriff_schreibt_kein_audit():
audit = FakeAudit()
service = WebQuellenSyncService(
_session_factory(),
adapter_klassen=(StatischeQuelle("spielbox", [TREFFER_SICHER()]),),
client_fabrik=lambda v: _ohne_netz_client(),
ki_fabrik=None,
audit=audit,
)
zeilen = service.laufe()
assert zeilen[0].neu == 1
assert audit.eintraege == []
def test_ki_ausfall_blockiert_den_sync_nie():
"""KI-Fabrik crasht → Sync läuft trotzdem komplett klassisch."""
def kaputte_fabrik():
raise RuntimeError("LLM nicht erreichbar")
service = WebQuellenSyncService(
_session_factory(),
adapter_klassen=(StatischeQuelle("spielbox", [TREFFER_SICHER()]),),
client_fabrik=lambda v: _ohne_netz_client(),
ki_fabrik=kaputte_fabrik,
umgebung={
"SPIELE_NEUHEITEN_KI_AKTIV": "1",
"SPIELE_NEUHEITEN_KI_BASIS_URL": "u",
"SPIELE_NEUHEITEN_KI_API_KEY": "k",
"SPIELE_NEUHEITEN_KI_MODELL": "m",
},
)
zeilen = service.laufe()
assert zeilen[0].neu == 1 and zeilen[0].fehlermeldung is None
@pytest.fixture
def session_factory():
fabrik = _session_factory()
yield fabrik
fabrik.engine_zum_aufraeumen.dispose() # type: ignore[attr-defined]

View File

@@ -0,0 +1,900 @@
"""Tests für die Web-Quellen-Erweiterung des Neuheiten-Plugins.
Alle HTTP-Antworten sind gemockt (Fake-Transport / Client-Fabrik) — es
gibt keine echten Netzwerkaufrufe. Abgedeckt: Parser-Extraktion je
Quelle, Pagination-Durchlauf, 304-Behandlung (If-None-Match), Rate-
Limit, Fehler-Isolation pro Quelle, Duplikatprüfung gegen BGG-Titel,
Env-Schalter und Sync-Übersicht im UI.
"""
from __future__ import annotations
import json
import httpx
import pytest
from tests._neuheiten import (
ADAPTER_KLASSEN,
BrettspielboxQuelle,
CliquenabendQuelle,
Neuheit,
QuellenFehler,
QuellenStatus,
QuellenTreffer,
QuellenLaufZeile,
SammelErgebnis,
SpielEssenQuelle,
SpielboxQuelle,
USER_AGENT,
WebQuellenClient,
WebQuellenSyncService,
jahr_aus_datumsangabe,
quelle_aktiv,
titel_aehnlichkeit,
)
# ---------- Gemockte Seiten (realistische Struktur-Anleihen der Quellen) ----
SPIELBOX_SEITE_1 = b"""
<div class="blog">
<div class="item column-1">
<div class="page-header"><h2 itemprop="name">
<a href="/neuheiten-neue-spiele/1369-pegasus-next-station-berlin">Pegasus Spiele: Next Station Berlin</a>
</h2></div>
<p><strong>24.07.2026</strong> - <em>Next Station Berlin</em> von Matthew Dunstan, das neu bei Pegasus Spiele erschienen ist.</p>
</div>
<div class="item column-2">
<div class="page-header"><h2><a href="/neuheiten-neue-spiele/1247-micromacro-kids">MICROMACRO Kids</a></h2></div>
<p><strong>02.03.2026</strong> - Neu im M&auml;rz.</p>
</div>
</div>
<ul class="pagination"><li><a href="/neuheiten-neue-spiele?start=6">2</a></li></ul>
"""
SPIELBOX_SEITE_2 = b"""
<div class="item column-1">
<div class="page-header"><h2><a href="/neuheiten-neue-spiele/1123-schmidt-ringtraeger">Schmidt Spiele: Ringtr&auml;ger</a></h2></div>
<p><strong>15.08.2026</strong> - Neue Titel von Schmidt Spiele.</p>
</div>
<ul class="pagination"></ul>
"""
BRETTSPIELBOX_SEITE = """
<div class="wp-block-columns is-layout-flex">
<div class="wp-block-column">
<p>0-9</p>
<ul>
<li>-60 Grad (dlp games)</li>
<li>5Pairs (<a href="https://brettspielbox.de/vorschau-bsl/">BSL</a>)</li>
</ul>
<p>A</p>
<ul><li>Abaku (Albi)</li></ul>
</div>
<div class="wp-block-column">
<p>M</p>
<ul>
<li>Machu Pichu (HUCH!)</li>
<li>Titel ohne Verlag</li>
<li>Fadenfroh (1 More Time Games)</li>
</ul>
</div>
</div>
"""
SPIELESSEN_PRODUKTE = {
"path": "static/spiel26/x/logos/",
"picturePath": "static/spiel26/x/products/",
"products": [
{
"ID": "188",
"TITEL": "-60°",
"UNTERTITEL": "Nanox Games",
"FIRMA_ID": "436",
"INFO": (
"<table><tr><td>Autor: </td><td>Alexander Pfister &amp; Peter Prinz</td></tr>"
"<tr><td>Verlag: </td><td>Nanox Games</td></tr>"
"<tr><td>Vertrieb: </td><td>dlp games Verlag GmbH</td></tr>"
"<tr><td>Erscheinungsdatum:</td><td>10/2026</td></tr>"
"<tr><td>Spielalter:</td><td>ab 12 Jahren</td></tr></table>"
),
},
{"ID": "189", "TITEL": "", "INFO": ""},
{"ID": "190", "TITEL": "Nur Aussteller-Match", "UNTERTITEL": "", "FIRMA_ID": "777", "INFO": ""},
],
}
SPIELESSEN_AUSSTELLER = {"exhibitors": [{"ID": "777", "NAME": "Mysterium Verlag"}]}
CLIQUENABEND_SEITE = """
<table>
<tr><td>Update</td><td>Verlag</td><td>Stand</td><td>Spiel</td><td>Autor</td></tr>
<tr>
<td>15.10.25</td>
<td>
<div style="font-weight:bold;">Dranda Games</div>
<a class="grey" href="/profile/067900.html">Profil</a>,
<div style="font-weight:bold;">PD Verlag</div>
<a class="grey" href="/profile/152000.html">Profil</a>,
</td>
<td>3-H400</td>
<td><div><a href="/spiele/790610-Galactic-Cruise.html" target="_blank"><b style="font-size:1.1em;">Galactic Cruise</b></a>
<div class="gameinfos">100,- &euro;</div></div></td>
<td>T.K. King und Koltin Thompson</td>
</tr>
<tr><td>26.09.25</td><td>Piatnik Profil ,</td><td>6-G200</td>
<td><div><a href="/spiele/790700-Elixirus.html"><b>Elixirus</b></a></div></td>
<td>Dickie Chapin</td></tr>
<tr><td>01.10.25</td><td>ohne Link</td><td>&mdash;</td><td>Nur Texttitel</td><td>Unbekannt</td></tr>
</table>
"""
CLIQUENABEND_DETAIL_1 = """
<html><body>
<h1>Galactic Cruise</h1>
<div class="right_bar"><center>
<a href="/spiele/790610-Galactic-Cruise.html"><img src="/images/db/62452_360x240.jpg" border="0"></a><br>
</center></div>
<b>Release:</b><br>
2025 <br>
<b>Anzahl der Spieler:</b><br>
1 bis 4 Spieler<br>
</body></html>
"""
CLIQUENABEND_DETAIL_FEHLT = "<html><body><p>Seite ohne Release-Feld</p></body></html>"
class FakeUhr:
"""Steuerbare Monoton-Uhr."""
def __init__(self) -> None:
self.zeit = 0.0
def __call__(self) -> float:
return self.zeit
class KartenTransport(httpx.BaseTransport):
"""Antworten je URL; zeichnet Requests (URL, Header, Uhrzeit) auf."""
def __init__(self, antworten: dict[str, httpx.Response], uhr: FakeUhr):
self.antworten = antworten
self.uhr = uhr
self.anfragen: list[tuple[str, float]] = []
self.header: list[dict[str, str]] = []
def handle_request(self, request: httpx.Request) -> httpx.Response:
url = str(request.url)
self.anfragen.append((url, self.uhr()))
self.header.append(dict(request.headers))
# 1) exakte URL, 2) URL ohne Query (dynamische Parameter wie
# ?columns=… bei JSON-Endpunkten). URLs mit Query fallen NIE auf
# eine längere gemockte Basis-URL zurück (?start=N wäre sonst
# ein Präfix der Basis-URL und würde endlos Seite 1 liefern).
antwort = self.antworten.get(url)
if antwort is None and "?" in url:
antwort = self.antworten.get(url.split("?")[0])
if antwort is None and "?" not in url:
antwort = self.antworten.get(url.split("?")[0])
if antwort is None or isinstance(antwort, Exception):
raise QuellenFehler(f"Keine gemockte Antwort für {url}")
return antwort
def _antwort(inhalt: bytes | str, etag: str | None = None) -> httpx.Response:
header = {}
if etag:
header["ETag"] = etag
return httpx.Response(
200, content=inhalt.encode("utf-8") if isinstance(inhalt, str) else inhalt,
headers=header, request=httpx.Request("GET", "https://example.org/mock"),
)
def _client(transport, uhr, schlaf=None, validatoren=None) -> WebQuellenClient:
return WebQuellenClient(
transport=transport,
mindestabstand_sekunden=1.0,
schlaf=schlaf or (lambda s: None),
uhr=uhr,
validatoren=validatoren,
)
@pytest.fixture
def session_factory():
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from redaktionskern.db import Base
engine = create_engine("sqlite://", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
fabrik = sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
yield fabrik
engine.dispose()
def _adapter(klasse, seiten: dict[str, bytes | str], etag: str | None = None):
uhr = FakeUhr()
transport = KartenTransport(
{u: _antwort(i, etag) for u, i in seiten.items()}, uhr
)
return klasse(_client(transport, uhr)), transport
# ---------- Hilfsfunktionen ----------
def test_jahr_aus_datumsangabe():
assert jahr_aus_datumsangabe("24.07.2026") == 2026
assert jahr_aus_datumsangabe("10/2026") == 2026
assert jahr_aus_datumsangabe("Herbst 2026") == 2026
assert jahr_aus_datumsangabe("Q3 2026") == 2026
assert jahr_aus_datumsangabe(None) is None
assert jahr_aus_datumsangabe("ohne Jahr") is None
def test_titel_aehnlichkeit_erkennt_duplikate_keine_erweiterungen():
# Reale BGG-Duplikatfälle werden erkannt …
assert titel_aehnlichkeit("Die Siedler von Catan", "Catan") >= 92
assert titel_aehnlichkeit("Fadenfroh!", "Fadenfroh") >= 92
assert titel_aehnlichkeit("Machu Pichu", "Machu Picchu") >= 92
# … echte Erweiterungen/andere Spiele nicht.
assert titel_aehnlichkeit("Catan", "Catan: Erweiterung") < 92
assert titel_aehnlichkeit("Azul", "Azul: Summer Pavilion") < 92
# ---------- Parser je Quelle ----------
def test_parser_spielbox_extrahiert_titel_verlag_datum_url():
# Ohne Pagination-Link — der Parser-Test interessiert nur Seite 1.
seite_ohne_pagination = SPIELBOX_SEITE_1.replace(
b'<ul class="pagination"><li><a href="/neuheiten-neue-spiele?start=6">2</a></li></ul>',
b'<ul class="pagination"></ul>',
)
adapter, _ = _adapter(SpielboxQuelle, {"https://www.spielbox.de/neuheiten-neue-spiele": seite_ohne_pagination})
ergebnis = adapter.sammle()
assert len(ergebnis.treffer) == 2
erster = ergebnis.treffer[0]
assert erster.titel == "Pegasus Spiele: Next Station Berlin"
assert erster.verlag == "Pegasus Spiele"
assert erster.erscheinungsdatum_oder_quartal == "24.07.2026"
assert erster.quellen_url == (
"https://www.spielbox.de/neuheiten-neue-spiele/1369-pegasus-next-station-berlin"
)
assert ergebnis.treffer[1].verlag is None # kein Doppelpunkt im Titel
def test_parser_brettspielbox_zerlegt_titel_verlag_und_links():
adapter, _ = _adapter(
BrettspielboxQuelle,
{"https://brettspielbox.de/brettspiel-neuheiten-spiel-2026-herbst-2026-a-z/": BRETTSPIELBOX_SEITE},
)
ergebnis = adapter.sammle()
titel_verlage = {(t.titel, t.verlag) for t in ergebnis.treffer}
assert ("-60 Grad", "dlp games") in titel_verlage
assert ("5Pairs", "BSL") in titel_verlage
assert ("Abaku", "Albi") in titel_verlage
assert ("Titel ohne Verlag", None) in titel_verlage
# Eintrag mit Vorstellungs-Link nutzt diesen als quellen_url …
fuenf = next(t for t in ergebnis.treffer if t.titel == "5Pairs")
assert fuenf.quellen_url == "https://brettspielbox.de/vorschau-bsl/"
# … alle anderen die Listenseite.
ohne = next(t for t in ergebnis.treffer if t.titel == "Abaku")
assert ohne.quellen_url.endswith("a-z/")
def test_parser_spielessen_nutzt_json_api_endpunkt():
adapter, transport = _adapter(
lambda client: SpielEssenQuelle(client, jahr=2026),
{}, # Endpunkte werden dynamisch gebaut → direkt setzen
)
transport.antworten.update(
{
# Der Adapter hängt ?columns=… an — Mock matcht per Präfix.
"https://maps.eyeled-services.de/de/spiel26/products": _antwort(
json.dumps(SPIELESSEN_PRODUKTE)
),
"https://maps.eyeled-services.de/de/spiel26/exhibitors": _antwort(
json.dumps(SPIELESSEN_AUSSTELLER)
),
}
)
ergebnis = adapter.sammle()
assert len(ergebnis.treffer) == 2 # leerer TITEL wird übersprungen
erster = ergebnis.treffer[0]
assert erster.titel == "-60°"
assert erster.verlag == "Nanox Games"
assert erster.autor == "Alexander Pfister & Peter Prinz"
assert erster.erscheinungsdatum_oder_quartal == "10/2026"
assert erster.quellen_url == "https://spiel-essen.de/de/die-spiel/neuheiten"
# Aussteller-Fallback für Produkte ohne INFO-Verlag:
assert ergebnis.treffer[1].verlag == "Mysterium Verlag"
def test_parser_cliquenabend_liest_tabellenspalten():
adapter, _ = _adapter(
CliquenabendQuelle,
{
"https://www.cliquenabend.de/events/130000-SPIEL-2025-Essen.html": CLIQUENABEND_SEITE,
"https://www.cliquenabend.de/spiele/790610-Galactic-Cruise.html": CLIQUENABEND_DETAIL_1,
"https://www.cliquenabend.de/spiele/790700-Elixirus.html": CLIQUENABEND_DETAIL_FEHLT,
},
)
ergebnis = adapter.sammle()
assert [t.titel for t in ergebnis.treffer] == ["Galactic Cruise", "Elixirus"]
erster = ergebnis.treffer[0]
assert erster.verlag == "Dranda Games, PD Verlag"
assert erster.autor == "T.K. King und Koltin Thompson"
# Release-Feld der Detailseite schlägt das Update-Datum der Tabelle:
assert erster.erscheinungsdatum_oder_quartal == "2025"
assert erster.quellen_url == "https://www.cliquenabend.de/spiele/790610-Galactic-Cruise.html"
# Cover-Bild aus der Detailseite (statt Messe-Foto):
assert erster.bild_url == "https://www.cliquenabend.de/images/db/62452_360x240.jpg"
assert ergebnis.treffer[1].verlag == "Piatnik"
# Ohne Release-Feld auf der Detailseite fällt der Parser auf das
# Update-Datum zurück:
assert ergebnis.treffer[1].erscheinungsdatum_oder_quartal == "26.09.25"
def test_parser_cliquenabend_detailseite_fehler_blockiert_nicht():
adapter, _ = _adapter(
CliquenabendQuelle,
{
"https://www.cliquenabend.de/events/130000-SPIEL-2025-Essen.html": CLIQUENABEND_SEITE,
# Detailseiten nicht erreichbar → Update-Datum bleibt erhalten.
},
)
ergebnis = adapter.sammle()
assert len(ergebnis.treffer) == 2
assert ergebnis.treffer[0].erscheinungsdatum_oder_quartal == "15.10.25"
def test_parser_cliquenabend_toleriert_fehlende_tabelle():
adapter, _ = _adapter(
CliquenabendQuelle,
{"https://www.cliquenabend.de/events/130000-SPIEL-2025-Essen.html": "<html><body><p>Wartung</p></body></html>"},
)
ergebnis = adapter.sammle()
assert ergebnis.treffer == []
# ---------- Pagination ----------
def test_spielbox_durchlaeuft_alle_pagination_seiten():
basis_url = "https://www.spielbox.de/neuheiten-neue-spiele"
adapter, transport = _adapter(
SpielboxQuelle,
{basis_url: SPIELBOX_SEITE_1, f"{basis_url}?start=6": SPIELBOX_SEITE_2},
)
ergebnis = adapter.sammle()
assert len(ergebnis.treffer) == 3 # 2 + 1 über die zweite Seite
angefragt = [u for u, _ in transport.anfragen]
assert angefragt.count(basis_url) == 1
assert f"{basis_url}?start=6" in angefragt
# ---------- Rate-Limit & User-Agent ----------
def test_rate_limit_mindestens_eine_sekunde_zwischen_requests():
uhr = FakeUhr()
wartezeiten: list[float] = []
basis_url = "https://www.spielbox.de/neuheiten-neue-spiele"
transport = KartenTransport(
{basis_url: _antwort(SPIELBOX_SEITE_1), f"{basis_url}?start=6": _antwort(SPIELBOX_SEITE_2)},
uhr,
)
def schlaf(sekunden: float) -> None:
wartezeiten.append(sekunden)
uhr.zeit += sekunden
client = WebQuellenClient(transport=transport, mindestabstand_sekunden=1.0, schlaf=schlaf, uhr=uhr)
SpielboxQuelle(client).sammle()
assert len(transport.anfragen) == 2
erste_zeit = transport.anfragen[0][1]
zweite_zeit = transport.anfragen[1][1]
assert zweite_zeit - erste_zeit >= 1.0
assert wartezeiten and wartezeiten[0] > 0
def test_client_sendet_redaktions_user_agent():
uhr = FakeUhr()
basis_url = "https://www.spielbox.de/neuheiten-neue-spiele"
transport = KartenTransport({basis_url: _antwort(SPIELBOX_SEITE_1)}, uhr)
client = WebQuellenClient(transport=transport, schlaf=lambda s: None, uhr=uhr)
client.hole(basis_url)
assert transport.header[0]["user-agent"] == USER_AGENT
# ---------- ETag / 304 ----------
def test_etag_wird_gesendet_und_304_ueberspringt_die_quelle():
basis_url = "https://www.spielbox.de/neuheiten-neue-spiele"
uhr = FakeUhr()
transport = KartenTransport(
{basis_url: httpx.Response(
304, request=httpx.Request("GET", basis_url)
)},
uhr,
)
client = WebQuellenClient(
transport=transport, schlaf=lambda s: None, uhr=uhr,
validatoren={basis_url: ('"abc123"', None)},
)
ergebnis = SpielboxQuelle(client).sammle()
assert transport.header[0].get("if-none-match") == '"abc123"'
assert ergebnis.unveraendert is True
assert ergebnis.treffer == []
def test_etag_wird_aus_antwort_gespeichert():
basis_url = "https://www.spielbox.de/neuheiten-neue-spiele"
adapter, _ = _adapter(SpielboxQuelle, {basis_url: SPIELBOX_SEITE_1}, etag='"etag-42"')
adapter.client.hole(basis_url)
assert adapter.client.validatoren[basis_url][0] == '"etag-42"'
# ---------- Sync-Orchestrierung ----------
class StatischerAdapter:
"""Adapter-Ersatz mit fester Trefferliste (für Service-Tests)."""
def __init__(self, name: str, treffer: list[QuellenTreffer]):
self.name = name
self.anzeigename = name.title()
self.start_urls = (f"https://{name}.example.org/liste",)
self._treffer = treffer
def __call__(self, client):
return self
def sammle(self) -> SammelErgebnis:
return SammelErgebnis(treffer=list(self._treffer), seiten=1)
class KaputterAdapter:
def __init__(self, name: str):
self.name = name
self.anzeigename = name.title()
self.start_urls = (f"https://{name}.example.org/liste",)
def __call__(self, client):
return self
def sammle(self) -> SammelErgebnis:
raise QuellenFehler("Seite umgebaut")
def _service(session_factory, klassen, dedup=None):
return WebQuellenSyncService(
session_factory, adapter_klassen=klassen, dedup=dedup,
client_fabrik=lambda validatoren: WebQuellenClient(
schlaf=lambda s: None, uhr=FakeUhr(), validatoren=validatoren
),
)
def test_sync_legt_web_eintraege_mit_quelle_an(session_factory):
quelle = StatischerAdapter(
"spielbox",
[QuellenTreffer(titel="Ringträger", verlag="Schmidt Spiele",
autor=None, erscheinungsdatum_oder_quartal="15.08.2026",
quellen_url="https://www.spielbox.de/x")],
)
service = _service(session_factory, (quelle,))
zeilen = service.laufe()
assert zeilen[0].neu == 1
assert zeilen[0].fehlermeldung is None
with session_factory() as db:
eintrag = db.query(Neuheit).one()
assert eintrag.quelle == "spielbox"
assert eintrag.bgg_id is None
assert eintrag.status == "neuheit"
assert eintrag.erscheinungsjahr == 2026
statuszeile = db_status(session_factory, "spielbox")
assert statuszeile.anzahl_neu == 1
assert statuszeile.letzte_laufzeit is not None
def db_status(session_factory, quelle: str) -> QuellenStatus:
with session_factory() as db:
zeile = db.query(QuellenStatus).filter_by(quelle=quelle).one()
db.expunge(zeile)
return zeile
def test_sync_filtert_duplikate_gegen_bggtitel(session_factory):
with session_factory() as db:
db.add(Neuheit(titel="Catan", verlag="KOSMOS", autor="Klaus Teuber",
erscheinungsjahr=1995, bgg_id=13, quelle="boardgamegeek"))
db.commit()
quelle = StatischerAdapter(
"spielbox",
[
QuellenTreffer(titel="Die Siedler von Catan", verlag="KOSMOS",
quellen_url="https://www.spielbox.de/catan"),
QuellenTreffer(titel="Ein ganz neues Spiel", verlag="Anderer Verlag",
quellen_url="https://www.spielbox.de/neu"),
],
)
service = _service(session_factory, (quelle,))
zeilen = service.laufe()
assert zeilen[0].gefiltert == 1 # BGG-Duplikat per Titel-Match
assert zeilen[0].neu == 1
with session_factory() as db:
titel = {e.titel for e in db.query(Neuheit).all()}
assert titel == {"Catan", "Ein ganz neues Spiel"}
neu = db.query(Neuheit).filter_by(titel="Ein ganz neues Spiel").one()
assert neu.bgg_id is None and neu.quelle == "spielbox"
def test_zweiter_lauf_aktualisiert_statt_duplikat(session_factory):
quelle = StatischerAdapter(
"brettspielbox",
[QuellenTreffer(titel="Fadenfroh", verlag="1 More Time Games",
quellen_url="https://brettspielbox.de/fadenfroh")],
)
service = _service(session_factory, (quelle,))
service.laufe()
zeilen = service.laufe()
assert zeilen[0].aktualisiert == 1
assert zeilen[0].neu == 0
with session_factory() as db:
assert db.query(Neuheit).count() == 1
def test_dedup_konflikt_wird_gefiltert_registry_api(session_factory):
class FakeDedup:
async def check_titel(self, titel, verlag, bgg_id, **kwargs):
class Ergebnis:
hat_konflikte = titel.startswith("Konflikt")
return Ergebnis()
quelle = StatischerAdapter(
"cliquenabend",
[
QuellenTreffer(titel="Konfliktfall", verlag="V", quellen_url="u"),
QuellenTreffer(titel="Saubere Ware", verlag="V", quellen_url="u"),
],
)
service = _service(session_factory, (quelle,), dedup=FakeDedup())
zeilen = service.laufe()
assert zeilen[0].gefiltert == 1
assert zeilen[0].neu == 1
assert [e.titel for e in alle(session_factory)] == ["Saubere Ware"]
def test_dedup_ausfall_blockiert_nicht(session_factory):
class KaputterDedup:
async def check_titel(self, titel, verlag, bgg_id, **kwargs):
raise RuntimeError("dedup nicht erreichbar")
quelle = StatischerAdapter(
"cliquenabend",
[QuellenTreffer(titel="Läuft trotzdem", verlag="V", quellen_url="u")],
)
service = _service(session_factory, (quelle,), dedup=KaputterDedup())
zeilen = service.laufe()
assert zeilen[0].neu == 1
def alle(session_factory):
from sqlalchemy import select
with session_factory() as db:
return list(db.scalars(select(Neuheit)).all())
def test_fehler_isolation_eine_quelle_crasht_andere_laeuft_weiter(session_factory):
kaputt = KaputterAdapter("spielbox")
gut = StatischerAdapter(
"cliquenabend",
[QuellenTreffer(titel="Galactic Cruise", verlag="Dranda Games",
autor="T.K. King", erscheinungsdatum_oder_quartal="15.10.25",
quellen_url="https://www.cliquenabend.de/spiele/x")],
)
service = _service(session_factory, (kaputt, gut))
zeilen = service.laufe()
assert zeilen[0].fehleranzahl == 1
assert "Seite umgebaut" in zeilen[0].fehlermeldung
assert zeilen[1].neu == 1 # andere Quelle lief normal weiter
status_kaputt = db_status(session_factory, "spielbox")
assert status_kaputt.fehlermeldung and status_kaputt.anzahl_fehler == 1
status_gut = db_status(session_factory, "cliquenabend")
assert status_gut.anzahl_neu == 1 and not status_gut.fehlermeldung
def test_env_schalter_deaktiviert_einzelne_quelle(session_factory, monkeypatch):
monkeypatch.setenv("SPIELE_NEUHEITEN_QUELLE_SPIELBOX_AKTIV", "0")
aus = StatischerAdapter("spielbox", [QuellenTreffer(titel="X", quellen_url="u")])
an = StatischerAdapter("cliquenabend", [QuellenTreffer(titel="Y", quellen_url="u")])
class BeobachtenderService(WebQuellenSyncService):
aufgerufen = []
def _client_fabrik_guard(self): # pragma: no cover - nur Signal
raise AssertionError("ausgeschaltete Quelle darf keinen Client bauen")
service = _service(session_factory, (aus, an))
zeilen = service.laufe()
assert zeilen[0].aktiv is False
assert zeilen[0].neu == 0
assert zeilen[1].neu == 1 # angeschaltete Quelle läuft
assert quelle_aktiv("spielbox") is False
assert quelle_aktiv("brettspielbox") is True # Standard „1“
def test_ui_schalter_uebersteuert_env(session_factory, monkeypatch):
"""Der DB-Schalter schaltet eine per Env aktive Quelle ab (und umgekehrt)."""
monkeypatch.delenv("SPIELE_NEUHEITEN_QUELLE_SPIELBOX_AKTIV", raising=False)
spielbox = StatischerAdapter("spielbox", [QuellenTreffer(titel="X", quellen_url="u")])
service = _service(session_factory, (spielbox,))
# Standard: an
assert service.quelle_laeuft("spielbox") is True
# Schalter „aus“ → Sync überspringt die Quelle
service.schalter_gesetzt("spielbox", False)
assert service.quelle_laeuft("spielbox") is False
zeilen = service.laufe()
assert zeilen[0].aktiv is False and zeilen[0].neu == 0
# Schalter „an“ → läuft wieder
service.schalter_gesetzt("spielbox", True)
assert service.quelle_laeuft("spielbox") is True
zeilen = service.laufe()
assert zeilen[0].aktiv is True and zeilen[0].neu == 1
def test_einzellauf_ignoriert_schalter(session_factory, monkeypatch):
"""`laufе(nur_quellen=…)` läuft auch bei ausgeschalteter Quelle."""
monkeypatch.delenv("SPIELE_NEUHEITEN_QUELLE_SPIELBOX_AKTIV", raising=False)
spielbox = StatischerAdapter("spielbox", [QuellenTreffer(titel="X", quellen_url="u")])
andere = StatischerAdapter("cliquenabend", [QuellenTreffer(titel="Y", quellen_url="u")])
service = _service(session_factory, (spielbox, andere))
service.schalter_gesetzt("spielbox", False)
zeilen = service.laufe(nur_quellen={"spielbox"})
assert len(zeilen) == 1
assert zeilen[0].quelle == "spielbox"
assert zeilen[0].aktiv is True
assert zeilen[0].neu == 1
def test_validatoren_werden_persistiert_und_wieder_verwendet(session_factory):
"""304-Behandlung über Läufe hinweg: ETag wird gespeichert und gesendet."""
import tests._neuheiten as h
basis_url = "https://www.spielbox.de/neuheiten-neuheiten"
uhr = FakeUhr()
class ValidatorenTransport(httpx.BaseTransport):
def __init__(self):
self.etag_gesendet: list[str | None] = []
self.runde = 0
def handle_request(self, request):
self.etag_gesendet.append(request.headers.get("if-none-match"))
self.runde += 1
if self.runde == 1:
return httpx.Response(200, content=b"<div class='item'></div>",
headers={"ETag": '"runde-1"'},
request=httpx.Request("GET", basis_url))
return httpx.Response(304, request=httpx.Request("GET", basis_url))
transport = ValidatorenTransport()
def fabrik(validatoren):
return WebQuellenClient(transport=transport, schlaf=lambda s: None,
uhr=uhr, validatoren=validatoren)
quelle = type("SpielboxPersistenz", (), {
"name": "spielbox",
"anzeigename": "Spielbox",
"start_urls": (basis_url,),
})
# Adapter-Klasse so bauen, dass sie den Transport-Parser nutzt:
from tests._neuheiten import SpielboxQuelle as SB
class PersistenteSpielbox(SB):
pass
PersistenteSpielbox.name = "spielbox"
PersistenteSpielbox.start_urls = (basis_url,)
service = WebQuellenSyncService(
session_factory, adapter_klassen=(PersistenteSpielbox,),
dedup=None, client_fabrik=fabrik,
)
erste = service.laufe()
zweite = service.laufe()
assert erste[0].unveraendert is False
assert transport.etag_gesendet[0] is None
assert zweite[0].unveraendert is True # 304 → Quelle übersprungen
assert transport.etag_gesendet[1] == '"runde-1"' # gespeicherter Validator
statuszeile = db_status(session_factory, "spielbox")
assert statuszeile.validatoren[basis_url][0] == '"runde-1"'
# ---------- UI-Übersicht ----------
def _test_app_und_client(monkeypatch, tmp_path):
from fastapi.testclient import TestClient
from redaktionskern.app import create_app
from redaktionskern.config import Settings
from tests.conftest import ADMIN_PASSWORD, PROJEKT_WURZEL
settings = Settings(
database_url=f"sqlite:///{tmp_path/'q.db'}",
session_secret="test-secret",
initial_admin_password=ADMIN_PASSWORD,
plugins_dir=PROJEKT_WURZEL / "plugins",
)
app = create_app(settings)
client = TestClient(app)
antwort = client.post("/login", data={"username": "admin", "password": ADMIN_PASSWORD},
follow_redirects=False)
assert antwort.status_code == 303
return app, client
def test_ui_zeigt_sync_uebersicht_der_quellen(monkeypatch, tmp_path):
monkeypatch.setenv("SPIELE_NEUHEITEN_QUELLE_SPIELBOX_AKTIV", "0")
app, client = _test_app_und_client(monkeypatch, tmp_path)
antwort = client.get("/neuheiten")
assert antwort.status_code == 200
for text in ("Web-Quellen", "spielbox.de Neuheiten", "Brettspielbox Messevorschau",
"SPIEL Essen Neuheiten", "Cliquenabend Messe-Übersicht"):
assert text in antwort.text, f"Fehlt: {text!r}"
def test_manueller_quellen_sync_endpunkt_mit_fakes(monkeypatch, tmp_path):
app, client = _test_app_und_client(monkeypatch, tmp_path)
plugin = app.state.registry.get("neuheiten")
fake_zeile = QuellenLaufZeile("spielbox", "spielbox.de Neuheiten")
fake_zeile.neu = 3
monkeypatch.setattr(plugin, "_quellen_sync_ausfuehren", lambda: [fake_zeile])
antwort = client.post("/neuheiten/quellen-sync", follow_redirects=False)
assert antwort.status_code == 303
assert "/neuheiten" in antwort.headers["location"]
# Meldung steckt im Redirect-Ziel — der Folge-Request muss die
# Location (inkl. ?meldung=…) abrufen, nicht die nackte Liste.
folge = client.get(antwort.headers["location"])
assert "Web-Quellen-Sync abgeschlossen" in folge.text
assert "3 neu" in folge.text
def test_rezensent_kann_quellen_sync_nicht_starten(monkeypatch, tmp_path):
from tests.conftest import lege_benutzer_an, melde_an
app, client = _test_app_und_client(monkeypatch, tmp_path)
lege_benutzer_an(app, "resi", "rezensent")
melde_an(client, username="resi", password="test-12345678")
antwort = client.post("/neuheiten/quellen-sync", follow_redirects=False)
assert antwort.status_code == 403
# ---------- Migration: bgg_id nullable ----------
def test_migration_0002_macht_bgg_id_nullable_erhaelt_daten(tmp_path):
"""Bestehende DB mit NOT NULL-bgg_id wird migriert, Daten bleiben erhalten."""
from sqlalchemy import create_engine, inspect, text
from redaktionskern.migrationen import run_migrations
from tests._neuheiten import models as m
engine = create_engine(f"sqlite:///{tmp_path/'mig.db'}")
with engine.begin() as conn:
conn.exec_driver_sql("""
CREATE TABLE neuheiten (
id INTEGER NOT NULL PRIMARY KEY,
titel VARCHAR(300) NOT NULL,
verlag VARCHAR(300),
autor VARCHAR(300),
erscheinungsjahr INTEGER,
bgg_id INTEGER NOT NULL,
status VARCHAR(50),
quelle VARCHAR(100),
erstellt_am DATETIME DEFAULT (CURRENT_TIMESTAMP),
aktualisiert_am DATETIME DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT uq_bgg UNIQUE (bgg_id)
)
""")
conn.exec_driver_sql(
"INSERT INTO neuheiten (titel, bgg_id, status, quelle) "
"VALUES ('Catan', 13, 'neuheit', 'boardgamegeek')"
)
plugin_name = "neuheiten"
plugin_instanz = _plugin_instance()
migrationen = plugin_instanz.migrations()
nur_alt = [migr for migr in migrationen if migr.version == "0001_neuheiten_tabelle"]
with engine.begin() as conn:
run_migrations(conn, plugin_name, nur_alt) # Stand: alte Version
with engine.begin() as conn:
neue = run_migrations(conn, plugin_name, migrationen)
assert "0002_bgg_id_nullable" in neue
inspektor = inspect(engine)
spalten = {c["name"]: c for c in inspektor.get_columns("neuheiten")}
assert spalten["bgg_id"]["nullable"] is True
with engine.connect() as conn:
zeile = conn.execute(text("SELECT titel, bgg_id FROM neuheiten")).one()
assert zeile == ("Catan", 13)
# NULL-bgg_id ist mehrfach erlaubt:
conn.execute(text(
"INSERT INTO neuheiten (titel, bgg_id, status, quelle) "
"VALUES ('Web-Titel A', NULL, 'neuheit', 'spielbox')"
))
conn.execute(text(
"INSERT INTO neuheiten (titel, bgg_id, status, quelle) "
"VALUES ('Web-Titel B', NULL, 'neuheit', 'spielbox')"
))
def _plugin_instance():
from tests._neuheiten import sys_modules_plugin
return sys_modules_plugin()
def test_migration_0003_legt_quellen_status_tabelle_an(tmp_path):
from sqlalchemy import create_engine, inspect
from redaktionskern.migrationen import run_migrations
engine = create_engine(f"sqlite:///{tmp_path/'mig3.db'}")
plugin_instanz = _plugin_instance()
with engine.begin() as conn:
run_migrations(conn, "neuheiten", plugin_instanz.migrations())
inspektor = inspect(engine)
assert "neuheiten_quellen_status" in inspektor.get_table_names()
# ---------- Registrierung ----------
def test_alle_vier_quellen_registriert():
namen = {klasse.name for klasse in ADAPTER_KLASSEN}
assert namen == {"spielbox", "brettspielbox", "spielessen", "cliquenabend"}

View File

@@ -8,8 +8,10 @@ from sqlalchemy.orm import sessionmaker
from redaktionskern.db import Base from redaktionskern.db import Base
from tests._neuheiten import ( from tests._neuheiten import (
BggAuthFehler,
BggFehler, BggFehler,
BggSpiel, BggSpiel,
MELDUNG_401,
Neuheit, Neuheit,
SuchTreffer, SuchTreffer,
SyncService, SyncService,
@@ -92,7 +94,9 @@ def test_sync_legt_eintraege_an(session_factory):
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]}, suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()}, details_pro_id={13: _spiel()},
) )
service = SyncService(session_factory, client) service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["catan"]) ergebnis = service.synchronisiere(["catan"])
@@ -115,7 +119,9 @@ def test_sync_aktualisiert_bestehende_eintraege_statt_duplikate(session_factory)
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]}, suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()}, details_pro_id={13: _spiel()},
) )
service = SyncService(session_factory, client) service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
service.synchronisiere(["catan"]) service.synchronisiere(["catan"])
@@ -140,7 +146,9 @@ def test_sync_veraenderter_titel_gleiche_bgg_id_kein_duplikat(session_factory):
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]}, suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()}, details_pro_id={13: _spiel()},
) )
service = SyncService(session_factory, client) service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
service.synchronisiere(["catan"]) service.synchronisiere(["catan"])
client.details_pro_id[13] = _spiel(titel="Catan (Neuausgabe)") client.details_pro_id[13] = _spiel(titel="Catan (Neuausgabe)")
@@ -174,7 +182,9 @@ def test_sync_filtert_erweiterungen(session_factory):
55: _spiel(55, "Catan: Erweiterung", typ="boardgameexpansion"), 55: _spiel(55, "Catan: Erweiterung", typ="boardgameexpansion"),
}, },
) )
service = SyncService(session_factory, client) service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["catan"]) ergebnis = service.synchronisiere(["catan"])
@@ -198,7 +208,9 @@ def test_sync_filtert_prototypen(session_factory):
78: _spiel(78, "My Prototype Game"), 78: _spiel(78, "My Prototype Game"),
}, },
) )
service = SyncService(session_factory, client) service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["essen"]) ergebnis = service.synchronisiere(["essen"])
@@ -214,9 +226,91 @@ def test_prototyp_heuristik():
assert not ist_moeglicher_prototyp("Die Burgen von Burgund") assert not ist_moeglicher_prototyp("Die Burgen von Burgund")
# ---------- Filter: Jahr und deutscher Titel ----------
def test_sync_filtert_nach_jahr_und_deutschem_titel(session_factory):
aktuelles = 2026
client = FakeBggClient(
suchergebnisse={
"mix": [
SuchTreffer(13, "Catan", aktuelles - 1),
SuchTreffer(42, "Die neuen Siedler", aktuelles),
SuchTreffer(99, "Future Game", aktuelles + 1),
SuchTreffer(88, "The Old English Game", aktuelles),
]
},
details_pro_id={
13: _spiel(13, "Catan", erscheinungsjahr=aktuelles - 1),
42: _spiel(42, "Die neuen Siedler", erscheinungsjahr=aktuelles),
99: _spiel(99, "Future Game", erscheinungsjahr=aktuelles + 1),
88: _spiel(88, "The Old English Game", erscheinungsjahr=aktuelles),
},
)
service = SyncService(session_factory, client)
service._min_jahr = aktuelles
ergebnis = service.synchronisiere(["mix"])
assert ergebnis.neu == 1 # nur "Die neuen Siedler" überlebt beide Filter
assert ergebnis.gefiltert == 3 # 1 zu alt + 1 zukünftig aber englisch + 1 aktuell aber englisch
eintraege = _alle_eintraege(session_factory)
assert [e.bgg_id for e in eintraege] == [42]
def test_sync_ohne_filter_behält_alle_jahre_und_sprachen(session_factory):
client = FakeBggClient(
suchergebnisse={
"mix": [
SuchTreffer(13, "Catan", 1995),
SuchTreffer(88, "The English Game", 2026),
]
},
details_pro_id={
13: _spiel(13, "Catan", erscheinungsjahr=1995),
88: _spiel(88, "The English Game", erscheinungsjahr=2026),
},
)
service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["mix"])
assert ergebnis.neu == 2
assert ergebnis.gefiltert == 0
# ---------- Robustheit ---------- # ---------- Robustheit ----------
def test_401_bricht_sync_ab_mit_klarer_meldung(session_factory):
"""BGG-401: Lauf stoppt, Meldung landet im Ergebnis, Teilergebnisse bleiben."""
class AuthKaputterClient(FakeBggClient):
def suche(self, suchbegriff: str, *, max_treffer: int | None = None):
self.suche_aufrufe.append(suchbegriff)
if suchbegriff == "abgelehnt":
raise BggAuthFehler(MELDUNG_401)
return list(self.suchergebnisse.get(suchbegriff, []))
client = AuthKaputterClient(
suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()},
)
service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["gut", "abgelehnt", "dritter"])
assert ergebnis.abbruch == MELDUNG_401
assert ergebnis.als_text() == MELDUNG_401 # klare Meldung statt Fehlerzähler
assert ergebnis.neu == 1 # Teilergebnis vor dem 401 bleibt erhalten
assert client.suche_aufrufe == ["gut", "abgelehnt"] # kein weiterer Begriff
assert len(_alle_eintraege(session_factory)) == 1 # Commit trotz Abbruch
def test_fehler_bei_einem_suchbegriff_bricht_lauf_nicht_ab(session_factory): def test_fehler_bei_einem_suchbegriff_bricht_lauf_nicht_ab(session_factory):
class HalbKaputterClient(FakeBggClient): class HalbKaputterClient(FakeBggClient):
def suche(self, suchbegriff: str, *, max_treffer: int | None = None): def suche(self, suchbegriff: str, *, max_treffer: int | None = None):
@@ -228,7 +322,9 @@ def test_fehler_bei_einem_suchbegriff_bricht_lauf_nicht_ab(session_factory):
suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]}, suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()}, details_pro_id={13: _spiel()},
) )
service = SyncService(session_factory, client) service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["kaputt", "gut"]) ergebnis = service.synchronisiere(["kaputt", "gut"])

View File

@@ -4,11 +4,19 @@ Alle BGG-Antworten sind gemockt (FakeBggClient), kein echter Netzwerkverkehr.
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from redaktionskern.app import create_app from redaktionskern.app import create_app
from tests._neuheiten import BggSpiel, Neuheit, SuchTreffer from tests._neuheiten import (
BggAuthFehler,
BggSpiel,
MELDUNG_401,
Neuheit,
SuchTreffer,
)
from tests.conftest import ADMIN_PASSWORD, lege_benutzer_an, melde_an from tests.conftest import ADMIN_PASSWORD, lege_benutzer_an, melde_an
@@ -33,6 +41,18 @@ class FakeBggClient:
pass pass
class FehlerwerfenderFake(FakeBggClient):
"""Wirft bei der Suche einen Fehler (z. B. BggAuthFehler bei HTTP 401)."""
def __init__(self, fehler: Exception):
super().__init__()
self.fehler = fehler
def suche(self, suchbegriff: str, *, max_treffer: int | None = None):
self.suche_aufrufe.append(suchbegriff)
raise self.fehler
def _client_mit_fakes(monkeypatch, client: TestClient, fake: FakeBggClient) -> None: def _client_mit_fakes(monkeypatch, client: TestClient, fake: FakeBggClient) -> None:
plugin = client.app.state.registry.get("neuheiten") plugin = client.app.state.registry.get("neuheiten")
# Instanz-Methode ersetzen; monkeypatch stellt nach dem Test wieder her # Instanz-Methode ersetzen; monkeypatch stellt nach dem Test wieder her
@@ -59,7 +79,7 @@ def test_liste_zeigt_eintraege_und_deutsche_beschriftung(app, client):
db.commit() db.commit()
melde_an(client) melde_an(client)
antwort = client.get("/neuheiten") antwort = client.get("/neuheiten?jahr=1990")
assert antwort.status_code == 200 assert antwort.status_code == 200
for text in ( for text in (
@@ -117,12 +137,31 @@ def test_sortierung_nach_jahr_abwaerts(app, client):
db.commit() db.commit()
melde_an(client) melde_an(client)
antwort = client.get("/neuheiten?sort=jahr&richtung=ab") antwort = client.get("/neuheiten?sort=jahr&richtung=ab&jahr=1990")
reihenfolge = [zeile for zeile in ("Neu", "Mittel", "Alt") if zeile] reihenfolge = [zeile for zeile in ("Neu", "Mittel", "Alt") if zeile]
position = [antwort.text.index(titel) for titel in reihenfolge] position = [antwort.text.index(titel) for titel in reihenfolge]
assert position == sorted(position) assert position == sorted(position)
def test_standard_jahr_filter_zeigt_nur_aktuelles_jahr_und_neuer(app, client):
"""Ohne jahr-Parameter: nur aktuelles Jahr und neuer (plus ohne Jahr)."""
with app.state.session_factory() as db:
db.add_all(
[
Neuheit(titel="Alt", bgg_id=1, erscheinungsjahr=1990),
Neuheit(titel="Aktuell", bgg_id=2, erscheinungsjahr=datetime.now(timezone.utc).year),
Neuheit(titel="Ohne Jahr", bgg_id=3),
]
)
db.commit()
melde_an(client)
antwort = client.get("/neuheiten")
assert "Aktuell" in antwort.text
assert "Ohne Jahr" in antwort.text
assert ">Alt<" not in antwort.text
def test_unbekannte_sortierung_faellt_auf_standard_zurueck(client): def test_unbekannte_sortierung_faellt_auf_standard_zurueck(client):
melde_an(client) melde_an(client)
antwort = client.get("/neuheiten?sort=hack&richtung=auf") antwort = client.get("/neuheiten?sort=hack&richtung=auf")
@@ -155,6 +194,8 @@ def test_sync_endpunkt_rollen_pruefung(app, settings):
def test_manueller_sync_legt_eintraege_an(monkeypatch, settings): def test_manueller_sync_legt_eintraege_an(monkeypatch, settings):
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
monkeypatch.setenv("SPIELE_BGG_JAHR_FILTER", "0")
monkeypatch.setenv("SPIELE_BGG_DEUTSCHE_TITEL_FILTER", "0")
fake = FakeBggClient( fake = FakeBggClient(
treffer=[SuchTreffer(13, "Catan", 1995)], treffer=[SuchTreffer(13, "Catan", 1995)],
spiel_details=[ spiel_details=[
@@ -165,6 +206,7 @@ def test_manueller_sync_legt_eintraege_an(monkeypatch, settings):
], ],
) )
monkeypatch.setenv("SPIELE_BGG_TOKEN", "test-token")
with TestClient(create_app(settings)) as client: with TestClient(create_app(settings)) as client:
melde_an(client) melde_an(client)
_client_mit_fakes(monkeypatch, client, fake) _client_mit_fakes(monkeypatch, client, fake)
@@ -193,6 +235,7 @@ def test_manueller_sync_ohne_suchbegriff_nutzt_konfigurierte_begriffe(monkeypatc
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
monkeypatch.setenv("SPIELE_BGG_SUCHBEGRIFFE", "essen, familien") monkeypatch.setenv("SPIELE_BGG_SUCHBEGRIFFE", "essen, familien")
monkeypatch.setenv("SPIELE_BGG_TOKEN", "test-token")
fake = FakeBggClient() fake = FakeBggClient()
with TestClient(create_app(settings)) as client: with TestClient(create_app(settings)) as client:
melde_an(client) melde_an(client)
@@ -201,6 +244,53 @@ def test_manueller_sync_ohne_suchbegriff_nutzt_konfigurierte_begriffe(monkeypatc
assert fake.suche_aufrufe == ["essen", "familien"] assert fake.suche_aufrufe == ["essen", "familien"]
def test_sync_ohne_token_wird_uebersprungen(monkeypatch, settings):
from fastapi.testclient import TestClient
monkeypatch.delenv("SPIELE_BGG_TOKEN", raising=False)
fake = FakeBggClient(treffer=[SuchTreffer(13, "Catan", 1995)])
with TestClient(create_app(settings)) as client:
melde_an(client)
_client_mit_fakes(monkeypatch, client, fake)
antwort = client.post(
"/neuheiten/sync",
data={"suchbegriff": "catan"},
follow_redirects=False,
)
assert antwort.status_code == 303
folge = client.get(antwort.headers["location"])
assert (
"Kein BGG-API-Token konfiguriert (SPIELE_BGG_TOKEN) — "
"Sync übersprungen." in folge.text
)
assert fake.suche_aufrufe == [] # kein Request an die BGG-API gefeuert
def test_sync_401_zeigt_klare_fehlermeldung(monkeypatch, settings):
from fastapi.testclient import TestClient
monkeypatch.setenv("SPIELE_BGG_TOKEN", "falscher-token")
fake = FehlerwerfenderFake(BggAuthFehler(MELDUNG_401))
with TestClient(create_app(settings)) as client:
melde_an(client)
_client_mit_fakes(monkeypatch, client, fake)
antwort = client.post(
"/neuheiten/sync",
data={"suchbegriff": "catan"},
follow_redirects=False,
)
assert antwort.status_code == 303
folge = client.get(antwort.headers["location"])
assert MELDUNG_401 in folge.text
assert "Unerwartete HTTP-Antwort" not in folge.text
def test_htmx_request_liefert_nur_teilfragment(app, client): def test_htmx_request_liefert_nur_teilfragment(app, client):
melde_an(client) melde_an(client)
antwort = client.get("/neuheiten", headers={"HX-Request": "true"}) antwort = client.get("/neuheiten", headers={"HX-Request": "true"})

View File

@@ -174,7 +174,10 @@ def test_migration_legt_planungsliste_an(app):
text("SELECT version FROM schema_migrations WHERE plugin='planung'") text("SELECT version FROM schema_migrations WHERE plugin='planung'")
).fetchall() ).fetchall()
assert "planungsliste" in tabellen assert "planungsliste" in tabellen
assert [z[0] for z in protokoll] == ["0001_planungsliste"] # 0002_bild_url + 0003_quellen_url: Coverbild- und Quellen-Link-Spalten.
assert [z[0] for z in protokoll] == [
"0001_planungsliste", "0002_bild_url", "0003_quellen_url",
]
def test_anonym_wird_zum_login_umgeleitet(client): def test_anonym_wird_zum_login_umgeleitet(client):

View File

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

381
uv.lock generated
View File

@@ -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,92 @@ 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 = "beautifulsoup4"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" },
]
[[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 +323,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 +352,74 @@ 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]]
name = "freezegun"
version = "1.5.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
]
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.5.5" version = "3.5.5"
@@ -517,6 +685,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 +905,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 +923,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"
@@ -677,6 +948,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
] ]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.3" version = "1.2.3"
@@ -829,6 +1112,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" }, { url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" },
] ]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "soupsieve"
version = "2.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" },
]
[[package]] [[package]]
name = "spiele-redaktion" name = "spiele-redaktion"
version = "0.1.0" version = "0.1.0"
@@ -836,6 +1137,7 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },
{ name = "argon2-cffi" }, { name = "argon2-cffi" },
{ name = "beautifulsoup4" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "itsdangerous" }, { name = "itsdangerous" },
@@ -844,10 +1146,12 @@ 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]
dev = [ dev = [
{ name = "freezegun" },
{ name = "httpx" }, { name = "httpx" },
{ name = "pytest" }, { name = "pytest" },
] ]
@@ -856,6 +1160,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "apscheduler", specifier = ">=3.11,<4" }, { name = "apscheduler", specifier = ">=3.11,<4" },
{ name = "argon2-cffi", specifier = ">=23.1" }, { name = "argon2-cffi", specifier = ">=23.1" },
{ name = "beautifulsoup4", specifier = ">=4.12" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.27" }, { name = "httpx", specifier = ">=0.27" },
{ name = "itsdangerous", specifier = ">=2.1" }, { name = "itsdangerous", specifier = ">=2.1" },
@@ -864,10 +1169,12 @@ 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]
dev = [ dev = [
{ name = "freezegun", specifier = ">=1.5.5" },
{ name = "httpx", specifier = ">=0.27" }, { name = "httpx", specifier = ">=0.27" },
{ name = "pytest", specifier = ">=8.0" }, { name = "pytest", specifier = ">=8.0" },
] ]
@@ -927,6 +1234,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 +1465,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 +1607,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" },
]