Compare commits
28 Commits
0f12cd952b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
929cea1887 | ||
|
|
d91402881a | ||
|
|
ca6d94938c | ||
|
|
667196a4ba | ||
|
|
8b38bc6c89 | ||
|
|
199f8b080d | ||
|
|
aea52d1563 | ||
|
|
fa453518d4 | ||
|
|
96d6af8163 | ||
|
|
20b84a8e1a | ||
|
|
f27940db8e | ||
|
|
c6f9b19f6b | ||
|
|
3b0626c774 | ||
|
|
8e5f65d864 | ||
|
|
b6d67e04e5 | ||
|
|
8c9f542b8c | ||
|
|
3de8fa5227 | ||
|
|
aad146be93 | ||
|
|
6950cffd4d | ||
|
|
07e49a530e | ||
|
|
60c4b6e818 | ||
|
|
55f6f32f73 | ||
|
|
3f8f67994e | ||
|
|
6b2e04d782 | ||
|
|
1d4cd400a3 | ||
|
|
469cf8826e | ||
|
|
d024f42f3c | ||
|
|
72fe3b7eda |
9
.env
Normal file
9
.env
Normal 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
|
||||
@@ -1,5 +1,11 @@
|
||||
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
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
|
||||
18
LICENSE
Normal file
18
LICENSE
Normal 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
226
README.en.md
Normal 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
352
README.md
@@ -1,10 +1,13 @@
|
||||
# Spiele-Redaktion
|
||||
|
||||
> **Deutsch:** [README.md](README.md) · **English:** [README.en.md](README.en.md)
|
||||
|
||||
**KI-Assistenz für Spielemagazin-Redaktionen** — Multi-User-Webanwendung mit
|
||||
modularer Plugin-Architektur. Lauffähiger Kern mit Plugin-System,
|
||||
Authentifizierung/Rollen und Migrationen; vollständig implementiert sind
|
||||
bisher **Audit-Log**, **Benachrichtigung**, **Neuheiten (BGG-Sync)**,
|
||||
**Dedup-Prüfung** und **Planungsliste**.
|
||||
**Dedup-Prüfung**, **Planungsliste**, **Archiv (12-Monats-Autopilot)**,
|
||||
**Erinnerungen** und **Export (CSV/PDF)**.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -14,6 +17,7 @@ bisher **Audit-Log**, **Benachrichtigung**, **Neuheiten (BGG-Sync)**,
|
||||
- **Plugins:** Entry-Points (`importlib.metadata`, Gruppe `spiele_redaktion.plugins`)
|
||||
plus lokales `plugins/`-Verzeichnis
|
||||
- **Hintergrund-Jobs:** APScheduler (BGG-Neuheiten-Sync)
|
||||
- **PDF-Export:** WeasyPrint (System-Bibliotheken: Pango/fontconfig)
|
||||
- **Dependencies/Tests:** [uv](https://docs.astral.sh/uv/) + pytest
|
||||
|
||||
## Setup
|
||||
@@ -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_BGG_SYNC_AKTIV` | `1` | Hintergrund-Sync des Neuheiten-Plugins an (`1`) oder aus (`0`) |
|
||||
| `SPIELE_BGG_SYNC_INTERVALL_STUNDEN` | `24` | Intervall des BGG-Syncs in Stunden (min. 1) |
|
||||
| `SPIELE_BGG_SUCHBEGRIFFE` | `brettspiel` | Komma-getrennte Suchbegriffe für den regelmäßigen Sync |
|
||||
| `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `25` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) |
|
||||
| `SPIELE_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` | `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_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
|
||||
@@ -75,6 +98,34 @@ Fake-BGG-Client, Prüfprotokoll, Prüfseite) und **planung** (beide Eintragswege
|
||||
mit Stub-gemockter dedup-/Benachrichtigungs-/Audit-API, Verlags-Auswahl-
|
||||
Dialog, Statuswechsel, Bearbeiten/Löschen, Rechte pro Rolle) plus ein
|
||||
Integrationstest mit den echten Plugins (In-App-Nachricht + Audit-Eintrag).
|
||||
Dazu 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
|
||||
|
||||
@@ -94,7 +145,9 @@ plugins/ ein Ordner pro Funktion, ladbar über den Plugin-Load
|
||||
├── benachrichtigung/ VOLL IMPLEMENTIERT: E-Mail/Telegram/In-App
|
||||
├── dedup/ VOLL IMPLEMENTIERT: check_titel-API, Heuristik, Protokoll
|
||||
├── planung/ VOLL IMPLEMENTIERT: Planungsliste, Verschiebung, Dialoge
|
||||
├── archiv/ erinnerung/ export/ (Stubs, ladbar)
|
||||
├── archiv/ VOLL IMPLEMENTIERT: 12-Monats-Autopilot, Job, UI
|
||||
├── erinnerung/ VOLL IMPLEMENTIERT: Ausgaben, 4-Wochen-Erinnerung, Tages-Job
|
||||
├── export/ VOLL IMPLEMENTIERT: CSV/PDF-Downloads, Rollen, Audit
|
||||
└── … je __init__.py + templates/<name>/
|
||||
```
|
||||
|
||||
@@ -122,7 +175,9 @@ class MeinPlugin(BasePlugin):
|
||||
return [NavEntry(label=self.title, url="/meinplugin")]
|
||||
|
||||
def on_load(self, context): # Lifecycle-Hook beim Start
|
||||
super().on_load(context) # context.engine/.session_factory/.templates/.settings
|
||||
super().on_load(context) # context.engine/.session_factory/.templates/
|
||||
# .settings/.registry (Plugin-Registry für
|
||||
# Hintergrund-Jobs ohne Request)
|
||||
|
||||
def on_unload(self): # Lifecycle-Hook beim Herunterfahren
|
||||
super().on_unload()
|
||||
@@ -222,7 +277,8 @@ manuelle Synchronisation für die Redaktion, deutsche sortier-/filterbare Liste.
|
||||
|
||||
### 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):
|
||||
|
||||
| Spalte | Inhalt |
|
||||
@@ -231,6 +287,7 @@ Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten`
|
||||
| `verlag` / `autor` | Verlag bzw. Autor(en), kommagetrennt |
|
||||
| `erscheinungsjahr` | Erscheinungsjahr — BGG liefert nur das Jahr, kein genaues Datum |
|
||||
| `bgg_id` | BoardGameGeek-ID, eindeutig → Merge-Kriterium des Syncs |
|
||||
| `bild_url` | URL des Coverbildes (nullable; BGG-Image/-Thumbnail oder Bild der Web-Quelle) |
|
||||
| `status` | `neuheit` (spätere Plugins: Planung/Archiv) |
|
||||
| `quelle` | Datenquelle (`boardgamegeek`) |
|
||||
| `erstellt_am` / `aktualisiert_am` | Zeitstempel |
|
||||
@@ -240,6 +297,14 @@ Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten`
|
||||
Pro Suchbegriff: `/search?type=boardgame&query=…` → IDs sammeln →
|
||||
`/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.
|
||||
- **Retry mit Backoff:** exponentielles Zurückhalten bei 5xx, 429 und
|
||||
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
|
||||
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
|
||||
|
||||
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.
|
||||
- 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; 1–2 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)
|
||||
|
||||
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
|
||||
`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
|
||||
|
||||
Jede `check_titel`-Prüfung landet in der eigenen Migration
|
||||
@@ -324,12 +471,14 @@ laufen automatisch durch die dedup-Prüfung.
|
||||
|
||||
### Datentabelle
|
||||
|
||||
Eigene Migration `0001_planungsliste`, Tabelle `planungsliste`:
|
||||
Eigene Migrationen `0001_planungsliste` und `0002_bild_url`,
|
||||
Tabelle `planungsliste`:
|
||||
|
||||
| Spalte | Inhalt |
|
||||
|--------|--------|
|
||||
| `titel` / `verlag` / `autor` | Spieldaten |
|
||||
| `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) |
|
||||
| `rezensent_id` | Zuordnung (FK auf die Benutzer-Tabelle des Kerns) |
|
||||
| `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
|
||||
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)
|
||||
|
||||
Docker/Podman Compose ist vorgesehen (Henry-Lab, danach Kundenhardware).
|
||||
Für Postgres genügt `SPIELE_DATABASE_URL`; die Migrationen sind portabel
|
||||
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
|
||||
|
||||
> 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 |
|
||||
| 3 | Plugin `audit-log` (Wer/Was/Wann, Admin-Ansicht, öffentliche API) | ✅ fertig |
|
||||
| 4 | Plugin `neuheiten` (BGG-Sync, APScheduler, Filter) | ✅ fertig |
|
||||
| 5 | Plugin `dedup` (Verlags-Konflikt, deutsche Version, Vorgänger-/Planungs-Check) | ✅ fertig (141 Tests grün) |
|
||||
| 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ✅ fertig (141 Tests grün) |
|
||||
| 7 | Plugin `archiv` (12-Monats-Autopilot) | ⏳ offen |
|
||||
| 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung) | ⏳ offen |
|
||||
| 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ⏳ offen |
|
||||
| 10 | Integrationstests über alle Plugins | ⏳ offen |
|
||||
| 11 | Deployment auf swen.henry.insight-it.de (Compose + Traefik, Live-Check) | ⏳ offen |
|
||||
| 5 | Plugin `dedup` (Verlags-Konflikt, deutsche Version, Vorgänger-/Planungs-Check) | ✅ fertig |
|
||||
| 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ✅ fertig |
|
||||
| 7 | Plugin `archiv` (12-Monats-Autopilot, Wiederherstellen, täglicher Job) | ✅ fertig |
|
||||
| 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung, Tages-Job) | ✅ fertig |
|
||||
| 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ✅ fertig |
|
||||
| 10 | Integrationstests über alle Plugins | ✅ 316 Tests grün |
|
||||
| 11 | Deployment auf swen.henry.insight-it.de (Compose + Traefik, Live-Check) | ✅ live |
|
||||
|
||||
Legende: ✅ fertig · 🔄 in Arbeit · ⏳ offen · ⚠️ fertig mit offenen Punkten
|
||||
|
||||
## Offene Punkte (nicht Teil von Phase 1)
|
||||
|
||||
- CSRF-Schutz für Formulare (aktuell SameSite=Lax-Cookie als Basisschutz)
|
||||
- Hintergrund-Jobs (APScheduler) für Erinnerungen
|
||||
- WeasyPrint für PDF-Export (Systemabhängigkeiten im Container)
|
||||
- WeasyPrint-Systemabhängigkeiten im Container prüfen
|
||||
- Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets
|
||||
|
||||
## Lizenz
|
||||
|
||||
Dieses Projekt steht unter der [MIT-Lizenz](LICENSE).
|
||||
|
||||
14
TODO.md
Normal file
14
TODO.md
Normal 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
|
||||
@@ -8,6 +8,8 @@ services:
|
||||
- SPIELE_SESSION_SECRET=${SPIELE_SESSION_SECRET:-change-me-in-production}
|
||||
# Vor dem ersten Start setzen, sonst wird der Admin mit "admin" angelegt:
|
||||
- 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
|
||||
#- SPIELE_SMTP_HOST=smtp.example.org
|
||||
#- SPIELE_SMTP_PORT=587
|
||||
|
||||
@@ -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:
|
||||
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
|
||||
Titel der Neuheitenliste und der Planungsliste, deren Erscheinungsdatum
|
||||
bzw. Eintragsdatum länger als 12 Monate zurückliegt, werden vollständig in
|
||||
die Archiv-Tabellen (`archiv_neuheiten`, `archiv_planung`) verschoben und
|
||||
aus den aktiven Listen entfernt. Nichts wird gelöscht oder verändert —
|
||||
Admins können jeden Eintrag über die Archiv-Ansicht jederzeit wieder in die
|
||||
aktive Liste zurückverschieben.
|
||||
|
||||
Umfang:
|
||||
- Eigene Migration mit beiden Spiegel-Tabellen.
|
||||
- Täglicher Hintergrund-Job (APScheduler, Cron um „03:00“ lokal).
|
||||
- Admin-Ansicht `/archiv` mit Suche, Herkunftsfilter und Wiederherstellen.
|
||||
- Je ein Audit-Log-Eintrag pro Archivierung (Akteur „System“) und pro
|
||||
Wiederherstellung (Akteur der Aktion).
|
||||
|
||||
Konfiguration über Umgebungsvariablen (gelesen beim Plugin-Start):
|
||||
- SPIELE_ARCHIV_JOB_AKTIV „1“ (Standard) = täglicher Job an, „0“ = aus
|
||||
- SPIELE_ARCHIV_JOB_UHRZEIT Tageszeit des Laufs im Format HH:MM (Standard 03:00)
|
||||
|
||||
Die Fachlogik liegt im Service (`service.py`) mit eingefrierbarer Uhr;
|
||||
Partner-Tabellen werden wie in dedup/planung nur über die gemeinsame
|
||||
SQLAlchemy-Metadata angesprochen. Der Kern bleibt unberührt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, Request
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
|
||||
from redaktionskern.auth.deps import require_user
|
||||
from redaktionskern.auth.models import User
|
||||
from redaktionskern.contracts import BasePlugin, NavEntry
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from redaktionskern.auth.deps import get_db, require_roles
|
||||
from redaktionskern.auth.models import Role, User
|
||||
from redaktionskern.contracts import BasePlugin, Migration, NavEntry
|
||||
|
||||
from .models import STATUS_ANZEIGE, ArchivNeuheit, ArchivPlanung
|
||||
from .service import (
|
||||
GUELTIGE_QUELLEN,
|
||||
QUELLE_NEUHEITEN,
|
||||
OBJEKT_TYPEN,
|
||||
ArchivService,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger("plugins.archiv")
|
||||
|
||||
JOB_ID = "archiv-taeglich"
|
||||
STANDARD_UHRZEIT = "03:00"
|
||||
|
||||
|
||||
def _uhrzeit_parsen(roh: str | None) -> tuple[int, int]:
|
||||
"""Parst „HH:MM“; bei Unsinn gilt der Standard 03:00."""
|
||||
teile = (roh or "").strip().split(":")
|
||||
try:
|
||||
stunde = int(teile[0])
|
||||
minute = int(teile[1]) if len(teile) > 1 else 0
|
||||
except (ValueError, IndexError):
|
||||
return 3, 0
|
||||
if not (0 <= stunde <= 23 and 0 <= minute <= 59):
|
||||
return 3, 0
|
||||
return stunde, minute
|
||||
|
||||
|
||||
def _tabelle_anlegen(conn) -> None:
|
||||
"""Migration 0001: legt beide Spiegel-Tabellen an (portabel, idempotent)."""
|
||||
ArchivNeuheit.__table__.create(conn, checkfirst=True)
|
||||
ArchivPlanung.__table__.create(conn, checkfirst=True)
|
||||
|
||||
|
||||
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):
|
||||
name = "archiv"
|
||||
title = "Archiv"
|
||||
description = "Automatische Archivierung von Titeln älter als 12 Monate (Platzhalter)."
|
||||
description = (
|
||||
"Verschiebt Titel älter als 12 Monate automatisch aus den aktiven "
|
||||
"Listen in das Archiv — vollständig wiederherstellbar."
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._scheduler = None # BackgroundScheduler, falls aktiviert
|
||||
#: Einfrierbare Uhr für Tests (Callable → aware datetime); None = echte Zeit.
|
||||
self._jetzt = None
|
||||
self._routen_registrieren()
|
||||
|
||||
# ---------- Plugin-Vertrag ----------
|
||||
|
||||
def migrations(self) -> list[Migration]:
|
||||
return [
|
||||
Migration(version="0001_archiv_tabellen", up=_tabelle_anlegen),
|
||||
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")
|
||||
def seite(request: Request, user: User = Depends(require_user)):
|
||||
"""Platzhalterseite des Plugins."""
|
||||
def ansicht(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_roles(Role.ADMIN.value)),
|
||||
q: str = "",
|
||||
quelle: str = "",
|
||||
meldung: str = "",
|
||||
fehler: str = "",
|
||||
):
|
||||
"""Archiv-Ansicht (nur Admin): Suche, Filter, Wiederherstellen."""
|
||||
service = self._neuer_service()
|
||||
eintraege = service.ansicht(db)
|
||||
|
||||
alle_neuheiten = sum(1 for e in eintraege if e["quelle"] == QUELLE_NEUHEITEN)
|
||||
alle_planung = len(eintraege) - alle_neuheiten
|
||||
|
||||
such = q.strip().lower()
|
||||
if such:
|
||||
eintraege = [
|
||||
e
|
||||
for e in eintraege
|
||||
if such in (e["titel"] or "").lower()
|
||||
or such in (e["verlag"] or "").lower()
|
||||
or such in (e["autor"] or "").lower()
|
||||
]
|
||||
if quelle in GUELTIGE_QUELLEN:
|
||||
eintraege = [e for e in eintraege if e["quelle"] == quelle]
|
||||
|
||||
namen = {
|
||||
benutzer.id: (benutzer.display_name or benutzer.username)
|
||||
for benutzer in db.scalars(select(User)).all()
|
||||
}
|
||||
|
||||
return self.context.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="archiv/index.html",
|
||||
context={
|
||||
"user": user,
|
||||
"titel": self.title,
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"eintraege": eintraege,
|
||||
"namen": namen,
|
||||
"status_anzeige": STATUS_ANZEIGE,
|
||||
"q": q,
|
||||
"quelle_filter": quelle if quelle in GUELTIGE_QUELLEN else "",
|
||||
"anzahl": len(eintraege),
|
||||
"anzahl_neuheiten": alle_neuheiten,
|
||||
"anzahl_planung": alle_planung,
|
||||
"meldung": meldung[:300],
|
||||
"fehler": fehler[:300],
|
||||
"job_aktiv": self._scheduler is not None and self._scheduler.running,
|
||||
},
|
||||
)
|
||||
|
||||
def navigation(self) -> list[NavEntry]:
|
||||
return [NavEntry(label=self.title, url="/archiv")]
|
||||
@self.router.post("/archiv/{quelle}/{archiv_id}/wiederherstellen")
|
||||
async def wiederherstellen(
|
||||
request: Request,
|
||||
quelle: str,
|
||||
archiv_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_roles(Role.ADMIN.value)),
|
||||
):
|
||||
"""Stellt einen archivierten Eintrag in die aktive Liste zurück."""
|
||||
ziel_url = "/archiv"
|
||||
if quelle not in GUELTIGE_QUELLEN:
|
||||
return RedirectResponse(
|
||||
f"{ziel_url}?fehler={quote('Unbekannte Herkunft.')}",
|
||||
status_code=303,
|
||||
)
|
||||
service = self._neuer_service()
|
||||
ergebnis = service.wiederherstellen(
|
||||
db, quelle, archiv_id, ersatz_rezensent_id=user.id
|
||||
)
|
||||
if not ergebnis.ok:
|
||||
return RedirectResponse(
|
||||
f"{ziel_url}?fehler={quote(ergebnis.meldung)}", status_code=303
|
||||
)
|
||||
|
||||
details = {
|
||||
"titel": ergebnis.titel,
|
||||
"von": "archiv",
|
||||
"ziel": ergebnis.ziel,
|
||||
"archiv_id": archiv_id,
|
||||
}
|
||||
if ergebnis.rezensent_ersetzt:
|
||||
details["rezensent_ersetzt"] = True
|
||||
audit = request.app.state.registry.get("audit-log")
|
||||
if audit is not None:
|
||||
try:
|
||||
await audit.log(
|
||||
user,
|
||||
"verschoben",
|
||||
OBJEKT_TYPEN[quelle],
|
||||
ergebnis.neuer_id,
|
||||
details,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - Audit blockiert nicht
|
||||
_logger.exception(
|
||||
"Audit-Log-Eintrag für Wiederherstellung fehlgeschlagen."
|
||||
)
|
||||
return RedirectResponse(
|
||||
f"{ziel_url}?meldung={quote(ergebnis.meldung)}", status_code=303
|
||||
)
|
||||
|
||||
|
||||
plugin = ArchivPlugin()
|
||||
|
||||
110
plugins/archiv/models.py
Normal file
110
plugins/archiv/models.py
Normal 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
500
plugins/archiv/service.py
Normal 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,
|
||||
)
|
||||
24
plugins/archiv/templates/archiv/_cover.html
Normal file
24
plugins/archiv/templates/archiv/_cover.html
Normal 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">🎲</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">🎲</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -2,9 +2,180 @@
|
||||
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
|
||||
{% block inhalt %}
|
||||
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
||||
<p class="text-slate-600 max-w-2xl">
|
||||
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
|
||||
in Version {{ version }} ist geladen.
|
||||
Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert.
|
||||
<p class="text-slate-600 max-w-3xl mb-4">
|
||||
Sicherungsdatei der Redaktion: Titel, deren Erscheinungs- bzw. Eintragsdatum
|
||||
mehr als 12 Monate zurückliegt, werden täglich automatisch aus den
|
||||
aktiven Listen hierher verschoben. Es geht nichts verloren — jeder
|
||||
Eintrag lässt sich unverändert zurück in die aktive Liste verschieben.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-4 mb-4 text-xs text-slate-500">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full {{ 'bg-emerald-500' if job_aktiv else 'bg-slate-300' }}"></span>
|
||||
Täglicher Archiv-Job: {{ 'aktiv' if job_aktiv else 'aus' }}
|
||||
</span>
|
||||
<span>{{ anzahl_neuheiten }} archivierte Neuheiten</span>
|
||||
<span>{{ anzahl_planung }} archivierte Planungseinträge</span>
|
||||
</div>
|
||||
|
||||
{% if meldung %}
|
||||
<div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div>
|
||||
{% endif %}
|
||||
{% if fehler %}
|
||||
<div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
|
||||
<form method="get" action="/archiv" class="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ücksetzen</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-slate-400 mb-3">{{ anzahl }} Einträ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 {{ 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">
|
||||
↻ 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ü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 {{ 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">
|
||||
↻ 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ür diese Suche.
|
||||
{% else %}
|
||||
Das Archiv ist leer — nichts zu sehen.
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
</button>
|
||||
<span class="text-xs text-slate-400">Ohne ausgewählten Kanal erhalten Sie keine Benachrichtigungen.</span>
|
||||
|
||||
@@ -15,6 +15,17 @@ in `pruefung.py` beschrieben. Jede Prüfung wird in der eigenen Tabelle
|
||||
Zusatzdaten von BoardGameGeek (Alternate-Names, Erweiterungs-Relationen)
|
||||
können über SPIELE_DEDUP_BGG_AKTIV=0 abgeschaltet werden (Standard: an);
|
||||
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
|
||||
|
||||
@@ -30,6 +41,7 @@ from redaktionskern.auth.models import User
|
||||
from redaktionskern.contracts import BasePlugin, Migration, NavEntry
|
||||
|
||||
from .bgg import BggPruefClient
|
||||
from .ki_pruefung import KiPruefClient, lade_konfiguration
|
||||
from .models import Pruefung
|
||||
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.
|
||||
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()
|
||||
|
||||
# ---------- Plugin-Vertrag ----------
|
||||
@@ -72,11 +90,17 @@ class DedupPlugin(BasePlugin):
|
||||
def on_load(self, context) -> None:
|
||||
super().on_load(context)
|
||||
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:
|
||||
if self._bgg_client is not None:
|
||||
self._bgg_client.schliessen()
|
||||
self._bgg_client = None
|
||||
if self._ki_client is not None:
|
||||
self._ki_client.schliessen()
|
||||
self._ki_client = None
|
||||
super().on_unload()
|
||||
|
||||
# ---------- Öffentliche Prüf-API für andere Plugins ----------
|
||||
@@ -102,6 +126,7 @@ class DedupPlugin(BasePlugin):
|
||||
id_wert = None
|
||||
|
||||
client = self._hilfsclient()
|
||||
ki_client = self._ki_hilfsclient()
|
||||
try:
|
||||
ergebnis = fuehre_pruefung_durch(
|
||||
self.context.session_factory,
|
||||
@@ -110,22 +135,65 @@ class DedupPlugin(BasePlugin):
|
||||
verlag=verlag,
|
||||
bgg_id=id_wert,
|
||||
user_id=user.id if user is not None else None,
|
||||
ki_client=ki_client,
|
||||
ki_konfidenz_min=self.ki_konfiguration.konfidenz_min,
|
||||
)
|
||||
finally:
|
||||
if client is not None:
|
||||
client.schliessen()
|
||||
if ki_client is not None:
|
||||
ki_client.schliessen()
|
||||
self._protokolliere(ergebnis, user)
|
||||
await self._auditiere_ki_befunde(ergebnis, user)
|
||||
return ergebnis
|
||||
|
||||
# ---------- 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:
|
||||
if self.bgg_client_fabrik is not None:
|
||||
return self.bgg_client_fabrik()
|
||||
if not self._bgg_aktiv or self.context is None:
|
||||
return 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
|
||||
|
||||
def _protokolliere(self, ergebnis: PruefErgebnis, user: User | None) -> None:
|
||||
|
||||
@@ -33,6 +33,7 @@ class BggPruefClient:
|
||||
self,
|
||||
mindestabstand_sekunden: float = 1.0,
|
||||
*,
|
||||
token: str | None = None,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
uhr: Callable[[], float] = time.monotonic,
|
||||
schlafen: Callable[[float], None] = time.sleep,
|
||||
@@ -42,8 +43,11 @@ class BggPruefClient:
|
||||
self._zuletzt: float | None = None
|
||||
self._uhr = uhr
|
||||
self._schlafen = schlafen
|
||||
headers: dict[str, str] = {}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
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:
|
||||
|
||||
345
plugins/dedup/ki_pruefung.py
Normal file
345
plugins/dedup/ki_pruefung.py
Normal 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 0–100-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
|
||||
@@ -43,6 +43,10 @@ TITEL_SCHWELLE = 85
|
||||
#: Literal, damit keine Import-Abhängigkeit zwischen den Plugins entsteht).
|
||||
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.
|
||||
DEUTSCHE_INDIKATOREN = frozenset({
|
||||
"der", "die", "das", "den", "dem", "des",
|
||||
@@ -142,6 +146,67 @@ def _verlags_schluessel(verlag: str) -> str:
|
||||
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 ----------------
|
||||
|
||||
#: Anzeigenamen der Konflikt-Arten (deutsche UI).
|
||||
@@ -181,6 +246,9 @@ class PruefErgebnis:
|
||||
verlags_optionen: list[str] = field(default_factory=list)
|
||||
#: Empfohlener (deutschester) Titel, falls Titel-Varianten bekannt sind.
|
||||
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
|
||||
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)
|
||||
):
|
||||
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)
|
||||
|
||||
|
||||
@@ -269,12 +339,18 @@ def fuehre_pruefung_durch(
|
||||
verlag: str | None = None,
|
||||
bgg_id: int | None = None,
|
||||
user_id: int | None = None,
|
||||
ki_client=None,
|
||||
ki_konfidenz_min: float = 0.7,
|
||||
) -> PruefErgebnis:
|
||||
"""Führt alle vier Prüfungen durch und liefert ein PruefErgebnis.
|
||||
|
||||
`bgg_hilfsclient` darf None sein (BGG-Zusatzdaten deaktiviert) — dann
|
||||
entfallen Alternate-Names und Vorgänger-Relationen, die Heuristik und
|
||||
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
|
||||
„schon in Planung“-Konflikt aus.
|
||||
"""
|
||||
@@ -284,6 +360,7 @@ def fuehre_pruefung_durch(
|
||||
ergebnis = PruefErgebnis.ohne_befund(titel, verlag, id_wert)
|
||||
if not titel:
|
||||
return ergebnis
|
||||
konfidenz_min = max(0.0, min(1.0, float(ki_konfidenz_min)))
|
||||
|
||||
with session_factory() as db:
|
||||
neuheiten = _lade_zeilen(db, "neuheiten", ("id", "titel", "verlag", "bgg_id"))
|
||||
@@ -304,12 +381,58 @@ def fuehre_pruefung_durch(
|
||||
except Exception as 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 = [
|
||||
zeile for zeile in (*neuheiten, *planungen)
|
||||
zeile for zeile in alle_zeilen
|
||||
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.
|
||||
bekannte_verlage: dict[str, str] = {}
|
||||
for zeile in kandidaten:
|
||||
@@ -340,8 +463,9 @@ def fuehre_pruefung_durch(
|
||||
zeile["titel"] for zeile in kandidaten
|
||||
if normalisiere(zeile.get("titel") or "") != normalisiere(titel)
|
||||
]
|
||||
ergebnis.titel_empfehlung = bevorzuge_deutschen_titel(
|
||||
[titel, *varianten, *alternativen]
|
||||
ergebnis.titel_empfehlung = (
|
||||
ki_titel_vorschlag # KI-Empfehlung (bestätigter Grenzfall) hat Vorrang
|
||||
or bevorzuge_deutschen_titel([titel, *varianten, *alternativen])
|
||||
)
|
||||
if varianten and ergebnis.titel_empfehlung \
|
||||
and normalisiere(ergebnis.titel_empfehlung) != normalisiere(titel):
|
||||
@@ -361,12 +485,16 @@ def fuehre_pruefung_durch(
|
||||
# c) Spiel oder Vorgänger bereits besprochen (Planungsstatus „abgeschlossen“).
|
||||
besprochen = [z for z in planungen if z.get("status") == STATUS_ABGESCHLOSSEN]
|
||||
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)
|
||||
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(
|
||||
art="besprochen",
|
||||
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 zeile in besprochen:
|
||||
@@ -394,20 +522,102 @@ def fuehre_pruefung_durch(
|
||||
for zeile in planungen:
|
||||
if user_id is not None and zeile.get("rezensent_id") == user_id:
|
||||
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)
|
||||
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(
|
||||
art="planung",
|
||||
beschreibung=(
|
||||
f"„{zeile['titel']}“ ist bereits in der Planungsliste "
|
||||
f"von {wer} (Status: {status})."
|
||||
),
|
||||
details={
|
||||
"planungseintrag": zeile["id"],
|
||||
"rezensent": wer,
|
||||
"status": status,
|
||||
},
|
||||
details=details,
|
||||
))
|
||||
|
||||
# ---- 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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,41 +1,538 @@
|
||||
"""Plugin „erinnerung“ — Platzhalter gemäß Plugin-Vertrag.
|
||||
"""Plugin „erinnerung“ — Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung.
|
||||
|
||||
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
|
||||
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
|
||||
Umfang:
|
||||
- **Ausgaben verwalten** (nur Rolle Admin): Name/Titel + Redaktionsschluss-Datum,
|
||||
mehrere Ausgaben parallel, anlegen/bearbeiten/löschen.
|
||||
- **Automatische Erinnerung:** vier Wochen vor dem Redaktionsschluss erhält
|
||||
jeder aktive Rezensent eine Benachrichtigung, der noch offene
|
||||
Planungseinträge hat (Status ≠ „abgeschlossen“) oder gar keine — über das
|
||||
Plugin „benachrichtigung“ (`send_notification`), Kanäle je Benutzer-Präferenz.
|
||||
- **Geplanter Job:** APScheduler, täglich einmal (Uhrzeit per Env
|
||||
`SPIELE_ERINNERUNG_JOB_UHRZEIT`, abschaltbar per `SPIELE_ERINNERUNG_JOB_AKTIV`).
|
||||
- **Keine Doppelerinnerung:** pro (Ausgabe, Benutzer) wird genau einmal
|
||||
erinnert; der Versand wird in `erinnerung_protokoll` protokolliert.
|
||||
- **Audit-Log:** Ausgaben-CRUD und jede Erinnerung werden über das
|
||||
Plugin „audit-log“ protokolliert (Erinnerung als „System“/`benachrichtigt`).
|
||||
|
||||
Geschäftsregeln liegen in `dienst.py` (einfrierbare Uhr: `heute()`),
|
||||
Partner-Plugins werden wie überall über die Registry geholt; fehlen sie,
|
||||
wird der Durchlauf ohne Versand/Protokoll übersprungen bzw. ohne Audit
|
||||
fortgesetzt — der Vorgang bricht nie.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, Request
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from urllib.parse import quote
|
||||
|
||||
from redaktionskern.auth.deps import require_user
|
||||
from redaktionskern.auth.models import User
|
||||
from redaktionskern.contracts import BasePlugin, NavEntry
|
||||
from fastapi import Depends, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from redaktionskern.auth.deps import get_db, require_roles, require_user
|
||||
from redaktionskern.auth.models import Role, User
|
||||
from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext
|
||||
|
||||
# Bewusst `from .dienst import …` (wie `from .models import …` überall sonst):
|
||||
# `from . import dienst` funktioniert unter dem dateibasierten Plugin-Loader
|
||||
# nicht zuverlässig (Python 3.14 versucht dort einen absoluten Import der
|
||||
# Namensraum-Oberpakete).
|
||||
from .dienst import (
|
||||
ERINNERUNGSFENSTER_TAGE,
|
||||
alle_ausgaben,
|
||||
bereits_erinnerte_ids,
|
||||
empfaenger,
|
||||
faellige_ausgaben,
|
||||
heute,
|
||||
im_erinnerungsfenster,
|
||||
nachricht_text,
|
||||
parse_datum,
|
||||
tage_bis,
|
||||
)
|
||||
from .models import Ausgabe, ErinnerungsProtokoll
|
||||
|
||||
_logger = logging.getLogger("plugins.erinnerung")
|
||||
|
||||
MODUL_PRAEFIX = "spiele_redaktion_plugins"
|
||||
JOB_ID = "erinnerungs-check"
|
||||
MAX_MELDUNGS_LAENGE = 300
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pruefbericht:
|
||||
"""Ergebnis eines `pruefe_und_erinnere`-Durchlaufs."""
|
||||
|
||||
gepruefte_ausgaben: int = 0
|
||||
versendet: list[dict] = field(default_factory=list)
|
||||
#: (Ausgabe, Benutzer)-Paare im Fenster, die wegen Protokolleintrag
|
||||
#: übersprungen wurden (Doppelschutz gegriffen).
|
||||
uebersprungen: int = 0
|
||||
|
||||
@property
|
||||
def anzahl_versendet(self) -> int:
|
||||
return len(self.versendet)
|
||||
|
||||
def als_text(self) -> str:
|
||||
if self.gepruefte_ausgaben == 0:
|
||||
return "Keine Ausgaben mit offenem Erinnerungsfenster."
|
||||
return (
|
||||
f"{self.gepruefte_ausgaben} Ausgabe(n) im Erinnerungsfenster geprüft, "
|
||||
f"{self.anzahl_versendet} Erinnerung(en) versendet, "
|
||||
f"{self.uebersprungen} bereits erinnert."
|
||||
)
|
||||
|
||||
|
||||
def _tabellen_erstellen(conn) -> None:
|
||||
"""Eigene Migration des Plugins: legt beide Tabellen an (idempotent)."""
|
||||
Ausgabe.__table__.create(conn, checkfirst=True)
|
||||
ErinnerungsProtokoll.__table__.create(conn, checkfirst=True)
|
||||
|
||||
|
||||
class ErinnerungPlugin(BasePlugin):
|
||||
name = "erinnerung"
|
||||
title = "Erinnerungen"
|
||||
description = "Erinnerungen vor Redaktionsschluss pro Ausgabe (Platzhalter)."
|
||||
description = (
|
||||
"Vier Wochen vor dem Redaktionsschluss pro Ausgabe Erinnerung an alle "
|
||||
"Rezensenten mit offener (oder fehlender) Planung."
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._scheduler = None # BackgroundScheduler, falls aktiviert
|
||||
self._lauf_sperre = threading.Lock()
|
||||
self._registry = None # wird bei Requests gemerkt (Partner-Auflösung)
|
||||
self._routen_registrieren()
|
||||
|
||||
# ---------- Plugin-Vertrag ----------
|
||||
|
||||
def migrations(self) -> list[Migration]:
|
||||
return [Migration(version="0001_ausgaben_und_protokoll", up=_tabellen_erstellen)]
|
||||
|
||||
def navigation(self) -> list[NavEntry]:
|
||||
return [NavEntry(label=self.title, url="/erinnerung")]
|
||||
|
||||
def on_load(self, context: PluginContext) -> None:
|
||||
super().on_load(context)
|
||||
import os
|
||||
|
||||
if os.environ.get("SPIELE_ERINNERUNG_JOB_AKTIV", "1").strip() == "1":
|
||||
self._scheduler_starten(os.environ.get("SPIELE_ERINNERUNG_JOB_UHRZEIT"))
|
||||
|
||||
def on_unload(self) -> None:
|
||||
if self._scheduler is not None:
|
||||
self._scheduler.shutdown(wait=False)
|
||||
self._scheduler = None
|
||||
super().on_unload()
|
||||
|
||||
# ---------- Partner-Plugins (benachrichtigung, audit-log) ----------
|
||||
|
||||
def _merke_registry(self, registry) -> None:
|
||||
"""Merk sich die Registry, damit auch der Hintergrund-Job Partner
|
||||
auflösen kann (auch ohne vorherigen Request via sys.modules-Fallback)."""
|
||||
self._registry = registry
|
||||
|
||||
def _partner(self, name: str):
|
||||
"""Löst ein Partner-Plugin auf: Registry zuerst, sonst Modul-Lookup.
|
||||
|
||||
Der Fallback über `sys.modules` deckt den Fall „Job läuft, bevor der
|
||||
erste Request die Registry gemerkt hat“ ab (Verzeichnis-Plugins).
|
||||
"""
|
||||
if self._registry is not None:
|
||||
partner = self._registry.get(name)
|
||||
if partner is not None:
|
||||
return partner
|
||||
modul = sys.modules.get(f"{MODUL_PRAEFIX}.{name}")
|
||||
return getattr(modul, "plugin", None) if modul else None
|
||||
|
||||
# ---------- Öffentliche API für andere Plugins/Tests ----------
|
||||
|
||||
async def pruefe_und_erinnere(
|
||||
self,
|
||||
jetzt: date | None = None,
|
||||
*,
|
||||
benachrichtigung=None,
|
||||
audit=None,
|
||||
) -> Pruefbericht:
|
||||
"""Prüft alle Ausgaben gegen die eingefrierbare Uhr und erinnert.
|
||||
|
||||
`jetzt` überschreibt die Uhr (Tests); ohne Angabe gilt `heute()`.
|
||||
Partner-Plugins können übergeben werden, sonst werden sie über die
|
||||
Registry aufgelöst. Der Durchlauf wirft nicht für einzelne Empfänger:
|
||||
Fehlschläge werden protokolliert und im Bericht gezählt.
|
||||
"""
|
||||
assert self.context is not None
|
||||
if benachrichtigung is None:
|
||||
benachrichtigung = self._partner("benachrichtigung")
|
||||
if audit is None:
|
||||
audit = self._partner("audit-log")
|
||||
|
||||
pruef_tag = jetzt if jetzt is not None else heute()
|
||||
bericht = Pruefbericht()
|
||||
|
||||
with self.context.session_factory() as db:
|
||||
for ausgabe in faellige_ausgaben(db, pruef_tag):
|
||||
bericht.gepruefte_ausgaben += 1
|
||||
tage_rest = tage_bis(ausgabe.redaktionsschluss, pruef_tag)
|
||||
schon_erinnert = bereits_erinnerte_ids(db, ausgabe.id)
|
||||
text = nachricht_text(ausgabe, tage_rest)
|
||||
|
||||
for empfaenger_user in empfaenger(db):
|
||||
if empfaenger_user.id in schon_erinnert:
|
||||
bericht.uebersprungen += 1
|
||||
continue
|
||||
kanaele: list[str] = []
|
||||
if benachrichtigung is not None:
|
||||
try:
|
||||
zustellbericht = await benachrichtigung.send_notification(
|
||||
empfaenger_user,
|
||||
f"Redaktionsschluss: {ausgabe.name}",
|
||||
text,
|
||||
"erinnerung",
|
||||
)
|
||||
kanaele = list(getattr(zustellbericht, "zugestellt", []))
|
||||
except Exception:
|
||||
# Ein Kanalfehler darf den Durchlauf nicht abbrechen;
|
||||
# ohne Protokolleintrag würde der nächste Lauf es
|
||||
# erneut (und hoffentlich erfolgreich) versuchen.
|
||||
_logger.exception(
|
||||
"Erinnerung an %s (Ausgabe %s) fehlgeschlagen.",
|
||||
empfaenger_user.username,
|
||||
ausgabe.name,
|
||||
)
|
||||
continue
|
||||
db.add(
|
||||
ErinnerungsProtokoll(
|
||||
ausgabe_id=ausgabe.id,
|
||||
user_id=empfaenger_user.id,
|
||||
kanaele=",".join(kanaele),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
bericht.versendet.append(
|
||||
{
|
||||
"ausgabe": ausgabe.name,
|
||||
"ausgabe_id": ausgabe.id,
|
||||
"benutzer": empfaenger_user.username,
|
||||
"user_id": empfaenger_user.id,
|
||||
"kanaele": kanaele,
|
||||
"tage_rest": tage_rest,
|
||||
}
|
||||
)
|
||||
if audit is not None:
|
||||
try:
|
||||
await audit.log(
|
||||
None,
|
||||
"benachrichtigt",
|
||||
"erinnerung",
|
||||
empfaenger_user.id,
|
||||
{
|
||||
"ausgabe": ausgabe.name,
|
||||
"ausgabe_id": ausgabe.id,
|
||||
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
|
||||
"tage_rest": tage_rest,
|
||||
"kanaele": kanaele,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"Audit-Log-Eintrag für Erinnerung fehlgeschlagen."
|
||||
)
|
||||
return bericht
|
||||
|
||||
def pruefe_und_erinnere_sync(
|
||||
self,
|
||||
jetzt: date | None = None,
|
||||
*,
|
||||
benachrichtigung=None,
|
||||
audit=None,
|
||||
) -> Pruefbericht:
|
||||
"""Synchrone Variante von `pruefe_und_erinnere` (wie audit.log_sync).
|
||||
|
||||
Für Kontexte ohne laufende Event-Loop (APScheduler-Thread, Tests).
|
||||
Darf nicht aus einer async-Funktion heraus aufgerufen werden.
|
||||
"""
|
||||
return asyncio.run(
|
||||
self.pruefe_und_erinnere(
|
||||
jetzt, benachrichtigung=benachrichtigung, audit=audit
|
||||
)
|
||||
)
|
||||
|
||||
# ---------- Hintergrund-Job ----------
|
||||
|
||||
def _scheduler_starten(self, uhrzeit_roh: str | None) -> None:
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
stunde, minute = 8, 0
|
||||
roh = (uhrzeit_roh or "08:00").strip()
|
||||
try:
|
||||
teile = roh.split(":")
|
||||
stunde = max(0, min(23, int(teile[0])))
|
||||
minute = max(0, min(59, int(teile[1]))) if len(teile) > 1 else 0
|
||||
except (ValueError, IndexError):
|
||||
stunde, minute = 8, 0
|
||||
|
||||
scheduler = BackgroundScheduler()
|
||||
scheduler.add_job(
|
||||
self._job,
|
||||
trigger="cron",
|
||||
hour=stunde,
|
||||
minute=minute,
|
||||
id=JOB_ID,
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
self._scheduler = scheduler
|
||||
_logger.info(
|
||||
"Erinnerungs-Check aktiv: täglich um %02d:%02d Uhr.", stunde, minute
|
||||
)
|
||||
|
||||
def _job(self) -> None:
|
||||
"""Hintergrund-Job: tägliche Prüfung (synchroner Kontext, eigener Thread)."""
|
||||
if not self._lauf_sperre.acquire(blocking=False):
|
||||
_logger.info("Erinnerungs-Check läuft bereits — Durchlauf übersprungen.")
|
||||
return
|
||||
try:
|
||||
bericht = self.pruefe_und_erinnere_sync()
|
||||
_logger.info("Erinnerungs-Check abgeschlossen: %s", bericht.als_text())
|
||||
except Exception:
|
||||
_logger.exception("Erinnerungs-Check fehlgeschlagen.")
|
||||
finally:
|
||||
self._lauf_sperre.release()
|
||||
|
||||
# ---------- Routen ----------
|
||||
|
||||
@staticmethod
|
||||
def _meldung(url: str, text: str, art: str = "meldung") -> RedirectResponse:
|
||||
return RedirectResponse(f"{url}?{art}={quote(text)}", status_code=303)
|
||||
|
||||
def _routen_registrieren(self) -> None:
|
||||
|
||||
@self.router.get("/erinnerung")
|
||||
def seite(request: Request, user: User = Depends(require_user)):
|
||||
"""Platzhalterseite des Plugins."""
|
||||
def seite(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
meldung: str = "",
|
||||
fehler: str = "",
|
||||
):
|
||||
"""Übersicht: Ausgaben mit Fristen; Admins verwalten und sehen
|
||||
den Erinnerungsstatus (wer wurde wann erinnert)."""
|
||||
self._merke_registry(request.app.state.registry)
|
||||
ist_admin = user.role == Role.ADMIN.value
|
||||
|
||||
pruef_tag = heute()
|
||||
ausgaben = alle_ausgaben(db)
|
||||
zeilen = []
|
||||
for ausgabe in ausgaben:
|
||||
tage_rest = tage_bis(ausgabe.redaktionsschluss, pruef_tag)
|
||||
zeilen.append(
|
||||
{
|
||||
"ausgabe": ausgabe,
|
||||
"tage_rest": tage_rest,
|
||||
"im_fenster": im_erinnerungsfenster(
|
||||
ausgabe.redaktionsschluss, pruef_tag
|
||||
),
|
||||
"erinnert_anzahl": db.scalar(
|
||||
select(func.count())
|
||||
.select_from(ErinnerungsProtokoll)
|
||||
.where(ErinnerungsProtokoll.ausgabe_id == ausgabe.id)
|
||||
)
|
||||
or 0,
|
||||
}
|
||||
)
|
||||
|
||||
protokoll = []
|
||||
if ist_admin:
|
||||
namen = {
|
||||
benutzer.id: (benutzer.display_name or benutzer.username)
|
||||
for benutzer in db.scalars(select(User)).all()
|
||||
}
|
||||
eintraege = list(
|
||||
db.scalars(
|
||||
select(ErinnerungsProtokoll).order_by(
|
||||
ErinnerungsProtokoll.erstellt_am.desc(),
|
||||
ErinnerungsProtokoll.id.desc(),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
ausgaben_namen = {a.id: a.name for a in ausgaben}
|
||||
for eintrag in eintraege:
|
||||
protokoll.append(
|
||||
{
|
||||
"eintrag": eintrag,
|
||||
"ausgabe_name": ausgaben_namen.get(eintrag.ausgabe_id, "—"),
|
||||
"benutzer": namen.get(eintrag.user_id, "—"),
|
||||
}
|
||||
)
|
||||
|
||||
return self.context.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="erinnerung/index.html",
|
||||
context={
|
||||
"user": user,
|
||||
"titel": self.title,
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"zeilen": zeilen,
|
||||
"ist_admin": ist_admin,
|
||||
"protokoll": protokoll,
|
||||
"fenster_tage": ERINNERUNGSFENSTER_TAGE,
|
||||
"meldung": meldung[:MAX_MELDUNGS_LAENGE],
|
||||
"fehler": fehler[:MAX_MELDUNGS_LAENGE],
|
||||
},
|
||||
)
|
||||
|
||||
def navigation(self) -> list[NavEntry]:
|
||||
return [NavEntry(label=self.title, url="/erinnerung")]
|
||||
@self.router.post("/erinnerung/ausgaben")
|
||||
def ausgabe_anlegen(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_roles(Role.ADMIN.value)),
|
||||
name: str = Form(""),
|
||||
redaktionsschluss: str = Form(""),
|
||||
):
|
||||
self._merke_registry(request.app.state.registry)
|
||||
roh_name = name.strip()
|
||||
if not roh_name:
|
||||
return self._meldung("/erinnerung", "Bitte einen Ausgaben-Namen angeben.", "fehler")
|
||||
try:
|
||||
schluss = parse_datum(redaktionsschluss)
|
||||
except ValueError:
|
||||
return self._meldung(
|
||||
"/erinnerung",
|
||||
"Ungültiges Datum — bitte als JJJJ-MM-TT angeben.",
|
||||
"fehler",
|
||||
)
|
||||
if schluss is None:
|
||||
return self._meldung("/erinnerung", "Bitte ein Redaktionsschluss-Datum angeben.", "fehler")
|
||||
|
||||
ausgabe = Ausgabe(name=roh_name, redaktionsschluss=schluss)
|
||||
db.add(ausgabe)
|
||||
db.commit()
|
||||
self._audit_sync(request, user, "erstellt", ausgabe.id, {
|
||||
"name": ausgabe.name,
|
||||
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
|
||||
})
|
||||
return self._meldung(
|
||||
"/erinnerung",
|
||||
f"Ausgabe „{ausgabe.name}“ angelegt (Redaktionsschluss "
|
||||
f"{ausgabe.redaktionsschluss.strftime('%d.%m.%Y')}).",
|
||||
)
|
||||
|
||||
@self.router.get("/erinnerung/ausgaben/{ausgabe_id}/bearbeiten")
|
||||
def bearbeiten_formular(
|
||||
request: Request,
|
||||
ausgabe_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_roles(Role.ADMIN.value)),
|
||||
):
|
||||
ausgabe = db.get(Ausgabe, ausgabe_id)
|
||||
if ausgabe is None:
|
||||
return self._meldung("/erinnerung", "Ausgabe nicht gefunden.", "fehler")
|
||||
return self.context.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="erinnerung/bearbeiten.html",
|
||||
context={
|
||||
"user": user,
|
||||
"titel_headline": f"Ausgabe bearbeiten — {ausgabe.name}",
|
||||
"ausgabe": ausgabe,
|
||||
},
|
||||
)
|
||||
|
||||
@self.router.post("/erinnerung/ausgaben/{ausgabe_id}/bearbeiten")
|
||||
def bearbeiten_speichern(
|
||||
request: Request,
|
||||
ausgabe_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_roles(Role.ADMIN.value)),
|
||||
name: str = Form(""),
|
||||
redaktionsschluss: str = Form(""),
|
||||
):
|
||||
self._merke_registry(request.app.state.registry)
|
||||
ausgabe = db.get(Ausgabe, ausgabe_id)
|
||||
if ausgabe is None:
|
||||
return self._meldung("/erinnerung", "Ausgabe nicht gefunden.", "fehler")
|
||||
roh_name = name.strip()
|
||||
if not roh_name:
|
||||
return self._meldung("/erinnerung", "Bitte einen Ausgaben-Namen angeben.", "fehler")
|
||||
try:
|
||||
schluss = parse_datum(redaktionsschluss)
|
||||
except ValueError:
|
||||
return self._meldung(
|
||||
"/erinnerung",
|
||||
"Ungültiges Datum — bitte als JJJJ-MM-TT angeben.",
|
||||
"fehler",
|
||||
)
|
||||
if schluss is None:
|
||||
return self._meldung("/erinnerung", "Bitte ein Redaktionsschluss-Datum angeben.", "fehler")
|
||||
|
||||
alt = {
|
||||
"name": ausgabe.name,
|
||||
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
|
||||
}
|
||||
ausgabe.name = roh_name
|
||||
ausgabe.redaktionsschluss = schluss
|
||||
db.commit()
|
||||
neu = {
|
||||
"name": ausgabe.name,
|
||||
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
|
||||
}
|
||||
geaendert = [k for k in alt if alt[k] != neu[k]]
|
||||
if geaendert:
|
||||
self._audit_sync(request, user, "geaendert", ausgabe.id, {
|
||||
"alt": {k: alt[k] for k in geaendert},
|
||||
"neu": {k: neu[k] for k in geaendert},
|
||||
})
|
||||
return self._meldung("/erinnerung", f"Ausgabe „{ausgabe.name}“ gespeichert.")
|
||||
|
||||
@self.router.post("/erinnerung/ausgaben/{ausgabe_id}/loeschen")
|
||||
def ausgabe_loeschen(
|
||||
request: Request,
|
||||
ausgabe_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_roles(Role.ADMIN.value)),
|
||||
):
|
||||
self._merke_registry(request.app.state.registry)
|
||||
ausgabe = db.get(Ausgabe, ausgabe_id)
|
||||
if ausgabe is None:
|
||||
return self._meldung("/erinnerung", "Ausgabe nicht gefunden.", "fehler")
|
||||
snapshot = {
|
||||
"name": ausgabe.name,
|
||||
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
|
||||
}
|
||||
# Protokollzeilen dieser Ausgabe mitlöschen (FK ohne Cascade).
|
||||
db.execute(
|
||||
delete(ErinnerungsProtokoll).where(
|
||||
ErinnerungsProtokoll.ausgabe_id == ausgabe_id
|
||||
)
|
||||
)
|
||||
db.delete(ausgabe)
|
||||
db.commit()
|
||||
self._audit_sync(request, user, "geloescht", ausgabe_id, snapshot)
|
||||
return self._meldung("/erinnerung", f"Ausgabe „{snapshot['name']}“ gelöscht.")
|
||||
|
||||
@self.router.post("/erinnerung/pruefen")
|
||||
async def jetzt_pruefen(
|
||||
request: Request,
|
||||
user: User = Depends(require_roles(Role.ADMIN.value)),
|
||||
):
|
||||
"""Manueller Durchlauf (gleiche Logik wie der tägliche Job)."""
|
||||
self._merke_registry(request.app.state.registry)
|
||||
bericht = await self.pruefe_und_erinnere()
|
||||
return self._meldung("/erinnerung", f"Prüfung abgeschlossen: {bericht.als_text()}")
|
||||
|
||||
# ---------- Audit (synchrone Routen → log_sync des audit-log-Plugins) ----------
|
||||
|
||||
def _audit_sync(self, request: Request, actor, action: str, objekt_id, details: dict) -> None:
|
||||
audit = request.app.state.registry.get("audit-log")
|
||||
if audit is None:
|
||||
return
|
||||
try:
|
||||
audit.log_sync(actor, action, "ausgabe", objekt_id, details)
|
||||
except Exception:
|
||||
_logger.exception("Audit-Log-Eintrag für Ausgabe fehlgeschlagen.")
|
||||
|
||||
|
||||
plugin = ErinnerungPlugin()
|
||||
|
||||
148
plugins/erinnerung/dienst.py
Normal file
148
plugins/erinnerung/dienst.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Geschäftslogik des Plugins „erinnerung“.
|
||||
|
||||
Alle Regeln liegen bewusst hier (und nicht im Kern):
|
||||
|
||||
- **Fenster:** Vier Wochen (28 Tage) vor dem Redaktionsschluss öffnet das
|
||||
Erinnerungsfenster; es gilt bis zum Redaktionsschluss (inklusive). Danach
|
||||
wird nicht mehr erinnert — vor dem Fenster erst recht nicht.
|
||||
- **Empfänger:** alle aktiven Rezensenten (Rolle „rezensent“), die noch
|
||||
offene Planungseinträge haben (Status ≠ „abgeschlossen“, also „offen“
|
||||
oder „in Bearbeitung“) oder gar keine Einträge. Wer alles abgeschlossen
|
||||
hat, wird nicht erinnert; Admins und Redakteure nie.
|
||||
- **Uhr:** `heute()` liefert das aktuelle Datum und ist die einzige
|
||||
Zeitquelle der Prüfung — Tests frieren sie ein (freezegun oder
|
||||
Monkeypatch), der geplante Job und die Routen nutzen sie unverändert.
|
||||
|
||||
Die Planungsliste wird nicht importiert, sondern über die gemeinsame
|
||||
SQLAlchemy-Metadata angesprochen (wie im planung-Plugin mit der
|
||||
Neuheiten-Tabelle): Fehlt das planung-Plugin, entfällt die Unterscheidung
|
||||
„hat offene Einträge“ und alle Rezensenten werden erinnert.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from redaktionskern.auth.models import Role, User
|
||||
from redaktionskern.db import Base
|
||||
|
||||
from .models import Ausgabe, ErinnerungsProtokoll
|
||||
|
||||
#: Länge des Erinnerungsfensters in Tagen (4 Wochen vor Redaktionsschluss).
|
||||
ERINNERUNGSFENSTER_TAGE = 4 * 7
|
||||
|
||||
#: Statuswert des planung-Plugins für „fertig“ (Kopie ohne Import-Abhängigkeit).
|
||||
STATUS_ABGESCHLOSSEN = "abgeschlossen"
|
||||
|
||||
|
||||
def heute() -> date:
|
||||
"""Aktuelles Datum — die eingefrierbare Uhr dieses Plugins."""
|
||||
return date.today()
|
||||
|
||||
|
||||
def tage_bis(schluss: date, tag: date) -> int:
|
||||
"""Tage vom Prüfdatum bis zum Redaktionsschluss (negativ = vorbei)."""
|
||||
return (schluss - tag).days
|
||||
|
||||
|
||||
def im_erinnerungsfenster(schluss: date, tag: date) -> bool:
|
||||
"""True, wenn am Prüfdatum erinnert werden darf.
|
||||
|
||||
Fenster: `schluss - 28 Tage` … `schluss` (jeweils inklusive).
|
||||
"""
|
||||
return 0 <= tage_bis(schluss, tag) <= ERINNERUNGSFENSTER_TAGE
|
||||
|
||||
|
||||
def parse_datum(roh: str) -> date | None:
|
||||
"""Parst ein ISO-Datum (JJJJ-MM-TT); None bei leer, ValueError bei Unsinn."""
|
||||
roh = (roh or "").strip()
|
||||
if not roh:
|
||||
return None
|
||||
return date.fromisoformat(roh)
|
||||
|
||||
|
||||
def alle_ausgaben(db: Session) -> list[Ausgabe]:
|
||||
"""Alle Ausgaben, frühester Redaktionsschluss zuerst."""
|
||||
return list(
|
||||
db.scalars(select(Ausgabe).order_by(Ausgabe.redaktionsschluss, Ausgabe.id)).all()
|
||||
)
|
||||
|
||||
|
||||
def faellige_ausgaben(db: Session, tag: date) -> list[Ausgabe]:
|
||||
"""Ausgaben, deren Erinnerungsfenster am Prüfdatum offen ist."""
|
||||
return [
|
||||
ausgabe
|
||||
for ausgabe in alle_ausgaben(db)
|
||||
if im_erinnerungsfenster(ausgabe.redaktionsschluss, tag)
|
||||
]
|
||||
|
||||
|
||||
def bereits_erinnerte_ids(db: Session, ausgabe_id: int) -> set[int]:
|
||||
"""Benutzer, die für diese Ausgabe schon erinnert wurden (Doppelschutz)."""
|
||||
return set(
|
||||
db.scalars(
|
||||
select(ErinnerungsProtokoll.user_id).where(
|
||||
ErinnerungsProtokoll.ausgabe_id == ausgabe_id
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def planungs_statistik(db: Session) -> tuple[set[int], set[int]]:
|
||||
"""Liefert (IDs mit offenen Einträgen, IDs mit Einträgen überhaupt)."""
|
||||
tabelle = Base.metadata.tables.get("planungsliste")
|
||||
if tabelle is None:
|
||||
# planung-Plugin nicht geladen → keine Unterscheidung möglich.
|
||||
return set(), set()
|
||||
offene = {
|
||||
zeile[0]
|
||||
for zeile in db.execute(
|
||||
select(tabelle.c.rezensent_id)
|
||||
.where(tabelle.c.status != STATUS_ABGESCHLOSSEN)
|
||||
.distinct()
|
||||
).all()
|
||||
}
|
||||
alle = {
|
||||
zeile[0]
|
||||
for zeile in db.execute(select(tabelle.c.rezensent_id).distinct()).all()
|
||||
}
|
||||
return offene, alle
|
||||
|
||||
|
||||
def empfaenger(db: Session) -> list[User]:
|
||||
"""Die zu erinnierenden Rezensenten (siehe Moduldokumentation).
|
||||
|
||||
Rezensent mit offenen Einträgen → erinnert.
|
||||
Rezensent ohne jeden Eintrag → erinnert.
|
||||
Rezensent nur mit abgeschlossenen Einträgen → nicht erinnert.
|
||||
"""
|
||||
rezensenten = list(
|
||||
db.scalars(
|
||||
select(User)
|
||||
.where(User.role == Role.REZENSENT.value, User.active.is_(True))
|
||||
.order_by(User.username)
|
||||
).all()
|
||||
)
|
||||
offene, alle = planungs_statistik(db)
|
||||
return [
|
||||
benutzer
|
||||
for benutzer in rezensenten
|
||||
if benutzer.id not in alle or benutzer.id in offene
|
||||
]
|
||||
|
||||
|
||||
def nachricht_text(ausgabe: Ausgabe, tage_rest: int) -> str:
|
||||
"""Text der Erinnerungs-Benachrichtigung (deutsch)."""
|
||||
if tage_rest == 0:
|
||||
rest = "heute ist der Redaktionsschluss"
|
||||
elif tage_rest == 1:
|
||||
rest = "morgen ist der Redaktionsschluss"
|
||||
else:
|
||||
rest = f"der Redaktionsschluss ist in {tage_rest} Tagen"
|
||||
return (
|
||||
f"Ausgabe „{ausgabe.name}“: {rest} ({ausgabe.redaktionsschluss.strftime('%d.%m.%Y')}).\n"
|
||||
"Bitte schließe deine Planungseinträge ab oder trage noch fehlende Titel "
|
||||
"unter „Planung“ nach."
|
||||
)
|
||||
64
plugins/erinnerung/models.py
Normal file
64
plugins/erinnerung/models.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Datenmodelle des Plugins „erinnerung“.
|
||||
|
||||
Zwei eigene Tabellen (SQLite ↔ Postgres portabel), der Kern bleibt unberührt:
|
||||
|
||||
- `erinnerung_ausgabe` — eine Magazin-Ausgabe mit Redaktionsschluss-Datum.
|
||||
Mehrere Ausgaben laufen parallel; der Name ist frei wählbar (z. B. „3/2025“).
|
||||
- `erinnerung_protokoll` — protokolliert jede versendete Erinnerung
|
||||
(Ausgabe + Benutzer). Die Eindeutigkeit von (ausgabe_id, user_id) stellt
|
||||
auf Datenbankebene sicher, dass niemand zweimal pro Ausgabe erinnert wird.
|
||||
|
||||
Keine ORM-Indizes: Bei erneuter Ausführung des Moduls würde ein deklarativ
|
||||
definierter Index doppelt im Table-Objekt landen und die Migration würde ihn
|
||||
zweimal anlegen (gleiche Begründung wie im benachrichtigung-Plugin).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from redaktionskern.db import Base
|
||||
|
||||
|
||||
class Ausgabe(Base):
|
||||
"""Eine Magazin-Ausgabe mit ihrem Redaktionsschluss-Datum."""
|
||||
|
||||
__tablename__ = "erinnerung_ausgabe"
|
||||
__table_args__ = {"extend_existing": True}
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
#: Name/Titel der Ausgabe, z. B. „3/2025“ oder „Sommer-Sonderheft“.
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
#: Redaktionsschluss (Datum, ohne Uhrzeit).
|
||||
redaktionsschluss: Mapped[date] = mapped_column(Date)
|
||||
erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
aktualisiert_am: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
|
||||
return f"<Ausgabe {self.name!r} (Redaktionsschluss {self.redaktionsschluss})>"
|
||||
|
||||
|
||||
class ErinnerungsProtokoll(Base):
|
||||
"""Eine versendete Erinnerung: wer wurde zu welcher Ausgabe wann erinnert."""
|
||||
|
||||
__tablename__ = "erinnerung_protokoll"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("ausgabe_id", "user_id", name="uq_erinnerung_ausgabe_user"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
ausgabe_id: Mapped[int] = mapped_column(ForeignKey("erinnerung_ausgabe.id"))
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
#: Zugestellte Kanäle (Komma-liste, z. B. „email,inapp“) — wie vom
|
||||
#: benachrichtigung-Plugin gemeldet; leer, falls kein Kanal zustellte.
|
||||
kanaele: Mapped[str] = mapped_column(String(200), default="")
|
||||
#: Wann wurde die Erinnerung ausgelöst.
|
||||
erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
|
||||
return f"<ErinnerungsProtokoll Ausgabe={self.ausgabe_id} User={self.user_id}>"
|
||||
34
plugins/erinnerung/templates/erinnerung/bearbeiten.html
Normal file
34
plugins/erinnerung/templates/erinnerung/bearbeiten.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %}
|
||||
{% block inhalt %}
|
||||
<h1 class="text-2xl font-bold mb-2">Ausgabe bearbeiten</h1>
|
||||
<p class="text-slate-600 mb-4">„{{ ausgabe.name }}“ · Redaktionsschluss:
|
||||
{{ ausgabe.redaktionsschluss.strftime('%d.%m.%Y') }}
|
||||
</p>
|
||||
|
||||
<form method="post" action="/erinnerung/ausgaben/{{ ausgabe.id }}/bearbeiten"
|
||||
class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-xs font-medium text-slate-500 mb-1">Name/Titel der Ausgabe *</label>
|
||||
<input type="text" id="name" name="name" required value="{{ ausgabe.name }}"
|
||||
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="redaktionsschluss" class="block text-xs font-medium text-slate-500 mb-1">Redaktionsschluss *</label>
|
||||
<input type="date" id="redaktionsschluss" name="redaktionsschluss" required value="{{ ausgabe.redaktionsschluss.isoformat() }}"
|
||||
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-48 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit"
|
||||
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-lg text-sm">
|
||||
Speichern
|
||||
</button>
|
||||
<a href="/erinnerung" class="text-sm text-slate-500 hover:text-slate-800 underline">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="text-xs text-slate-400 mt-3 max-w-2xl">
|
||||
Hinweis: Bereits protokollierte Erinnerungen bleiben bestehen — pro Ausgabe
|
||||
und Benutzer wird nur einmal erinnert.
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -2,9 +2,143 @@
|
||||
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
|
||||
{% block inhalt %}
|
||||
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
||||
<p class="text-slate-600 max-w-2xl">
|
||||
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
|
||||
in Version {{ version }} ist geladen.
|
||||
Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert.
|
||||
<p class="text-slate-600 mb-4">
|
||||
Redaktionsschlüsse der kommenden Ausgaben. Vier Wochen vor jedem
|
||||
Redaktionsschluss erhalten alle Rezensenten eine Erinnerung, die noch offene
|
||||
Planungseinträge haben — oder noch gar keine eingetragen haben.
|
||||
</p>
|
||||
|
||||
{% if meldung %}
|
||||
<div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div>
|
||||
{% endif %}
|
||||
{% if fehler %}
|
||||
<div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ist_admin %}
|
||||
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
|
||||
<h2 class="font-semibold mb-3">Neue Ausgabe anlegen</h2>
|
||||
<form method="post" action="/erinnerung/ausgaben" class="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label for="name" class="block text-xs font-medium text-slate-500 mb-1">Name/Titel der Ausgabe *</label>
|
||||
<input type="text" id="name" name="name" required placeholder="z. B. 3/2025"
|
||||
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="redaktionsschluss" class="block text-xs font-medium text-slate-500 mb-1">Redaktionsschluss *</label>
|
||||
<input type="date" id="redaktionsschluss" name="redaktionsschluss" required
|
||||
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-48 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
|
||||
Ausgabe anlegen
|
||||
</button>
|
||||
</form>
|
||||
<p class="text-xs text-slate-400 mt-2">Mehrere Ausgaben können parallel laufen.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm mb-6">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Ausgabe</th>
|
||||
<th class="px-4 py-3 font-medium">Redaktionsschluss</th>
|
||||
<th class="px-4 py-3 font-medium">Restzeit</th>
|
||||
<th class="px-4 py-3 font-medium">Erinnerungen</th>
|
||||
{% if ist_admin %}<th class="px-4 py-3 font-medium">Aktionen</th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for zeile in zeilen %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-medium">{{ zeile.ausgabe.name }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">{{ zeile.ausgabe.redaktionsschluss.strftime('%d.%m.%Y') }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
{% if zeile.tage_rest > fenster_tage %}
|
||||
<span class="inline-block px-2 py-0.5 rounded-full bg-slate-100 text-slate-600 text-xs">
|
||||
noch {{ zeile.tage_rest }} Tage · Erinnerung in {{ zeile.tage_rest - fenster_tage }} Tagen
|
||||
</span>
|
||||
{% elif zeile.im_fenster %}
|
||||
<span class="inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs">
|
||||
Erinnerungsphase — {{ zeile.tage_rest }} Tag{{ 'e' if zeile.tage_rest != 1 }} bis zum Redaktionsschluss
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-block px-2 py-0.5 rounded-full bg-slate-200 text-slate-500 text-xs">Redaktionsschluss vorbei</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-slate-600">
|
||||
{{ zeile.erinnert_anzahl }} erinnert
|
||||
</td>
|
||||
{% if ist_admin %}
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<a href="/erinnerung/ausgaben/{{ zeile.ausgabe.id }}/bearbeiten"
|
||||
class="text-xs border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-2 py-1 rounded-lg whitespace-nowrap">Bearbeiten</a>
|
||||
<form method="post" action="/erinnerung/ausgaben/{{ zeile.ausgabe.id }}/loeschen">
|
||||
<button type="submit" class="text-xs border border-red-200 text-red-600 hover:bg-red-50 px-2 py-1 rounded-lg whitespace-nowrap">Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="{{ 5 if ist_admin else 4 }}" class="px-4 py-8 text-center text-slate-400">
|
||||
Noch keine Ausgaben angelegt.
|
||||
{% if ist_admin %}Lege oben die erste Ausgabe mit ihrem Redaktionsschluss an.{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if ist_admin %}
|
||||
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6 flex flex-wrap items-center gap-3">
|
||||
<div class="mr-auto">
|
||||
<h2 class="font-semibold">Sofort prüfen</h2>
|
||||
<p class="text-xs text-slate-500">Führt denselben Check aus wie der tägliche Hintergrund-Job ({{ fenster_tage }} Tage vor dem Redaktionsschluss). Doppelerinnerungen sind ausgeschlossen.</p>
|
||||
</div>
|
||||
<form method="post" action="/erinnerung/pruefen">
|
||||
<button type="submit"
|
||||
class="bg-sky-600 hover:bg-sky-700 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
|
||||
Jetzt prüfen und erinnern
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
|
||||
<h2 class="font-semibold mb-3">Erinnerungsstatus</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Wer wurde für welche Ausgabe wann erinnert? Pro Ausgabe und Benutzer wird höchstens einmal erinnert.</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Ausgabe</th>
|
||||
<th class="px-4 py-3 font-medium">Rezensent</th>
|
||||
<th class="px-4 py-3 font-medium">Kanäle</th>
|
||||
<th class="px-4 py-3 font-medium">Erinnert am</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for eintrag in protokoll %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-medium">{{ eintrag.ausgabe_name }}</td>
|
||||
<td class="px-4 py-3">{{ eintrag.benutzer }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
{% if eintrag.eintrag.kanaele %}{{ eintrag.eintrag.kanaele.replace(',', ', ') }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-slate-500">
|
||||
{{ eintrag.eintrag.erstellt_am.strftime('%d.%m.%Y %H:%M') if eintrag.eintrag.erstellt_am else '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="px-4 py-8 text-center text-slate-400">Noch keine Erinnerungen versendet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,41 +1,212 @@
|
||||
"""Plugin „export“ — Platzhalter gemäß Plugin-Vertrag.
|
||||
"""Plugin „export“ — Listen als CSV und PDF herunterladen.
|
||||
|
||||
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
|
||||
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
|
||||
Exportiert die drei Redaktionslisten **Neuheiten**, **Planung** und
|
||||
**Archiv**:
|
||||
|
||||
- **CSV** (stdlib ``csv``): Semikolon-getrennt für Excel mit deutschem
|
||||
Gebietsschema, UTF-8 mit Byte-Order-Mark.
|
||||
- **PDF** (WeasyPrint): formatierte Tabellen mit deutscher Kopfzeile,
|
||||
Erstell-Datum und Seitenzahlen; die Kopfzeile wiederholt sich auf jeder
|
||||
Seite. WeasyPrint wird erst beim PDF-Download importiert — fehlen die
|
||||
System-Bibliotheken, bleiben App und CSV-Export voll nutzbar.
|
||||
|
||||
Rollen: Admin/Redakteur dürfen alles exportieren, Rezensenten nur die
|
||||
Planungsliste. Die Prüfung passiert serverseitig am Download-Endpunkt;
|
||||
auf der Export-Seite werden nicht erlaubte Formate ausgegraut angezeigt.
|
||||
|
||||
Jeder Download wird best effort ins Audit-Log geschrieben (Aktion
|
||||
„exportiert“). Eigene Routen/Templates, keine Logik im Kern — die
|
||||
Datenzugriffe laufen über die gemeinsame SQLAlchemy-Metadata, ohne
|
||||
Partner-Plugins zu importieren (siehe exporte.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, Request
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from redaktionskern.auth.deps import require_user
|
||||
from redaktionskern.auth.models import User
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from redaktionskern.auth.deps import AccessDenied, get_db, require_user
|
||||
from redaktionskern.auth.models import Role, User
|
||||
from redaktionskern.contracts import BasePlugin, NavEntry
|
||||
|
||||
from .exporte import LISTEN, csv_erzeugen, dateiname, pdf_erzeugen
|
||||
|
||||
_logger = logging.getLogger("plugins.export")
|
||||
|
||||
REDAKTION = (Role.ADMIN.value, Role.REDAKTEUR.value)
|
||||
|
||||
#: Medien-Typen der Downloads.
|
||||
MEDIA_CSV = "text/csv; charset=utf-8"
|
||||
MEDIA_PDF = "application/pdf"
|
||||
|
||||
|
||||
def _als_stream(daten: bytes, blockgroesse: int = 64 * 1024):
|
||||
"""Zerlegt ein fertiges Dokument in Blöcke für die StreamingResponse."""
|
||||
for start in range(0, len(daten), blockgroesse):
|
||||
yield daten[start : start + blockgroesse]
|
||||
|
||||
|
||||
def _download(daten: bytes, medientyp: str, name: str) -> StreamingResponse:
|
||||
return StreamingResponse(
|
||||
_als_stream(daten),
|
||||
media_type=medientyp,
|
||||
headers={"Content-Disposition": f'attachment; filename="{name}"'},
|
||||
)
|
||||
|
||||
|
||||
class ExportPlugin(BasePlugin):
|
||||
name = "export"
|
||||
title = "Export"
|
||||
description = "Listen als CSV und PDF (Platzhalter)."
|
||||
description = (
|
||||
"Listen (Neuheiten, Planung, Archiv) als CSV für Excel oder als "
|
||||
"PDF zum Drucken herunterladen."
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._routen_registrieren()
|
||||
|
||||
# ---------- Plugin-Vertrag ----------
|
||||
|
||||
def navigation(self) -> list[NavEntry]:
|
||||
return [NavEntry(label=self.title, url="/export")]
|
||||
|
||||
# ---------- Hilfsfunktionen ----------
|
||||
|
||||
@staticmethod
|
||||
def _definition(liste: str):
|
||||
definition = LISTEN.get((liste or "").lower())
|
||||
if definition is None:
|
||||
raise HTTPException(status_code=404, detail="Unbekannte Liste.")
|
||||
return definition
|
||||
|
||||
@staticmethod
|
||||
def _pruefe_zugriff(user: User, definition) -> None:
|
||||
"""Admin/Redakteur dürfen alles; Rezensenten nur die Planungsliste."""
|
||||
if definition.nur_redaktion and user.role not in REDAKTION:
|
||||
raise AccessDenied(
|
||||
f"Die Liste „{definition.titel}“ darf nur von Admins und "
|
||||
"Redakteuren exportiert werden."
|
||||
)
|
||||
|
||||
def _lade_zeilen(self, db: Session, definition):
|
||||
"""Holt die Zeilen über den Lader der Liste (None = Partner fehlt)."""
|
||||
return definition.lader(db)
|
||||
|
||||
def _audit_export(
|
||||
self,
|
||||
request: Request,
|
||||
user: User,
|
||||
liste: str,
|
||||
format_name: str,
|
||||
anzahl: int,
|
||||
) -> None:
|
||||
"""Schreibt einen Audit-Eintrag „exportiert“ (best effort)."""
|
||||
audit = request.app.state.registry.get("audit-log")
|
||||
if audit is None:
|
||||
return
|
||||
try:
|
||||
audit.log_sync(
|
||||
user,
|
||||
"exportiert",
|
||||
f"liste-{liste}",
|
||||
None,
|
||||
details={"format": format_name, "anzahl": anzahl},
|
||||
ip_adresse=request.client.host if request.client else None,
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception("Audit-Log-Eintrag für Export fehlgeschlagen.")
|
||||
|
||||
# ---------- Routen ----------
|
||||
|
||||
def _routen_registrieren(self) -> None:
|
||||
|
||||
@self.router.get("/export")
|
||||
def seite(request: Request, user: User = Depends(require_user)):
|
||||
"""Platzhalterseite des Plugins."""
|
||||
def seite(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
fehler: str = "",
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
"""Export-Seite: Karten je Liste mit Format-Buttons."""
|
||||
karten = []
|
||||
for definition in LISTEN.values():
|
||||
zeilen = self._lade_zeilen(db, definition)
|
||||
karten.append(
|
||||
{
|
||||
"key": definition.key,
|
||||
"titel": definition.titel,
|
||||
"beschreibung": definition.beschreibung,
|
||||
"erlaubt": not definition.nur_redaktion or user.role in REDAKTION,
|
||||
"verfuegbar": zeilen is not None,
|
||||
"anzahl": len(zeilen) if zeilen is not None else None,
|
||||
}
|
||||
)
|
||||
return self.context.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="export/index.html",
|
||||
context={
|
||||
"user": user,
|
||||
"titel": self.title,
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"karten": karten,
|
||||
"ist_redaktion": user.role in REDAKTION,
|
||||
"fehler": fehler[:300],
|
||||
},
|
||||
)
|
||||
|
||||
def navigation(self) -> list[NavEntry]:
|
||||
return [NavEntry(label=self.title, url="/export")]
|
||||
@self.router.get("/export/{liste}/csv")
|
||||
def csv_download(
|
||||
request: Request,
|
||||
liste: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
"""Liste als Semikolon-getrennte CSV (UTF-8 mit BOM) herunterladen."""
|
||||
definition = self._definition(liste)
|
||||
self._pruefe_zugriff(user, definition)
|
||||
zeilen = self._lade_zeilen(db, definition)
|
||||
if zeilen is None:
|
||||
return RedirectResponse("/export?fehler=Liste+nicht+verfügbar", status_code=303)
|
||||
daten = csv_erzeugen(definition.spalten, zeilen)
|
||||
self._audit_export(request, user, definition.key, "csv", len(zeilen))
|
||||
return _download(
|
||||
daten,
|
||||
MEDIA_CSV,
|
||||
dateiname(definition.key, "csv"),
|
||||
)
|
||||
|
||||
@self.router.get("/export/{liste}/pdf")
|
||||
def pdf_download(
|
||||
request: Request,
|
||||
liste: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
"""Liste als PDF (WeasyPrint) herunterladen."""
|
||||
definition = self._definition(liste)
|
||||
self._pruefe_zugriff(user, definition)
|
||||
zeilen = self._lade_zeilen(db, definition)
|
||||
if zeilen is None:
|
||||
return RedirectResponse("/export?fehler=Liste+nicht+verfügbar", status_code=303)
|
||||
try:
|
||||
daten = pdf_erzeugen(definition.titel, definition.spalten, zeilen)
|
||||
except Exception:
|
||||
# Fehlende System-Bibliotheken (Pango & Co.) dürfen nur diesen
|
||||
# Download betreffen — App und CSV-Export bleiben nutzbar.
|
||||
_logger.exception("PDF-Erzeugung für Liste '%s' fehlgeschlagen.", definition.key)
|
||||
return RedirectResponse(
|
||||
"/export?fehler=PDF-Erzeugung+fehlgeschlagen+%28WeasyPrint%2FSystem-Bibliotheken+fehlen%3F%29",
|
||||
status_code=303,
|
||||
)
|
||||
self._audit_export(request, user, definition.key, "pdf", len(zeilen))
|
||||
return _download(
|
||||
daten,
|
||||
MEDIA_PDF,
|
||||
dateiname(definition.key, "pdf", datetime.now()),
|
||||
)
|
||||
|
||||
|
||||
plugin = ExportPlugin()
|
||||
|
||||
266
plugins/export/exporte.py
Normal file
266
plugins/export/exporte.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""Export-Logik des Plugins „export“ — Listen als CSV und PDF.
|
||||
|
||||
Diese Datei enthält die komplette Fachlogik des Export-Plugins:
|
||||
|
||||
- **CSV** über das Standardmodul ``csv``, Semikolon-getrennt für Excel mit
|
||||
deutschem Gebietsschema, kodiert als UTF-8 mit Byte-Order-Mark (BOM).
|
||||
- **PDF** über WeasyPrint: sauber formatierte Tabellen mit deutscher
|
||||
Kopfzeile (auf jeder Seite wiederholt), Erstell-Datum und Seitenzahlen.
|
||||
WeasyPrint wird erst beim PDF-Aufruf importiert (lazy) — ohne die
|
||||
System-Bibliotheken (Pango usw.) bleiben CSV-Exporte und die App nutzbar.
|
||||
|
||||
Die Datenzugriffe lesen die Tabellen der Partner-Plugins über die gemeinsame
|
||||
SQLAlchemy-Metadata (``Base.metadata.tables``) — genau wie das planung-Plugin
|
||||
es vorlebt. Fehlt ein Partner-Plugin, meldet der jeweilige Lader ``None``;
|
||||
die Liste erscheint dann im Export als nicht verfügbar.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import html
|
||||
import io
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from redaktionskern.auth.models import User
|
||||
from redaktionskern.db import Base
|
||||
|
||||
# ---------------- Status-Anzeigenamen (lokale Kopien, keine Plugin-Importe) ----------------
|
||||
|
||||
NEUHEITEN_STATUS_ANZEIGE: dict[str, str] = {
|
||||
"neuheit": "Neuheit",
|
||||
"planung": "In Planung",
|
||||
"archiv": "Archiviert",
|
||||
}
|
||||
|
||||
PLANUNG_STATUS_ANZEIGE: dict[str, str] = {
|
||||
"offen": "Offen",
|
||||
"in_bearbeitung": "In Bearbeitung",
|
||||
"abgeschlossen": "Abgeschlossen",
|
||||
}
|
||||
|
||||
#: Titel, die das archiv-Plugin später automatisch setzt (12-Monats-Regel).
|
||||
STATUS_ARCHIV = "archiv"
|
||||
|
||||
|
||||
def _datum_de(wert: datetime | None) -> str:
|
||||
"""Deutsches Kurzformat (TT.MM.JJJJ HH:MM); leer bei None."""
|
||||
return wert.strftime("%d.%m.%Y %H:%M") if wert else ""
|
||||
|
||||
|
||||
def _oder_leer(wert) -> str:
|
||||
"""None/Leer → Leerstring, alles andere als Text."""
|
||||
return "" if wert is None else str(wert)
|
||||
|
||||
|
||||
# ---------------- Listen-Definitionen ----------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ListenDefinition:
|
||||
"""Eine exportierbare Liste: Anzeige-Texte, Spalten und Daten-Lader."""
|
||||
|
||||
key: str
|
||||
titel: str
|
||||
beschreibung: str
|
||||
nur_redaktion: bool
|
||||
spalten: list[str] = field(default_factory=list)
|
||||
lader: object = None # Callable[[Session], list[list[str]] | None]
|
||||
|
||||
|
||||
def _lade_neuheiten(db: Session, *, nur_archiv: bool = False) -> list[list[str]] | None:
|
||||
"""Zeilen der Neuheiten-Tabelle (Archiv = Filter auf Status „archiv“).
|
||||
|
||||
Gibt ``None`` zurück, wenn das neuheiten-Plugin nicht geladen ist.
|
||||
"""
|
||||
tabelle = Base.metadata.tables.get("neuheiten")
|
||||
if tabelle is None:
|
||||
return None
|
||||
abfrage = select(tabelle)
|
||||
if nur_archiv:
|
||||
abfrage = abfrage.where(tabelle.c.status == STATUS_ARCHIV)
|
||||
zeilen: list[list[str]] = []
|
||||
# Core-Select auf die Tabelle: jede Zeile ist ein Row mit Attributzugriff.
|
||||
for zeile in db.execute(abfrage.order_by(tabelle.c.titel.asc(), tabelle.c.id.asc())):
|
||||
zeilen.append(
|
||||
[
|
||||
zeile.titel,
|
||||
_oder_leer(zeile.verlag),
|
||||
_oder_leer(zeile.autor),
|
||||
_oder_leer(zeile.erscheinungsjahr),
|
||||
NEUHEITEN_STATUS_ANZEIGE.get(zeile.status, zeile.status),
|
||||
_datum_de(zeile.aktualisiert_am),
|
||||
]
|
||||
)
|
||||
return zeilen
|
||||
|
||||
|
||||
def _lade_planung(db: Session) -> list[list[str]] | None:
|
||||
"""Zeilen der Planungsliste inklusive Rezensenten-Namen.
|
||||
|
||||
Gibt ``None`` zurück, wenn das planung-Plugin nicht geladen ist.
|
||||
"""
|
||||
tabelle = Base.metadata.tables.get("planungsliste")
|
||||
if tabelle is None:
|
||||
return None
|
||||
namen = {
|
||||
benutzer.id: (benutzer.display_name or benutzer.username)
|
||||
for benutzer in db.scalars(select(User)).all()
|
||||
}
|
||||
zeilen: list[list[str]] = []
|
||||
abfrage = select(tabelle).order_by(tabelle.c.titel.asc(), tabelle.c.id.asc())
|
||||
for zeile in db.execute(abfrage):
|
||||
zeilen.append(
|
||||
[
|
||||
zeile.titel,
|
||||
_oder_leer(zeile.verlag),
|
||||
_oder_leer(zeile.autor),
|
||||
_oder_leer(zeile.ausgabe),
|
||||
namen.get(zeile.rezensent_id, ""),
|
||||
PLANUNG_STATUS_ANZEIGE.get(zeile.status, zeile.status),
|
||||
_oder_leer(zeile.notizen),
|
||||
_datum_de(zeile.aktualisiert_am),
|
||||
]
|
||||
)
|
||||
return zeilen
|
||||
|
||||
|
||||
NEUHEITEN_SPALTEN = ["Titel", "Verlag", "Autor", "Erscheinungsjahr", "Status", "Aktualisiert am"]
|
||||
PLANUNG_SPALTEN = ["Titel", "Verlag", "Autor", "Ausgabe", "Rezensent", "Status", "Notizen", "Aktualisiert am"]
|
||||
|
||||
#: Alle exportierbaren Listen (Reihenfolge = Reihenfolge auf der Export-Seite).
|
||||
LISTEN: dict[str, ListenDefinition] = {
|
||||
definition.key: definition
|
||||
for definition in (
|
||||
ListenDefinition(
|
||||
key="neuheiten",
|
||||
titel="Neuheitenliste",
|
||||
beschreibung=(
|
||||
"Aktuelle Neuheiten aus der BoardGameGeek-Synchronisation — "
|
||||
"inklusive Status (Neuheit, in Planung, archiviert)."
|
||||
),
|
||||
nur_redaktion=True,
|
||||
spalten=NEUHEITEN_SPALTEN,
|
||||
lader=lambda db: _lade_neuheiten(db),
|
||||
),
|
||||
ListenDefinition(
|
||||
key="planung",
|
||||
titel="Planungsliste",
|
||||
beschreibung=(
|
||||
"Geplante Rezensionen mit Zuordnung an Rezensentin/Rezensent, "
|
||||
"Ausgabe und Bearbeitungsstatus."
|
||||
),
|
||||
nur_redaktion=False,
|
||||
spalten=PLANUNG_SPALTEN,
|
||||
lader=_lade_planung,
|
||||
),
|
||||
ListenDefinition(
|
||||
key="archiv",
|
||||
titel="Archiv",
|
||||
beschreibung=(
|
||||
"Archivierte Titel (älter als 12 Monate, Status „archiviert“) — "
|
||||
"gefüllt durch den Autopilot des archiv-Plugins."
|
||||
),
|
||||
nur_redaktion=True,
|
||||
spalten=list(NEUHEITEN_SPALTEN),
|
||||
lader=lambda db: _lade_neuheiten(db, nur_archiv=True),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
# ---------------- Formaterzeugung ----------------
|
||||
|
||||
|
||||
def csv_erzeugen(kopfzeilen: list[str], zeilen: list[list[str]]) -> bytes:
|
||||
"""CSV-Dokument als Bytes: Semikolon-getrennt, UTF-8 mit BOM (Excel-DE)."""
|
||||
puffer = io.StringIO(newline="")
|
||||
writer = csv.writer(puffer, delimiter=";")
|
||||
writer.writerow(kopfzeilen)
|
||||
writer.writerows(zeilen)
|
||||
# utf-8-sig schreibt das Byte-Order-Mark (EF BB BF) — Excel erkennt
|
||||
# daran die UTF-8-Kodierung auch ohne Datei-Import-Assistenten.
|
||||
return puffer.getvalue().encode("utf-8-sig")
|
||||
|
||||
|
||||
def _zelle(wert: str) -> str:
|
||||
return html.escape(_oder_leer(wert))
|
||||
|
||||
|
||||
def _html_tabelle(listen_titel: str, kopfzeilen: list[str], zeilen: list[list[str]], erstellt_am: datetime) -> str:
|
||||
"""Baut das HTML-Gerüst für den PDF-Druck (deutsche Kopfzeile, Datum, Seitenzahlen)."""
|
||||
kopf = "".join(f"<th>{_zelle(spalte)}</th>" for spalte in kopfzeilen)
|
||||
koerper = "".join(
|
||||
"<tr>" + "".join(f"<td>{_zelle(zelle)}</td>" for zelle in zeile) + "</tr>"
|
||||
for zeile in zeilen
|
||||
)
|
||||
leer = "" if zeilen else '<p class="hinweis">Diese Liste enthält zurzeit keine Einträge.</p>'
|
||||
datum_text = erstellt_am.strftime("%d.%m.%Y, %H:%M")
|
||||
return f"""<!doctype html>
|
||||
<html lang="de">
|
||||
<head><meta charset="utf-8"><title>{html.escape(listen_titel)}</title>
|
||||
<style>
|
||||
@page {{
|
||||
size: A4;
|
||||
margin: 20mm 15mm 18mm 15mm;
|
||||
@bottom-center {{
|
||||
content: "Seite " counter(page) " von " counter(pages);
|
||||
font-size: 9pt;
|
||||
color: #64748b;
|
||||
}}
|
||||
}}
|
||||
body {{ font-family: sans-serif; color: #0f172a; font-size: 10pt; }}
|
||||
h1 {{ font-size: 17pt; margin: 0 0 2mm 0; }}
|
||||
.meta {{ font-size: 9pt; color: #475569; margin: 0 0 6mm 0; }}
|
||||
table {{ width: 100%; border-collapse: collapse; }}
|
||||
thead {{ display: table-header-group; }} /* Kopfzeile auf jeder Seite */
|
||||
th {{
|
||||
background-color: #ecfdf5;
|
||||
border-bottom: 1pt solid #047857;
|
||||
text-align: left;
|
||||
padding: 2mm 2.5mm;
|
||||
font-size: 9.5pt;
|
||||
}}
|
||||
td {{
|
||||
border-bottom: 0.4pt solid #cbd5e1;
|
||||
padding: 1.8mm 2.5mm;
|
||||
vertical-align: top;
|
||||
word-wrap: break-word;
|
||||
}}
|
||||
tr {{ page-break-inside: avoid; }}
|
||||
.hinweis {{ color: #475569; font-style: italic; }}
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>{html.escape(listen_titel)}</h1>
|
||||
<p class="meta">Spiele-Redaktion · Erstellt am {datum_text} Uhr ·
|
||||
{len(zeilen)} Einträge</p>
|
||||
{leer}
|
||||
<table>
|
||||
<thead><tr>{kopf}</tr></thead>
|
||||
<tbody>{koerper}</tbody>
|
||||
</table>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def pdf_erzeugen(
|
||||
listen_titel: str,
|
||||
kopfzeilen: list[str],
|
||||
zeilen: list[list[str]],
|
||||
erstellt_am: datetime | None = None,
|
||||
) -> bytes:
|
||||
"""PDF-Dokument über WeasyPrint (Import lazy, damit CSV ohne System-Bibliotheken bleibt)."""
|
||||
from weasyprint import HTML # noqa: PLC0415 — bewusst spät
|
||||
|
||||
dokument = _html_tabelle(
|
||||
listen_titel, kopfzeilen, zeilen, erstellt_am or datetime.now()
|
||||
)
|
||||
return HTML(string=dokument).write_pdf()
|
||||
|
||||
|
||||
def dateiname(liste: str, endung: str, jetzt: datetime | None = None) -> str:
|
||||
"""ASCII-sicherer Download-Name, z. B. ``spiele-planung-export-20250821-1430.csv``."""
|
||||
stempel = (jetzt or datetime.now()).strftime("%Y%m%d-%H%M")
|
||||
return f"spiele-{liste}-export-{stempel}.{endung}"
|
||||
@@ -2,9 +2,60 @@
|
||||
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
|
||||
{% block inhalt %}
|
||||
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
||||
<p class="text-slate-600 max-w-2xl">
|
||||
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
|
||||
in Version {{ version }} ist geladen.
|
||||
Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert.
|
||||
<p class="text-slate-600 mb-4 max-w-3xl">
|
||||
Redaktionslisten als Datei herunterladen:
|
||||
<strong>CSV</strong> (Semikolon-getrennt, UTF-8 mit BOM — direkt in Excel
|
||||
öffnbar) oder <strong>PDF</strong> (formatierte Tabelle mit Datum und
|
||||
Seitenzahlen). Admins und Redakteure können alle Listen exportieren;
|
||||
Rezensentinnen und Rezensenten die Planungsliste.
|
||||
</p>
|
||||
|
||||
{% if fehler %}
|
||||
<div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3 mb-6">
|
||||
{% for karte in karten %}
|
||||
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 flex flex-col
|
||||
{% if not karte.verfuegbar %}opacity-60{% endif %}">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h2 class="font-semibold text-slate-800">{{ karte.titel }}</h2>
|
||||
{% if karte.verfuegbar %}
|
||||
<span class="text-xs text-slate-500 bg-slate-100 rounded-full px-2 py-0.5 whitespace-nowrap">{{ karte.anzahl }} Einträge</span>
|
||||
{% else %}
|
||||
<span class="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5 whitespace-nowrap">Nicht verfügbar</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-sm text-slate-600 mb-4 flex-1">{{ karte.beschreibung }}</p>
|
||||
|
||||
{% if karte.erlaubt %}
|
||||
{% if karte.verfuegbar %}
|
||||
<div class="flex gap-2">
|
||||
<a href="/export/{{ karte.key }}/csv"
|
||||
class="flex-1 text-center bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-3 py-2 rounded-lg text-sm">
|
||||
CSV herunterladen
|
||||
</a>
|
||||
<a href="/export/{{ karte.key }}/pdf"
|
||||
class="flex-1 text-center bg-slate-700 hover:bg-slate-800 text-white font-medium px-3 py-2 rounded-lg text-sm">
|
||||
PDF herunterladen
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-xs text-slate-500">Das zugehörige Plugin ist nicht geladen — kein Export möglich.</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-xs text-slate-400 italic">Nur für Admins und Redakteure — du hast hier keinen Export-Zugriff.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 text-sm text-slate-600">
|
||||
<h3 class="font-semibold text-slate-800 mb-1">Hinweise zu den Formaten</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li><strong>CSV:</strong> Semikolon als Trennzeichen (Excel mit deutschem Gebietsschema), UTF-8 mit Byte-Order-Mark — Umlaute und ß bleiben erhalten.</li>
|
||||
<li><strong>PDF:</strong> A4, deutsche Kopfzeile auf jeder Seite, Erstell-Datum und Seitenzahlen („Seite X von Y“).</li>
|
||||
<li>Jeder Download wird im <a href="/audit-log" class="text-emerald-700 underline">Audit-Log</a> protokolliert (nur Admins einsehbar).</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,23 +7,29 @@ Umfang:
|
||||
- Manueller „Jetzt synchronisieren“-Button für Admins/Redakteure.
|
||||
- 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_INTERVALL_STUNDEN Intervall in Stunden (Standard 24)
|
||||
- SPIELE_BGG_SUCHBEGRIFFE Komma-getrennte Suchbegriffe
|
||||
(Standard: „brettspiel“)
|
||||
- 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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import Depends, Form, Request
|
||||
from fastapi import Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
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 .bgg import BggClient, BggFehler
|
||||
from .models import Neuheit
|
||||
from .sync import SyncService
|
||||
from .models import Neuheit, QuellenStatus
|
||||
from .quellen import ADAPTER_KLASSEN
|
||||
from .quellen_sync import (
|
||||
QuellenLaufZeile,
|
||||
WebQuellenSyncService,
|
||||
quelle_aktiv,
|
||||
)
|
||||
from .sync import SyncErgebnis, SyncService
|
||||
|
||||
_logger = logging.getLogger("plugins.neuheiten")
|
||||
|
||||
MELDUNG_OHNE_TOKEN = (
|
||||
"Kein BGG-API-Token konfiguriert (SPIELE_BGG_TOKEN) — Sync übersprungen."
|
||||
)
|
||||
|
||||
SORTIERBAR = {
|
||||
"titel": Neuheit.titel,
|
||||
"verlag": Neuheit.verlag,
|
||||
@@ -68,8 +84,11 @@ class NeuheitenPlugin(BasePlugin):
|
||||
super().__init__()
|
||||
self._scheduler = None # BackgroundScheduler, falls aktiviert
|
||||
self._sync_sperre = threading.Lock()
|
||||
self._quellen_sperre = threading.Lock()
|
||||
self._suchbegriffe: list[str] = []
|
||||
self._max_treffer_pro_suche = 25
|
||||
self._jahr_filter = True
|
||||
self._deutscher_titel_filter = True
|
||||
|
||||
@self.router.get("/neuheiten")
|
||||
def seite(
|
||||
@@ -77,14 +96,17 @@ class NeuheitenPlugin(BasePlugin):
|
||||
user: User = Depends(require_user),
|
||||
q: str = "",
|
||||
status: str = "",
|
||||
jahr: int | None = None,
|
||||
sort: str = STANDARD_SORTIERUNG[0],
|
||||
richtung: str = STANDARD_SORTIERUNG[1],
|
||||
meldung: str = "",
|
||||
fehler: str = "",
|
||||
seite_nr: int = 1,
|
||||
):
|
||||
spalte = SORTIERBAR.get(sort, SORTIERBAR[STANDARD_SORTIERUNG[0]])
|
||||
abwaerts = richtung == "ab" if sort in SORTIERBAR else False
|
||||
spalte_sortiert = spalte.desc() if abwaerts else spalte.asc()
|
||||
pro_seite = 50
|
||||
|
||||
with self.context.session_factory() as db:
|
||||
abfrage = select(Neuheit)
|
||||
@@ -99,8 +121,31 @@ class NeuheitenPlugin(BasePlugin):
|
||||
)
|
||||
if 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(
|
||||
abfrage.order_by(spalte_sortiert, Neuheit.id.asc())
|
||||
.offset((max(seite_nr, 1) - 1) * pro_seite)
|
||||
.limit(pro_seite)
|
||||
).all()
|
||||
status_optionen = [
|
||||
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 = {
|
||||
"user": user,
|
||||
"titel": self.title,
|
||||
"eintraege": eintraege,
|
||||
"q": q,
|
||||
"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],
|
||||
"richtung": "ab" if abwaerts else "auf",
|
||||
"status_optionen": status_optionen,
|
||||
@@ -125,7 +175,14 @@ class NeuheitenPlugin(BasePlugin):
|
||||
),
|
||||
"suchbegriffe": ", ".join(self._suchbegriffe) or "—",
|
||||
"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":
|
||||
return self.context.templates.TemplateResponse(
|
||||
@@ -151,16 +208,188 @@ class NeuheitenPlugin(BasePlugin):
|
||||
_logger.warning("Manueller BGG-Sync fehlgeschlagen: %s", exc)
|
||||
ziel = f"/neuheiten?meldung={quote(f'Sync fehlgeschlagen: {exc}')}"
|
||||
return RedirectResponse(ziel, status_code=303)
|
||||
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)
|
||||
|
||||
@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 ----------
|
||||
|
||||
def migrations(self) -> list[Migration]:
|
||||
def neuheiten_tabelle(conn) -> None:
|
||||
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]:
|
||||
return [NavEntry(label=self.title, url="/neuheiten")]
|
||||
@@ -169,16 +398,30 @@ class NeuheitenPlugin(BasePlugin):
|
||||
super().on_load(context)
|
||||
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:
|
||||
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:
|
||||
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":
|
||||
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:
|
||||
if self._scheduler is not None:
|
||||
@@ -188,17 +431,34 @@ class NeuheitenPlugin(BasePlugin):
|
||||
|
||||
# ---------- 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:
|
||||
"""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]):
|
||||
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()
|
||||
try:
|
||||
service = SyncService(
|
||||
self.context.session_factory,
|
||||
client,
|
||||
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)
|
||||
finally:
|
||||
@@ -211,13 +471,53 @@ class NeuheitenPlugin(BasePlugin):
|
||||
return
|
||||
try:
|
||||
ergebnis = self._sync_ausfuehren(self._suchbegriffe)
|
||||
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:
|
||||
_logger.warning("BGG-Sync fehlgeschlagen: %s", exc)
|
||||
finally:
|
||||
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
|
||||
|
||||
try:
|
||||
@@ -226,7 +526,40 @@ class NeuheitenPlugin(BasePlugin):
|
||||
intervall_stunden = 24
|
||||
intervall_stunden = max(intervall_stunden, 1)
|
||||
|
||||
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)
|
||||
scheduler.add_job(
|
||||
self._sync_job,
|
||||
@@ -236,8 +569,6 @@ class NeuheitenPlugin(BasePlugin):
|
||||
id="bgg-neuheiten-sync",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
self._scheduler = scheduler
|
||||
_logger.info(
|
||||
"BGG-Neuheiten-Sync aktiv: alle %s h, Suchbegriffe: %s",
|
||||
intervall_stunden,
|
||||
|
||||
@@ -11,6 +11,13 @@ Eigenschaften:
|
||||
beschränkt; Erweiterungen (`boardgameexpansion`) werden zusätzlich auf
|
||||
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 —
|
||||
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."""
|
||||
|
||||
|
||||
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)
|
||||
class SuchTreffer:
|
||||
"""Ein Treffer aus der BGG-Suche."""
|
||||
@@ -53,9 +70,34 @@ class BggSpiel:
|
||||
verlag: str | None
|
||||
autor: str | 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
|
||||
|
||||
|
||||
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:
|
||||
"""Liest `<tag><value>…</value></tag>` tolerant aus."""
|
||||
if element is None:
|
||||
@@ -144,6 +186,7 @@ def parse_things(xml_daten: bytes | str) -> list[BggSpiel]:
|
||||
verlag=_verketten(element, "boardgamepublisher"),
|
||||
autor=_verketten(element, "boardgamedesigner"),
|
||||
erscheinungsjahr=_jahr(_attribut_wert(element, "yearpublished")),
|
||||
bild_url=_bild_url(element),
|
||||
typ=typ,
|
||||
)
|
||||
)
|
||||
@@ -161,6 +204,7 @@ class BggClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
token: str | None = None,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
mindestabstand_sekunden: float = 1.0,
|
||||
max_versuche: int = 4,
|
||||
@@ -174,14 +218,20 @@ class BggClient:
|
||||
self.max_versuche = max_versuche
|
||||
self.backoff_basis_sekunden = backoff_basis_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._uhr = uhr
|
||||
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(
|
||||
base_url=BASIS_URL,
|
||||
timeout=timeout_sekunden,
|
||||
transport=transport,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
def schliessen(self) -> None:
|
||||
@@ -214,6 +264,11 @@ class BggClient:
|
||||
if antwort.status_code == 200:
|
||||
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:
|
||||
# 202: BGG stellt die Anfrage in eine Warteschlange;
|
||||
# 429/5xx: temporär nicht bedienbar → Backoff und erneut.
|
||||
|
||||
@@ -6,9 +6,10 @@ statt Duplikate anzulegen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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 redaktionskern.db import Base
|
||||
@@ -25,7 +26,14 @@ class Neuheit(Base):
|
||||
autor: Mapped[str | None] = mapped_column(String(300))
|
||||
# BoardGameGeek liefert nur das Erscheinungsjahr (kein genaues Datum).
|
||||
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)
|
||||
quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek")
|
||||
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
|
||||
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"
|
||||
|
||||
59
plugins/neuheiten/quellen/__init__.py
Normal file
59
plugins/neuheiten/quellen/__init__.py
Normal 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",
|
||||
]
|
||||
388
plugins/neuheiten/quellen/basis.py
Normal file
388
plugins/neuheiten/quellen/basis.py
Normal 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, 0–100) 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."
|
||||
)
|
||||
66
plugins/neuheiten/quellen/brettspielbox.py
Normal file
66
plugins/neuheiten/quellen/brettspielbox.py
Normal 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 (A–Z)"
|
||||
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
|
||||
186
plugins/neuheiten/quellen/cliquenabend.py
Normal file
186
plugins/neuheiten/quellen/cliquenabend.py
Normal 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
|
||||
575
plugins/neuheiten/quellen/ki_hilfe.py
Normal file
575
plugins/neuheiten/quellen/ki_hilfe.py
Normal 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. 1–2 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
|
||||
89
plugins/neuheiten/quellen/spielbox.py
Normal file
89
plugins/neuheiten/quellen/spielbox.py
Normal 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)
|
||||
187
plugins/neuheiten/quellen/spielessen.py
Normal file
187
plugins/neuheiten/quellen/spielessen.py
Normal 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
|
||||
425
plugins/neuheiten/quellen_sync.py
Normal file
425
plugins/neuheiten/quellen_sync.py
Normal 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 (0–100).
|
||||
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)
|
||||
@@ -3,21 +3,24 @@
|
||||
- Update statt Duplikat: bestehende Einträge werden über die eindeutige
|
||||
BGG-ID gefunden und aktualisiert (Status bleibt erhalten).
|
||||
- Erweiterungen werden im Client und hier auf Elementebene gefiltert.
|
||||
- Prototypen: Die BGG-XML-API2 liefert keinen verlässlichen Prototyp-Marker;
|
||||
als Heuristik werden Titel mit Prototyp-Schlüsselwörtern ausgeschlossen
|
||||
(dokumentierte Einschränkung, siehe README).
|
||||
- Prototypen: Titel mit Prototyp-Schlüsselwörtern werden ausgeschlossen.
|
||||
- Jahresfilter: optional nur Spiele mit Erscheinungsjahr ≥ aktuelles Jahr.
|
||||
- Deutsch-Filter: optional nur Spiele mit deutsch klingendem Titel.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import unicodedata
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from .bgg import BggSpiel, BggClient
|
||||
from .bgg import BggAuthFehler, BggSpiel, BggClient
|
||||
from .models import STATUS_NEUHEIT, Neuheit
|
||||
|
||||
_logger = logging.getLogger("plugins.neuheiten")
|
||||
@@ -28,12 +31,58 @@ PROTOTYP_MUSTER = re.compile(
|
||||
|
||||
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:
|
||||
"""Heuristik: Titel klingt nach Prototyp/Protoversion."""
|
||||
return bool(PROTOTYP_MUSTER.search(titel))
|
||||
|
||||
|
||||
def aktuelles_jahr() -> int:
|
||||
return date.today().year
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncErgebnis:
|
||||
"""Zusammenfassung eines Sync-Laufs."""
|
||||
@@ -42,11 +91,16 @@ class SyncErgebnis:
|
||||
aktualisiert: int = 0
|
||||
gefiltert: int = 0
|
||||
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:
|
||||
if self.abbruch:
|
||||
return self.abbruch
|
||||
text = (
|
||||
f"{self.neu} neu, {self.aktualisiert} aktualisiert, "
|
||||
f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen)"
|
||||
f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen/Jahr/Sprache)"
|
||||
)
|
||||
if self.fehler:
|
||||
text += f"; {len(self.fehler)} Fehler"
|
||||
@@ -64,6 +118,7 @@ def speichere_oder_aktualisiere(db: Session, spiel: BggSpiel) -> bool:
|
||||
autor=spiel.autor,
|
||||
erscheinungsjahr=spiel.erscheinungsjahr,
|
||||
bgg_id=spiel.bgg_id,
|
||||
bild_url=spiel.bild_url,
|
||||
status=STATUS_NEUHEIT,
|
||||
quelle="boardgamegeek",
|
||||
)
|
||||
@@ -73,6 +128,10 @@ def speichere_oder_aktualisiere(db: Session, spiel: BggSpiel) -> bool:
|
||||
vorhanden.verlag = spiel.verlag
|
||||
vorhanden.autor = spiel.autor
|
||||
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
|
||||
|
||||
|
||||
@@ -86,11 +145,16 @@ class SyncService:
|
||||
*,
|
||||
thing_batch_groesse: int = THING_BATCH_GROESSE,
|
||||
max_treffer_pro_suchbegriff: int | None = None,
|
||||
jahr_filter: bool = True,
|
||||
deutscher_titel_filter: bool = True,
|
||||
) -> None:
|
||||
self.session_factory = session_factory
|
||||
self.client = client
|
||||
self.thing_batch_groesse = thing_batch_groesse
|
||||
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:
|
||||
ergebnis = SyncErgebnis()
|
||||
@@ -101,6 +165,13 @@ class SyncService:
|
||||
continue
|
||||
try:
|
||||
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
|
||||
_logger.warning("BGG-Sync fehlgeschlagen für %r: %s", begriff, exc)
|
||||
ergebnis.fehler.append(f"{begriff}: {exc}")
|
||||
@@ -122,6 +193,13 @@ class SyncService:
|
||||
if ist_moeglicher_prototyp(spiel.titel):
|
||||
ergebnis.gefiltert += 1
|
||||
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):
|
||||
ergebnis.neu += 1
|
||||
else:
|
||||
|
||||
24
plugins/neuheiten/templates/neuheiten/_cover.html
Normal file
24
plugins/neuheiten/templates/neuheiten/_cover.html
Normal 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">🎲</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">🎲</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -1,9 +1,61 @@
|
||||
<div id="neuheiten-liste">
|
||||
<p class="text-xs text-slate-500 mb-2">{{ anzahl }} Einträ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 {{ 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 }} ↗</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">→ 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äge gefunden.
|
||||
{% if ist_redaktion %}Nutze „Jetzt synchronisieren“, um die Liste aus BoardGameGeek zu befüllen.{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<!-- Desktop/Tablet (≥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">
|
||||
<thead class="bg-slate-50 text-left text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Cover</th>
|
||||
{% 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')] %}
|
||||
<th class="px-4 py-3 font-medium whitespace-nowrap">
|
||||
@@ -21,10 +73,21 @@
|
||||
<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 "neuheiten/_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 {{ 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 }} ↗</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
|
||||
<td class="px-4 py-3">{{ eintrag.autor or '—' }}</td>
|
||||
@@ -39,14 +102,14 @@
|
||||
{% if eintrag.status == 'neuheit' %}
|
||||
<form method="post" action="/planung/uebernehmen/{{ eintrag.id }}">
|
||||
<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">→ Zur Planung</button>
|
||||
class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2.5 py-2 rounded-lg whitespace-nowrap">→ Zur Planung</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<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äge gefunden.
|
||||
{% if ist_redaktion %}Nutze „Jetzt synchronisieren“, um die Liste aus BoardGameGeek zu befüllen.{% endif %}
|
||||
</td>
|
||||
@@ -56,3 +119,10 @@
|
||||
</table>
|
||||
</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äge …</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -20,16 +20,16 @@
|
||||
|
||||
<div class="flex flex-wrap items-end gap-3 mb-4">
|
||||
{% if ist_redaktion %}
|
||||
<form method="post" action="/neuheiten/sync" class="flex items-end gap-2">
|
||||
<div>
|
||||
<form method="post" action="/neuheiten/sync" class="flex flex-col sm:flex-row items-stretch sm:items-end gap-2">
|
||||
<div class="sm:w-72">
|
||||
<label for="suchbegriff" class="block text-xs font-medium text-slate-500 mb-1">
|
||||
Sofort auf BoardGameGeek suchen & importieren (optional)
|
||||
</label>
|
||||
<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>
|
||||
<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">
|
||||
↻ Jetzt synchronisieren
|
||||
</button>
|
||||
</form>
|
||||
@@ -41,20 +41,105 @@
|
||||
</p>
|
||||
</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">
|
||||
↻ 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) }} 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 %}
|
||||
· max. 1 Anfrage/Sekunde je Quelle · Duplikate gegen BGG werden per Titel-Match gefiltert
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/neuheiten"
|
||||
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']"
|
||||
class="flex flex-wrap gap-3 mb-4">
|
||||
hx-trigger="input changed delay:300ms from:find input[name='q'], change from:find select[name='status'], change from:find select[name='jahr']"
|
||||
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 …"
|
||||
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">
|
||||
<select name="status" class="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white">
|
||||
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="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>
|
||||
{% for option in status_optionen %}
|
||||
<option value="{{ option }}" {% if option == status_filter %}selected{% endif %}>{{ option }}</option>
|
||||
{% endfor %}
|
||||
</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>
|
||||
<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>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ from urllib.parse import quote
|
||||
|
||||
from fastapi import Depends, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from redaktionskern.auth.deps import AccessDenied, get_db, require_user
|
||||
@@ -83,7 +83,53 @@ class PlanungPlugin(BasePlugin):
|
||||
# ---------- Plugin-Vertrag ----------
|
||||
|
||||
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]:
|
||||
return [NavEntry(label=self.title, url="/planung")]
|
||||
@@ -161,6 +207,7 @@ class PlanungPlugin(BasePlugin):
|
||||
aktion: str,
|
||||
versteckt: dict[str, str],
|
||||
abbrechen_url: str = "/planung",
|
||||
bild_url: str | None = None,
|
||||
):
|
||||
"""Auswahl-Dialog bei Verlags-Konflikt: Welcher Verlag wird geführt?"""
|
||||
return self.context.templates.TemplateResponse(
|
||||
@@ -170,6 +217,7 @@ class PlanungPlugin(BasePlugin):
|
||||
"user": user,
|
||||
"titel_headline": f"Dedup-Prüfung — {titel}",
|
||||
"spiel_titel": titel,
|
||||
"bild_url": bild_url,
|
||||
"konflikte": ergebnis.konflikte,
|
||||
"konflikt_anzeige": KONFLIKT_ANZEIGE,
|
||||
"titel_empfehlung": ergebnis.titel_empfehlung,
|
||||
@@ -192,15 +240,22 @@ class PlanungPlugin(BasePlugin):
|
||||
status: str = "",
|
||||
meldung: 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)
|
||||
if status in GUELTIGE_STATUS:
|
||||
abfrage = abfrage.where(Planungseintrag.status == status)
|
||||
gesamt = db.scalar(
|
||||
select(func.count()).select_from(abfrage.subquery())
|
||||
) or 0
|
||||
eintraege = db.scalars(
|
||||
abfrage.order_by(
|
||||
Planungseintrag.aktualisiert_am.desc(), Planungseintrag.id.desc()
|
||||
)
|
||||
.offset((max(seite_nr, 1) - 1) * pro_seite)
|
||||
.limit(pro_seite)
|
||||
).all()
|
||||
namen = {
|
||||
benutzer.id: (benutzer.display_name or benutzer.username)
|
||||
@@ -221,7 +276,9 @@ class PlanungPlugin(BasePlugin):
|
||||
"ist_redaktion": user.role in REDAKTION,
|
||||
"meldung": meldung[: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}",
|
||||
versteckt={"neuheit_id": str(neuheit_id), "verlag_bestaetigt": "1"},
|
||||
abbrechen_url="/neuheiten",
|
||||
bild_url=zeile.bild_url,
|
||||
)
|
||||
|
||||
gewaehlter_verlag = (verlag_wahl.strip() or (zeile.verlag or "")) or None
|
||||
@@ -362,6 +420,8 @@ class PlanungPlugin(BasePlugin):
|
||||
verlag=gewaehlter_verlag,
|
||||
autor=zeile.autor,
|
||||
bgg_id=zeile.bgg_id,
|
||||
bild_url=zeile.bild_url,
|
||||
quellen_url=zeile.quellen_url,
|
||||
ausgabe="",
|
||||
rezensent_id=user.id,
|
||||
status="offen",
|
||||
|
||||
@@ -48,6 +48,9 @@ class Planungseintrag(Base):
|
||||
autor: Mapped[str | None] = mapped_column(String(300))
|
||||
#: BoardGameGeek-ID, sofern bekannt (Match-Kriterium der dedup-Prüfung).
|
||||
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).
|
||||
ausgabe: Mapped[str] = mapped_column(String(100), default="")
|
||||
rezensent_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
|
||||
24
plugins/planung/templates/planung/_cover.html
Normal file
24
plugins/planung/templates/planung/_cover.html
Normal 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">🎲</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">🎲</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -6,8 +6,16 @@
|
||||
{% if user.id == eintrag.rezensent_id %}du{% else %}{{ user.display_name }}{% endif %}
|
||||
</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"
|
||||
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>
|
||||
<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 }}"
|
||||
@@ -38,4 +46,5 @@
|
||||
<a href="{{ abbrechen_url }}" class="text-sm text-slate-500 hover:text-slate-800 underline">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -17,29 +17,30 @@
|
||||
|
||||
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
|
||||
<h2 class="font-semibold mb-3">Titel händisch nachtragen</h2>
|
||||
<form method="post" action="/planung/neu" class="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<!-- Mobil: Felder untereinander vollbreit; ab sm: flexibel nebeneinander -->
|
||||
<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>
|
||||
<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 class="sm:w-48 lg:w-52">
|
||||
<label for="verlag" class="block text-xs font-medium text-slate-500 mb-1">Verlag</label>
|
||||
<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 class="sm:w-32">
|
||||
<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"
|
||||
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 class="sm:w-32">
|
||||
<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"
|
||||
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">
|
||||
<input type="text" id="bgg_id" name="bgg_id" inputmode="numeric"
|
||||
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>
|
||||
<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üfen & eintragen
|
||||
</button>
|
||||
</form>
|
||||
@@ -47,22 +48,99 @@
|
||||
|
||||
<div class="flex items-center mb-3 gap-3">
|
||||
<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()">
|
||||
<option value="">Alle Status</option>
|
||||
{% for wert in status_optionen %}
|
||||
<option value="{{ wert }}" {% if wert == status_filter %}selected{% endif %}>{{ status_anzeige[wert] }}</option>
|
||||
{% endfor %}
|
||||
</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>
|
||||
<p class="text-xs text-slate-400 ml-auto">{{ anzahl }} Einträge</p>
|
||||
</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 {{ 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 }} ↗</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.">⚠ Prü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ö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äge — schiebe einen Titel aus den Neuheiten hierher oder trage ihn oben händisch nach.
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<!-- Desktop/Tablet (≥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">
|
||||
<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">Verlag</th>
|
||||
<th class="px-4 py-3 font-medium">Ausgabe</th>
|
||||
@@ -75,11 +153,20 @@
|
||||
<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 "planung/_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 {{ 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 }} ↗</a>
|
||||
{% endif %}
|
||||
{% if eintrag.pruefung %}
|
||||
<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' %}
|
||||
<form method="post" action="/planung/{{ eintrag.id }}/status">
|
||||
<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>
|
||||
{% endif %}
|
||||
{% if eintrag.status != 'abgeschlossen' %}
|
||||
<form method="post" action="/planung/{{ eintrag.id }}/status">
|
||||
<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>
|
||||
{% endif %}
|
||||
<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">
|
||||
<button type="submit" class="text-xs border border-red-200 text-red-600 hover:bg-red-50 px-2 py-1 rounded-lg whitespace-nowrap">Löschen</button>
|
||||
<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öschen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
@@ -129,7 +216,7 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<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äge — schiebe einen Titel aus den Neuheiten hierher oder trage ihn oben händisch nach.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -137,4 +224,11 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</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äge …</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,7 +7,14 @@
|
||||
der Eintrag angelegt wird.
|
||||
</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">⚠ {{ konflikte|length }} Treffer für „{{ spiel_titel }}“</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-slate-700">
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ aktion }}" class="bg-white border border-slate-200 rounded-xl shadow-sm p-4">
|
||||
{% for name, wert in versteckt.items() %}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
name = "spiele-redaktion"
|
||||
version = "0.1.0"
|
||||
description = "KI-Assistenz für Spielemagazin-Redaktionen — modularer Kern mit Plugin-Architektur"
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -14,13 +15,16 @@ dependencies = [
|
||||
"argon2-cffi>=23.1",
|
||||
"apscheduler>=3.11,<4",
|
||||
"httpx>=0.27",
|
||||
"beautifulsoup4>=4.12",
|
||||
"rapidfuzz>=3.14.5",
|
||||
"weasyprint>=69.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"httpx>=0.27",
|
||||
"freezegun>=1.5.5",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -16,6 +16,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape
|
||||
from sqlalchemy import func, select
|
||||
@@ -82,6 +83,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
session_factory=session_factory,
|
||||
settings=settings,
|
||||
templates=templates,
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
with engine.begin() as conn:
|
||||
@@ -108,6 +110,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
redoc_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.engine = engine
|
||||
app.state.session_factory = session_factory
|
||||
|
||||
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from redaktionskern.config import Settings
|
||||
from redaktionskern.plugin_loader import PluginRegistry
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -49,11 +50,16 @@ class PluginContext:
|
||||
session_factory: "sessionmaker",
|
||||
settings: "Settings",
|
||||
templates: "Jinja2Templates",
|
||||
registry: "PluginRegistry | None" = None,
|
||||
) -> None:
|
||||
self.engine = engine
|
||||
self.session_factory = session_factory
|
||||
self.settings = settings
|
||||
self.templates = templates
|
||||
#: Registry aller geladenen Plugins — damit auch Hintergrund-Jobs
|
||||
#: eines Plugins die öffentliche API anderer Plugins erreichen
|
||||
#: (in Anfragen geht das üblicherweise über request.app.state.registry).
|
||||
self.registry = registry
|
||||
|
||||
|
||||
class BasePlugin:
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from importlib.metadata import entry_points
|
||||
from pathlib import Path
|
||||
|
||||
@@ -81,6 +82,19 @@ def _aus_verzeichnis(verzeichnis: Path) -> list[BasePlugin]:
|
||||
if spec is None or spec.loader is None:
|
||||
raise PluginError(f"Plugin '{eintrag.name}' kann nicht geladen werden.")
|
||||
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
|
||||
spec.loader.exec_module(modul)
|
||||
if not hasattr(modul, "plugin"):
|
||||
|
||||
BIN
src/redaktionskern/static/powered-by-bgg.png
Normal file
BIN
src/redaktionskern/static/powered-by-bgg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -9,16 +9,32 @@
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-slate-100 text-slate-800 min-h-screen flex flex-col">
|
||||
<header class="bg-white border-b border-slate-200">
|
||||
<nav class="max-w-6xl mx-auto px-4 py-3 flex items-center gap-5" aria-label="Hauptnavigation">
|
||||
<header class="bg-white border-b border-slate-200" x-data="{ menue: false }">
|
||||
<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">🎳 {{ app_name }}</a>
|
||||
<!-- Desktop: horizontale Navigationsleiste -->
|
||||
<span class="hidden md:flex items-center gap-5 flex-1">
|
||||
{% for eintrag in plugin_nav %}
|
||||
<a href="{{ eintrag.url }}" class="text-sm text-slate-600 hover:text-emerald-700">{{ eintrag.label }}</a>
|
||||
{% endfor %}
|
||||
<div class="ml-auto relative" x-data="{ offen: false }">
|
||||
</span>
|
||||
<!-- Mobil: Burger-Button (Alpine.js), Touch-Ziel ≥≥ 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 %}
|
||||
<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 }} ▾
|
||||
</button>
|
||||
<div x-show="offen" x-cloak @click.outside="offen = false"
|
||||
@@ -33,16 +49,36 @@
|
||||
</form>
|
||||
</div>
|
||||
{% 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 %}
|
||||
</div>
|
||||
</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>
|
||||
<main class="flex-1 max-w-6xl w-full mx-auto px-4 py-6">
|
||||
{% block inhalt %}{% endblock %}
|
||||
</main>
|
||||
<footer class="text-center text-xs text-slate-400 py-4">
|
||||
{{ app_name }} · 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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -33,19 +33,27 @@
|
||||
|
||||
<div>
|
||||
<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ä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"
|
||||
{% if ziel and ziel.id == user.id %}disabled title="Eigene Rolle kann nicht geändert werden"{% endif %}
|
||||
class="w-full border border-slate-300 rounded-lg px-3 py-2 bg-white">
|
||||
{% for rollen_wert, rollen_text in rollen_anzeige.items() %}
|
||||
<option value="{{ rollen_wert }}" {% if form.role == rollen_wert %}selected{% endif %}>{{ rollen_text }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if aktion == 'bearbeiten' %}
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<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ä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
|
||||
</label>
|
||||
{% endif %}
|
||||
@@ -58,13 +66,13 @@
|
||||
class="w-full border border-slate-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<div class="flex flex-col sm:flex-row gap-3 pt-2">
|
||||
<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
|
||||
</button>
|
||||
<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>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold">Benutzerverwaltung</h1>
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
</div>
|
||||
<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
|
||||
</button>
|
||||
</form>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -28,6 +28,8 @@ models = _neuheiten.models
|
||||
|
||||
BggClient = bgg.BggClient
|
||||
BggFehler = bgg.BggFehler
|
||||
BggAuthFehler = bgg.BggAuthFehler
|
||||
MELDUNG_401 = bgg.MELDUNG_401
|
||||
SuchTreffer = bgg.SuchTreffer
|
||||
BggSpiel = bgg.BggSpiel
|
||||
parse_suche = bgg.parse_suche
|
||||
@@ -40,3 +42,48 @@ speichere_oder_aktualisiere = sync_modul.speichere_oder_aktualisiere
|
||||
|
||||
Neuheit = models.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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Gemeinsame Fixtures: App mit temporaerer SQLite-DB und TestClient."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
@@ -22,8 +23,16 @@ def _hintergrundjobs_deaktiviert(monkeypatch):
|
||||
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "1").
|
||||
"""
|
||||
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "0")
|
||||
# Auch die täglichen Hintergrund-Jobs bleiben in Tests aus.
|
||||
monkeypatch.setenv("SPIELE_ARCHIV_JOB_AKTIV", "0")
|
||||
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_AKTIV", "0")
|
||||
# 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.
|
||||
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
|
||||
@@ -66,6 +75,58 @@ def melde_an(
|
||||
return antwort
|
||||
|
||||
|
||||
def _installiere_weasyprint():
|
||||
"""Best effort: WeasyPrint nachinstallieren (z. B. frisches Checkout ohne uv sync).
|
||||
|
||||
Zuerst uv im Projektverzeichnis, als Rueckfallebene pip. Scheitert beides
|
||||
(kein Netz, keine Tools), skippen die PDF-Tests spaeter sauber.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
befehle = [
|
||||
["uv", "pip", "install", "weasyprint"],
|
||||
[sys.executable, "-m", "pip", "install", "--quiet", "weasyprint"],
|
||||
]
|
||||
for befehl in befehle:
|
||||
if shutil.which(befehl[0]) is None:
|
||||
continue
|
||||
try:
|
||||
subprocess.run(
|
||||
befehl,
|
||||
cwd=str(PROJEKT_WURZEL),
|
||||
check=False,
|
||||
timeout=180,
|
||||
capture_output=True,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def pdf_faehig():
|
||||
"""WeasyPrint bereitstellen; PDF-Tests ueberspringen, wenn das System es nicht hergibt.
|
||||
|
||||
- fehlt das Paket: einmaliger Installationsversuch (uv, sonst pip)
|
||||
- fehlen die System-Bibliotheken (Pango/fontconfig & Co.): Probe-Renderung
|
||||
schlaegt fehl -> pytest.skip statt rotem Test
|
||||
"""
|
||||
try:
|
||||
import weasyprint # noqa: F401
|
||||
except ImportError:
|
||||
_installiere_weasyprint()
|
||||
try:
|
||||
import weasyprint # noqa: F401
|
||||
except ImportError:
|
||||
pytest.skip("WeasyPrint ist nicht installiert und konnte nicht nachinstalliert werden.")
|
||||
from weasyprint import HTML
|
||||
|
||||
try:
|
||||
HTML(string="<p>Probe äöü ß</p>").write_pdf()
|
||||
except Exception as exc:
|
||||
pytest.skip(f"WeasyPrint kann auf diesem System nicht rendern (System-Bibliotheken fehlen?): {exc}")
|
||||
|
||||
|
||||
def lege_benutzer_an(app, username: str, role: str, password: str = "test-12345678"):
|
||||
"""Legt direkt per DB einen Benutzer an (fuer Rollen-Tests)."""
|
||||
from redaktionskern.auth.models import User
|
||||
|
||||
675
tests/test_archiv.py
Normal file
675
tests/test_archiv.py
Normal file
@@ -0,0 +1,675 @@
|
||||
"""Tests: Plugin „archiv“ — 12-Monats-Regel, Job, Wiederherstellen, Rollen.
|
||||
|
||||
Alle Zeitpunkte werden mit eingefrorener Uhr geprüft: Der Service nimmt eine
|
||||
einfrierbare „jetzt“-Fabrik entgegen (Plugin-Attribut `_jetzt` bzw.
|
||||
Konstruktor-Argument), die Systemuhr wird nicht manipuliert. HTTP-Antworten
|
||||
nutzen den echten TestClient; die Partner-Plugins (audit-log, neuheiten,
|
||||
planung) laufen echt mit — nur ihre Tabellen werden direkt befüllt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import insert, select
|
||||
|
||||
from redaktionskern.app import create_app
|
||||
from redaktionskern.auth.models import User
|
||||
from redaktionskern.config import Settings
|
||||
from redaktionskern.contracts import Migration
|
||||
from redaktionskern.db import Base
|
||||
from tests.conftest import ADMIN_PASSWORD, lege_benutzer_an, melde_an
|
||||
|
||||
from plugins.archiv.models import ArchivNeuheit, ArchivPlanung
|
||||
from plugins.archiv.service import ArchivService, als_utc, naiv_utc, vor_monaten
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
# ---------------- Hilfen ----------------
|
||||
|
||||
def einfriere_uhr(jahr, monat, tag, stunde=12, minute=0, sekunde=0):
|
||||
"""Fabrik für eine eingefrorene UTC-Uhr."""
|
||||
moment = datetime(jahr, monat, tag, stunde, minute, sekunde, tzinfo=UTC)
|
||||
return lambda: moment
|
||||
|
||||
|
||||
def archiv_service(app, jetzt) -> ArchivService:
|
||||
return ArchivService(app.state.session_factory, jetzt=jetzt)
|
||||
|
||||
|
||||
def friere_plugin_uhr_ein(app, jetzt) -> None:
|
||||
app.state.registry.get("archiv")._jetzt = jetzt
|
||||
|
||||
|
||||
def fuege_neuheit_hinzu(app, **felder) -> int:
|
||||
felder.setdefault("titel", "Testspiel")
|
||||
felder.setdefault("bgg_id", 4711)
|
||||
tabelle = Base.metadata.tables["neuheiten"]
|
||||
with app.state.session_factory() as db:
|
||||
ergebnis = db.execute(insert(tabelle).values(**felder))
|
||||
db.commit()
|
||||
return ergebnis.inserted_primary_key[0]
|
||||
|
||||
|
||||
def fuege_planung_hinzu(app, **felder) -> int:
|
||||
felder.setdefault("titel", "Testspiel")
|
||||
felder.setdefault("rezensent_id", 1)
|
||||
tabelle = Base.metadata.tables["planungsliste"]
|
||||
with app.state.session_factory() as db:
|
||||
ergebnis = db.execute(insert(tabelle).values(**felder))
|
||||
db.commit()
|
||||
return ergebnis.inserted_primary_key[0]
|
||||
|
||||
|
||||
def _alle(app, tabellenname: str) -> list[dict]:
|
||||
tabelle = Base.metadata.tables[tabellenname]
|
||||
with app.state.session_factory() as db:
|
||||
return [dict(z) for z in db.execute(select(tabelle)).mappings().all()]
|
||||
|
||||
|
||||
def zaehle(app, tabellenname: str) -> int:
|
||||
return len(_alle(app, tabellenname))
|
||||
|
||||
|
||||
def audit_zeilen(app) -> list[dict]:
|
||||
return _alle(app, "audit_eintraege")
|
||||
|
||||
|
||||
def sichtbar(antwort) -> str:
|
||||
return html.unescape(antwort.text)
|
||||
|
||||
|
||||
def cron_feld(trigger, name: str) -> int:
|
||||
"""Liest ein CronTrigger-Feld (z. B. hour=3 → 3)."""
|
||||
feld = next(f for f in trigger.fields if f.name == name)
|
||||
ausdruck = feld.expressions[0]
|
||||
return int(getattr(ausdruck, "first", ausdruck))
|
||||
|
||||
|
||||
JETZT = einfriere_uhr(2025, 8, 21, 12, 0, 0) # Stichtag: 21.08.2024 12:00 UTC
|
||||
|
||||
|
||||
# ---------------- Migration ----------------
|
||||
|
||||
def test_migration_legt_beide_archiv_tabellen_an(app):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspektion = inspect(app.state.engine)
|
||||
neuheiten_spalten = {c["name"] for c in inspektion.get_columns("archiv_neuheiten")}
|
||||
planung_spalten = {c["name"] for c in inspektion.get_columns("archiv_planung")}
|
||||
meta = {"quell_id", "archiviert_am", "archiviert_von", "grund", "referenz_am"}
|
||||
assert {
|
||||
"id", "titel", "verlag", "autor", "erscheinungsjahr",
|
||||
"bgg_id", "status", "quelle", "erstellt_am", "aktualisiert_am",
|
||||
} | meta <= neuheiten_spalten
|
||||
assert {
|
||||
"id", "titel", "verlag", "autor", "bgg_id", "ausgabe", "rezensent_id",
|
||||
"status", "notizen", "quelle", "pruefung",
|
||||
"erstellt_am", "aktualisiert_am",
|
||||
} | meta <= planung_spalten
|
||||
|
||||
|
||||
# ---------------- Monatsarithmetik & Zeitzonen (Einheitstests) ----------------
|
||||
|
||||
def test_vor_monaten_klemmt_monatstage():
|
||||
assert vor_monaten(datetime(2025, 3, 31), 1) == datetime(2025, 2, 28)
|
||||
assert vor_monaten(datetime(2024, 2, 29), 12) == datetime(2023, 2, 28)
|
||||
assert vor_monaten(datetime(2024, 3, 31), 1) == datetime(2024, 2, 29) # Schaltjahr
|
||||
assert vor_monaten(datetime(2025, 1, 15), 12) == datetime(2024, 1, 15)
|
||||
|
||||
|
||||
def test_als_utc_normalisiert_naive_und_bewohnte_zeiten():
|
||||
naive = datetime(2025, 3, 10, 23, 30)
|
||||
assert als_utc(naive).tzinfo == UTC
|
||||
assert als_utc(naive).hour == 23 # naive Werte gelten als UTC
|
||||
|
||||
berlin = timezone(timedelta(hours=2))
|
||||
bewohnt = datetime(2025, 3, 11, 1, 0, tzinfo=berlin) # = 23:00 UTC am 10.03.
|
||||
assert als_utc(bewohnt).utcoffset() == timedelta(0)
|
||||
assert (als_utc(bewohnt).month, als_utc(bewohnt).day, als_utc(bewohnt).hour) == (3, 10, 23)
|
||||
assert naiv_utc(bewohnt) == datetime(2025, 3, 10, 23, 0)
|
||||
|
||||
|
||||
def test_stichtag_rechnet_in_utc_unabhaengig_von_der_uhrzeitzone():
|
||||
berlin = timezone(timedelta(hours=2))
|
||||
# Die Uhr steht auf Berliner Zeit: 01:30 +02:00 == 23:30 UTC am Vortag.
|
||||
service = ArchivService(
|
||||
session_factory=None,
|
||||
jetzt=lambda: datetime(2025, 3, 11, 1, 30, tzinfo=berlin),
|
||||
)
|
||||
assert service.jetzt().utcoffset() == timedelta(0)
|
||||
assert (service.jetzt().hour, service.jetzt().minute) == (23, 30)
|
||||
assert service.stichtag() == datetime(2024, 3, 10, 23, 30, tzinfo=UTC)
|
||||
|
||||
|
||||
# ---------------- 12-Monats-Regel: Neuheiten ----------------
|
||||
|
||||
def test_alte_neuheit_wird_vollstaendig_archiviert_und_auditiert(app):
|
||||
quell_id = fuege_neuheit_hinzu(
|
||||
app,
|
||||
titel="Alter Klassiker",
|
||||
verlag="Alt-Verlag",
|
||||
autor="A. Autorin",
|
||||
erscheinungsjahr=2023,
|
||||
bgg_id=1001,
|
||||
status="neuheit",
|
||||
quelle="boardgamegeek",
|
||||
erstellt_am=datetime(2023, 5, 1, 10, 0),
|
||||
)
|
||||
|
||||
lauf, ereignisse = archiv_service(app, JETZT).lauf()
|
||||
# Audit-Einträge schreibt der Aufrufkontext (Route/Job) über die Plugin-API:
|
||||
app.state.registry.get("archiv")._auditiere_archivierung(ereignisse)
|
||||
|
||||
assert (lauf.neuheiten_archiviert, lauf.planung_archiviert) == (1, 0)
|
||||
assert len(ereignisse) == 1
|
||||
assert zaehle(app, "neuheiten") == 0
|
||||
|
||||
zeile = _alle(app, "archiv_neuheiten")[0]
|
||||
# Sicherungsdatei-Prinzip: alle Felder vollständig erhalten.
|
||||
assert zeile["quell_id"] == quell_id
|
||||
assert zeile["titel"] == "Alter Klassiker"
|
||||
assert zeile["verlag"] == "Alt-Verlag"
|
||||
assert zeile["autor"] == "A. Autorin"
|
||||
assert zeile["erscheinungsjahr"] == 2023
|
||||
assert zeile["bgg_id"] == 1001
|
||||
assert zeile["status"] == "neuheit"
|
||||
assert zeile["quelle"] == "boardgamegeek"
|
||||
assert zeile["erstellt_am"] == datetime(2023, 5, 1, 10, 0)
|
||||
# Referenz = Ende des Erscheinungsjahres (BGG liefert nur das Jahr).
|
||||
assert zeile["referenz_am"] == datetime(2023, 12, 31, 23, 59, 59)
|
||||
assert "2023" in zeile["grund"]
|
||||
assert zeile["archiviert_von"] == "System"
|
||||
|
||||
# Genau ein Audit-Eintrag pro Archivierung, Akteur „System“.
|
||||
eintraege = audit_zeilen(app)
|
||||
assert len(eintraege) == 1
|
||||
audit = eintraege[0]
|
||||
assert audit["actor_name"] == "System"
|
||||
assert audit["action"] == "verschoben"
|
||||
assert audit["objekt_typ"] == "neuheit"
|
||||
assert audit["objekt_id"] == str(quell_id)
|
||||
assert audit["details"]["titel"] == "Alter Klassiker"
|
||||
assert audit["details"]["ziel"] == "archiv"
|
||||
assert "12 Monate" in audit["details"]["grund"]
|
||||
|
||||
|
||||
def test_junge_neuheiten_bleiben_in_der_aktiven_liste(app):
|
||||
fuege_neuheit_hinzu(app, titel="Frisch 2025", bgg_id=1, erscheinungsjahr=2025)
|
||||
fuege_neuheit_hinzu(app, titel="Letztes Jahr", bgg_id=2, erscheinungsjahr=2024)
|
||||
fuege_neuheit_hinzu(app, titel="Uralt", bgg_id=3, erscheinungsjahr=2023)
|
||||
|
||||
lauf, _ = archiv_service(app, JETZT).lauf()
|
||||
|
||||
# Ein Titel des Jahres 2024 zählt erst nach dem 31.12.2025 als älter als
|
||||
# 12 Monate (großzügige Jahres-Auslegung) — nur 2023 fällt unter die Regel.
|
||||
assert lauf.neuheiten_archiviert == 1
|
||||
assert {z["titel"] for z in _alle(app, "neuheiten")} == {
|
||||
"Frisch 2025", "Letztes Jahr",
|
||||
}
|
||||
assert {z["titel"] for z in _alle(app, "archiv_neuheiten")} == {"Uralt"}
|
||||
|
||||
|
||||
def test_neuheit_ohne_jahr_nutzt_eintragsdatum(app):
|
||||
alt = datetime(2024, 6, 1, 9, 0) # > 12 Monate vor dem 21.08.2025
|
||||
jung = datetime(2024, 10, 1, 9, 0) # < 12 Monate
|
||||
fuege_neuheit_hinzu(app, titel="Ohne Jahr alt", bgg_id=10,
|
||||
erscheinungsjahr=None, erstellt_am=alt)
|
||||
fuege_neuheit_hinzu(app, titel="Ohne Jahr jung", bgg_id=11,
|
||||
erscheinungsjahr=None, erstellt_am=jung)
|
||||
|
||||
lauf, _ = archiv_service(app, JETZT).lauf()
|
||||
|
||||
assert lauf.neuheiten_archiviert == 1
|
||||
zeile = _alle(app, "archiv_neuheiten")[0]
|
||||
assert zeile["titel"] == "Ohne Jahr alt"
|
||||
assert zeile["referenz_am"] == alt
|
||||
assert "eingetragen" in zeile["grund"]
|
||||
|
||||
|
||||
# ---------------- Grenzfälle & Planungseinträge ----------------
|
||||
|
||||
def test_grenzfall_genau_12_monate_bleibt_eine_sekunde_elter_faellt(app):
|
||||
stichtag = datetime(2024, 3, 15, 12, 0) # exakt „jetzt − 12 Monate“
|
||||
fuege_planung_hinzu(app, titel="Exakt 12 Monate", erstellt_am=stichtag)
|
||||
fuege_planung_hinzu(
|
||||
app, titel="Eine Sekunde älter", erstellt_am=stichtag - timedelta(seconds=1)
|
||||
)
|
||||
fuege_planung_hinzu(
|
||||
app, titel="Eine Sekunde jünger", erstellt_am=stichtag + timedelta(seconds=1)
|
||||
)
|
||||
|
||||
lauf, _ = archiv_service(app, einfriere_uhr(2025, 3, 15, 12, 0, 0)).lauf()
|
||||
|
||||
# „älter als 12 Monate“ ist streng: genau 12 Monate bleibt aktiv.
|
||||
assert lauf.planung_archiviert == 1
|
||||
assert {z["titel"] for z in _alle(app, "archiv_planung")} == {"Eine Sekunde älter"}
|
||||
assert {z["titel"] for z in _alle(app, "planungsliste")} == {
|
||||
"Exakt 12 Monate", "Eine Sekunde jünger",
|
||||
}
|
||||
|
||||
|
||||
def test_zeitzone_grenzfall_um_utc_mitternacht(app):
|
||||
# Eingefrorene UTC-Uhr 23:30:30 → Stichtag 2024-03-10 23:30:30 UTC.
|
||||
# In Berlin wäre es bereits der 11.03., 00:30 — die Entscheidung folgt
|
||||
# trotzdem konsequent UTC.
|
||||
jetzt = einfriere_uhr(2025, 3, 10, 23, 30, 30)
|
||||
fuege_planung_hinzu(
|
||||
app, titel="Knapp drunter", erstellt_am=datetime(2024, 3, 10, 23, 30, 0)
|
||||
)
|
||||
fuege_planung_hinzu(
|
||||
app, titel="Knapp drueber", erstellt_am=datetime(2024, 3, 10, 23, 31, 0)
|
||||
)
|
||||
|
||||
lauf, _ = archiv_service(app, jetzt).lauf()
|
||||
|
||||
assert lauf.planung_archiviert == 1
|
||||
assert {z["titel"] for z in _alle(app, "archiv_planung")} == {"Knapp drunter"}
|
||||
assert {z["titel"] for z in _alle(app, "planungsliste")} == {"Knapp drueber"}
|
||||
|
||||
|
||||
def test_schaltjahr_29_februar_wird_geklemmt_nicht_uebersprungen(app):
|
||||
fuege_planung_hinzu(
|
||||
app, titel="29. Februar", erstellt_am=datetime(2024, 2, 29, 12, 0)
|
||||
)
|
||||
|
||||
# 28.02.2025: Stichtag 29.02.2024 wird auf den 28.02 geklemmt — der
|
||||
# Eintrag liegt einen Tag „danach“ und bleibt aktiv.
|
||||
lauf, _ = archiv_service(app, einfriere_uhr(2025, 2, 28, 12, 0)).lauf()
|
||||
assert lauf.planung_archiviert == 0
|
||||
|
||||
# 01.03.2025: Stichtag 01.03.2024 → der 29.02.2024 ist älter → Archiv.
|
||||
lauf, _ = archiv_service(app, einfriere_uhr(2025, 3, 1, 12, 0)).lauf()
|
||||
assert lauf.planung_archiviert == 1
|
||||
assert {z["titel"] for z in _alle(app, "archiv_planung")} == {"29. Februar"}
|
||||
|
||||
|
||||
def test_planungseintrag_wird_vollstaendig_archiviert(app):
|
||||
fuege_planung_hinzu(
|
||||
app,
|
||||
titel="Altes Planungsspiel",
|
||||
verlag="Plan-Verlag",
|
||||
autor="B. Autor",
|
||||
bgg_id=2002,
|
||||
ausgabe="3/2024",
|
||||
rezensent_id=1,
|
||||
status="in_bearbeitung",
|
||||
notizen="Rezension angefangen",
|
||||
quelle="neuheiten",
|
||||
pruefung=[{"art": "titel", "beschreibung": "ähnlicher Titel"}],
|
||||
erstellt_am=datetime(2024, 5, 1, 8, 30),
|
||||
)
|
||||
|
||||
lauf, ereignisse = archiv_service(app, JETZT).lauf()
|
||||
app.state.registry.get("archiv")._auditiere_archivierung(ereignisse)
|
||||
|
||||
assert (lauf.neuheiten_archiviert, lauf.planung_archiviert) == (0, 1)
|
||||
assert zaehle(app, "planungsliste") == 0
|
||||
|
||||
zeile = _alle(app, "archiv_planung")[0]
|
||||
assert zeile["titel"] == "Altes Planungsspiel"
|
||||
assert zeile["verlag"] == "Plan-Verlag"
|
||||
assert zeile["autor"] == "B. Autor"
|
||||
assert zeile["bgg_id"] == 2002
|
||||
assert zeile["ausgabe"] == "3/2024"
|
||||
assert zeile["status"] == "in_bearbeitung"
|
||||
assert zeile["notizen"] == "Rezension angefangen"
|
||||
assert zeile["quelle"] == "neuheiten"
|
||||
assert zeile["pruefung"] == [{"art": "titel", "beschreibung": "ähnlicher Titel"}]
|
||||
assert zeile["erstellt_am"] == datetime(2024, 5, 1, 8, 30)
|
||||
assert zeile["referenz_am"] == datetime(2024, 5, 1, 8, 30)
|
||||
|
||||
audit = audit_zeilen(app)[0]
|
||||
assert audit["objekt_typ"] == "planungseintrag"
|
||||
assert audit["actor_name"] == "System"
|
||||
assert audit["details"]["herkunft"] == "planung"
|
||||
|
||||
|
||||
def test_lauf_ist_idempotent(app):
|
||||
fuege_neuheit_hinzu(app, titel="Alt", bgg_id=1, erscheinungsjahr=2020)
|
||||
fuege_planung_hinzu(app, titel="Alt geplant", erstellt_am=datetime(2023, 1, 1))
|
||||
|
||||
service = archiv_service(app, JETZT)
|
||||
erster, ereignisse_eins = service.lauf()
|
||||
zweiter, ereignisse_zwei = service.lauf()
|
||||
plugin = app.state.registry.get("archiv")
|
||||
plugin._auditiere_archivierung(ereignisse_eins)
|
||||
plugin._auditiere_archivierung(ereignisse_zwei)
|
||||
|
||||
assert erster.gesamt == 2
|
||||
assert len(ereignisse_eins) == 2
|
||||
assert zweiter.gesamt == 0
|
||||
assert ereignisse_zwei == []
|
||||
assert zaehle(app, "archiv_neuheiten") == 1
|
||||
assert zaehle(app, "archiv_planung") == 1
|
||||
assert len([a for a in audit_zeilen(app) if a["actor_name"] == "System"]) == 2
|
||||
|
||||
|
||||
def test_fehlende_partner_tabellen_brechen_nicht(tmp_path):
|
||||
"""Ohne neuheiten-/planungsliste-Tabelle läuft der Lauf leer durch."""
|
||||
from redaktionskern.db import make_engine, make_session_factory
|
||||
|
||||
engine = make_engine(f"sqlite:///{tmp_path / 'leer.db'}")
|
||||
fabrik = make_session_factory(engine)
|
||||
with engine.begin() as conn:
|
||||
ArchivNeuheit.__table__.create(conn, checkfirst=True)
|
||||
ArchivPlanung.__table__.create(conn, checkfirst=True)
|
||||
|
||||
try:
|
||||
lauf, ereignisse = ArchivService(fabrik, jetzt=JETZT).lauf()
|
||||
assert lauf.gesamt == 0
|
||||
assert ereignisse == []
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ---------------- Hintergrund-Job & Scheduler ----------------
|
||||
|
||||
def test_job_laeuft_und_schreibt_audit_eintraege(app):
|
||||
friere_plugin_uhr_ein(app, JETZT)
|
||||
fuege_neuheit_hinzu(app, titel="Job-Kandidat", bgg_id=3003, erscheinungsjahr=2022)
|
||||
fuege_planung_hinzu(app, titel="Job-Planung", erstellt_am=datetime(2023, 2, 2))
|
||||
|
||||
plugin = app.state.registry.get("archiv")
|
||||
plugin._archiv_job()
|
||||
|
||||
assert zaehle(app, "neuheiten") == 0
|
||||
assert zaehle(app, "planungsliste") == 0
|
||||
assert zaehle(app, "archiv_neuheiten") == 1
|
||||
assert zaehle(app, "archiv_planung") == 1
|
||||
audit = audit_zeilen(app)
|
||||
assert len(audit) == 2
|
||||
assert {a["objekt_typ"] for a in audit} == {"neuheit", "planungseintrag"}
|
||||
|
||||
|
||||
def test_job_ohne_audit_log_plugin_laeuft_trotzdem(app, monkeypatch):
|
||||
friere_plugin_uhr_ein(app, JETZT)
|
||||
fuege_neuheit_hinzu(app, titel="Ohne Audit", bgg_id=3004, erscheinungsjahr=2021)
|
||||
|
||||
registry = app.state.registry
|
||||
original_get = registry.get
|
||||
monkeypatch.setattr(
|
||||
registry,
|
||||
"get",
|
||||
lambda name: None if name == "audit-log" else original_get(name),
|
||||
)
|
||||
|
||||
plugin = app.state.registry.get("archiv")
|
||||
plugin._archiv_job() # darf nicht werfen
|
||||
assert zaehle(app, "archiv_neuheiten") == 1
|
||||
assert audit_zeilen(app) == []
|
||||
|
||||
|
||||
def test_scheduler_standardmaessig_deaktiviert_in_tests(app):
|
||||
assert app.state.registry.get("archiv")._scheduler is None
|
||||
|
||||
|
||||
def test_scheduler_laesst_sich_aktivieren_und_stoppt_beim_entladen(
|
||||
monkeypatch, settings
|
||||
):
|
||||
monkeypatch.setenv("SPIELE_ARCHIV_JOB_AKTIV", "1")
|
||||
|
||||
app = create_app(settings)
|
||||
plugin = app.state.registry.get("archiv")
|
||||
with TestClient(app):
|
||||
assert plugin._scheduler is not None
|
||||
assert plugin._scheduler.running
|
||||
job = plugin._scheduler.get_job("archiv-taeglich")
|
||||
assert job is not None
|
||||
assert cron_feld(job.trigger, "hour") == 3 # Standard 03:00 …
|
||||
assert cron_feld(job.trigger, "minute") == 0 # … täglich
|
||||
|
||||
assert plugin._scheduler is None # on_unload hat ihn gestoppt
|
||||
|
||||
|
||||
def test_scheduler_uhrzeit_ist_konfigurierbar(monkeypatch, settings):
|
||||
monkeypatch.setenv("SPIELE_ARCHIV_JOB_AKTIV", "1")
|
||||
monkeypatch.setenv("SPIELE_ARCHIV_JOB_UHRZEIT", "05:30")
|
||||
app = create_app(settings)
|
||||
plugin = app.state.registry.get("archiv")
|
||||
with TestClient(app):
|
||||
job = plugin._scheduler.get_job("archiv-taeglich")
|
||||
assert cron_feld(job.trigger, "hour") == 5
|
||||
assert cron_feld(job.trigger, "minute") == 30
|
||||
|
||||
|
||||
def test_scheduler_unsinnige_uhrzeit_faellt_auf_standard(monkeypatch, settings):
|
||||
monkeypatch.setenv("SPIELE_ARCHIV_JOB_AKTIV", "1")
|
||||
monkeypatch.setenv("SPIELE_ARCHIV_JOB_UHRZEIT", "25:99")
|
||||
app = create_app(settings)
|
||||
plugin = app.state.registry.get("archiv")
|
||||
with TestClient(app):
|
||||
job = plugin._scheduler.get_job("archiv-taeglich")
|
||||
assert cron_feld(job.trigger, "hour") == 3
|
||||
assert cron_feld(job.trigger, "minute") == 0
|
||||
|
||||
|
||||
def test_scheduler_aktiv_ist_der_standard(monkeypatch, settings):
|
||||
monkeypatch.setenv("SPIELE_ARCHIV_JOB_AKTIV", "1")
|
||||
app = create_app(settings)
|
||||
plugin = app.state.registry.get("archiv")
|
||||
with TestClient(app):
|
||||
assert plugin._scheduler is not None and plugin._scheduler.running
|
||||
|
||||
|
||||
# ---------------- Wiederherstellen ----------------
|
||||
|
||||
def test_wiederherstellen_neuheit_zurueck_in_aktive_liste(app, client):
|
||||
melde_an(client)
|
||||
friere_plugin_uhr_ein(app, JETZT)
|
||||
fuege_neuheit_hinzu(
|
||||
app, titel="Rückkehrer", verlag="V", autor="A",
|
||||
erscheinungsjahr=2023, bgg_id=4004, status="neuheit",
|
||||
erstellt_am=datetime(2023, 2, 1),
|
||||
)
|
||||
app.state.registry.get("archiv")._archiv_job()
|
||||
archiv_id = _alle(app, "archiv_neuheiten")[0]["id"]
|
||||
|
||||
folgeseite = client.post(f"/archiv/neuheiten/{archiv_id}/wiederherstellen")
|
||||
assert folgeseite.status_code == 200
|
||||
assert zaehle(app, "archiv_neuheiten") == 0
|
||||
|
||||
aktiv = _alle(app, "neuheiten")
|
||||
assert len(aktiv) == 1
|
||||
eintrag = aktiv[0]
|
||||
assert eintrag["titel"] == "Rückkehrer"
|
||||
assert eintrag["bgg_id"] == 4004
|
||||
assert eintrag["erscheinungsjahr"] == 2023
|
||||
assert eintrag["status"] == "neuheit"
|
||||
|
||||
seite = sichtbar(folgeseite)
|
||||
assert "zurück in die Neuheitenliste" in seite
|
||||
|
||||
audit = [a for a in audit_zeilen(app) if a["actor_name"] == "admin"]
|
||||
assert len(audit) == 1
|
||||
assert audit[0]["action"] == "verschoben"
|
||||
assert audit[0]["objekt_typ"] == "neuheit"
|
||||
assert audit[0]["objekt_id"] == str(eintrag["id"])
|
||||
assert audit[0]["details"]["von"] == "archiv"
|
||||
assert audit[0]["details"]["ziel"] == "neuheiten"
|
||||
assert audit[0]["details"]["titel"] == "Rückkehrer"
|
||||
|
||||
|
||||
def test_wiederherstellen_planung_mit_urspruenglichem_rezensenten(app, client):
|
||||
lege_benutzer_an(app, "reza", "rezensent")
|
||||
with app.state.session_factory() as db:
|
||||
reza_id = db.scalar(select(User.id).where(User.username == "reza"))
|
||||
|
||||
melde_an(client)
|
||||
friere_plugin_uhr_ein(app, JETZT)
|
||||
fuege_planung_hinzu(
|
||||
app, titel="Plan-Rückkehrer", rezensent_id=reza_id, status="offen",
|
||||
ausgabe="1/2024", erstellt_am=datetime(2023, 3, 3),
|
||||
)
|
||||
app.state.registry.get("archiv")._archiv_job()
|
||||
archiv_id = _alle(app, "archiv_planung")[0]["id"]
|
||||
|
||||
antwort = client.post(
|
||||
f"/archiv/planung/{archiv_id}/wiederherstellen", follow_redirects=False
|
||||
)
|
||||
assert antwort.status_code == 303
|
||||
|
||||
aktiv = _alle(app, "planungsliste")
|
||||
assert len(aktiv) == 1
|
||||
assert aktiv[0]["rezensent_id"] == reza_id
|
||||
assert aktiv[0]["status"] == "offen"
|
||||
assert aktiv[0]["ausgabe"] == "1/2024"
|
||||
assert zaehle(app, "archiv_planung") == 0
|
||||
|
||||
|
||||
def test_wiederherstellen_neuheit_konflikt_bgg_id_wird_abgelehnt(app, client):
|
||||
melde_an(client)
|
||||
friere_plugin_uhr_ein(app, JETZT)
|
||||
fuege_neuheit_hinzu(
|
||||
app, titel="Konflikt-Kandidat", bgg_id=5005, erscheinungsjahr=2022
|
||||
)
|
||||
app.state.registry.get("archiv")._archiv_job()
|
||||
|
||||
# Der Sync hat den Titel inzwischen erneut geliefert — BGG-ID wieder aktiv.
|
||||
fuege_neuheit_hinzu(
|
||||
app, titel="Konflikt-Kandidat (neu)", bgg_id=5005, erscheinungsjahr=2025
|
||||
)
|
||||
archiv_id = _alle(app, "archiv_neuheiten")[0]["id"]
|
||||
|
||||
antwort = client.post(f"/archiv/neuheiten/{archiv_id}/wiederherstellen")
|
||||
assert antwort.status_code == 200
|
||||
assert "bereits einen aktiven Neuheiten-Eintrag" in sichtbar(antwort)
|
||||
|
||||
# Archiv unverändert, keine Duplikate entstanden.
|
||||
assert zaehle(app, "archiv_neuheiten") == 1
|
||||
assert zaehle(app, "neuheiten") == 1
|
||||
|
||||
|
||||
def test_wiederherstellen_planung_ersatz_rezensent_bei_geloeschtem_benutzer(
|
||||
app, client
|
||||
):
|
||||
lege_benutzer_an(app, "weg", "rezensent")
|
||||
with app.state.session_factory() as db:
|
||||
weg_id = db.scalar(select(User.id).where(User.username == "weg"))
|
||||
db.delete(db.get(User, weg_id))
|
||||
db.commit()
|
||||
|
||||
melde_an(client)
|
||||
friere_plugin_uhr_ein(app, JETZT)
|
||||
fuege_planung_hinzu(
|
||||
app, titel="Verwaist", rezensent_id=weg_id, erstellt_am=datetime(2023, 4, 4)
|
||||
)
|
||||
app.state.registry.get("archiv")._archiv_job()
|
||||
archiv_id = _alle(app, "archiv_planung")[0]["id"]
|
||||
|
||||
with app.state.session_factory() as db:
|
||||
admin_id = db.scalar(select(User.id).where(User.username == "admin"))
|
||||
|
||||
antwort = client.post(f"/archiv/planung/{archiv_id}/wiederherstellen")
|
||||
assert antwort.status_code == 200
|
||||
assert "zugeordnet" in sichtbar(antwort)
|
||||
|
||||
aktiv = _alle(app, "planungsliste")
|
||||
assert aktiv[0]["rezensent_id"] == admin_id
|
||||
audit = [a for a in audit_zeilen(app) if a["actor_name"] == "admin"][0]
|
||||
assert audit["details"]["rezensent_ersetzt"] is True
|
||||
|
||||
|
||||
def test_wiederherstellen_unbekannter_eintrag_zeigt_fehler(app, client):
|
||||
melde_an(client)
|
||||
antwort = client.post("/archiv/neuheiten/9999/wiederherstellen")
|
||||
assert antwort.status_code == 200
|
||||
assert "nicht gefunden" in sichtbar(antwort)
|
||||
|
||||
|
||||
def test_wiederherstellen_unbekannte_herkunft_wird_abgelehnt(app, client):
|
||||
melde_an(client)
|
||||
folgeseite = client.post("/archiv/unsinn/1/wiederherstellen")
|
||||
assert folgeseite.status_code == 200
|
||||
assert "Unbekannte Herkunft" in sichtbar(folgeseite)
|
||||
|
||||
|
||||
# ---------------- Rollen ----------------
|
||||
|
||||
def test_archiv_ansicht_nur_fuer_admin(app, client):
|
||||
# anonym → Login
|
||||
antwort = client.get("/archiv", follow_redirects=False)
|
||||
assert antwort.status_code == 303
|
||||
assert antwort.headers["location"].startswith("/login")
|
||||
|
||||
# Rezensentin → 403
|
||||
lege_benutzer_an(app, "rezensentin", "rezensent")
|
||||
melde_an(client, "rezensentin", "test-12345678")
|
||||
assert client.get("/archiv").status_code == 403
|
||||
client.post("/logout")
|
||||
|
||||
# Redakteur → 403 (Archiv ist reine Admin-Sicht)
|
||||
lege_benutzer_an(app, "redakteur", "redakteur")
|
||||
melde_an(client, "redakteur", "test-12345678")
|
||||
assert client.get("/archiv").status_code == 403
|
||||
client.post("/logout")
|
||||
|
||||
# Admin → 200
|
||||
melde_an(client)
|
||||
assert client.get("/archiv").status_code == 200
|
||||
|
||||
|
||||
def test_wiederherstellen_nur_fuer_admin(app, client):
|
||||
friere_plugin_uhr_ein(app, JETZT)
|
||||
fuege_neuheit_hinzu(app, titel="Geschützt", bgg_id=6006, erscheinungsjahr=2022)
|
||||
app.state.registry.get("archiv")._archiv_job()
|
||||
archiv_id = _alle(app, "archiv_neuheiten")[0]["id"]
|
||||
|
||||
lege_benutzer_an(app, "rezensentin", "rezensent")
|
||||
melde_an(client, "rezensentin", "test-12345678")
|
||||
antwort = client.post(
|
||||
f"/archiv/neuheiten/{archiv_id}/wiederherstellen", follow_redirects=False
|
||||
)
|
||||
assert antwort.status_code == 403
|
||||
assert zaehle(app, "archiv_neuheiten") == 1 # nichts verschoben
|
||||
assert zaehle(app, "neuheiten") == 0
|
||||
|
||||
|
||||
# ---------------- Admin-UI: Suche & Filter ----------------
|
||||
|
||||
def test_suche_filtert_ueber_titel_verlag_autor(app, client):
|
||||
melde_an(client)
|
||||
friere_plugin_uhr_ein(app, JETZT)
|
||||
fuege_neuheit_hinzu(app, titel="Alpha Spiel", verlag="Beta Verlag",
|
||||
bgg_id=1, erscheinungsjahr=2020)
|
||||
fuege_neuheit_hinzu(app, titel="Gamma Spiel", autor="Delta Autorin",
|
||||
bgg_id=2, erscheinungsjahr=2020)
|
||||
fuege_planung_hinzu(app, titel="Epsilon Spiel",
|
||||
erstellt_am=datetime(2020, 1, 1))
|
||||
app.state.registry.get("archiv")._archiv_job()
|
||||
|
||||
def treffer(q="", quelle=""):
|
||||
seite = sichtbar(client.get(f"/archiv?q={q}&quelle={quelle}"))
|
||||
return [
|
||||
t for t in ("Alpha Spiel", "Gamma Spiel", "Epsilon Spiel")
|
||||
if t in seite
|
||||
]
|
||||
|
||||
assert treffer(q="alpha") == ["Alpha Spiel"]
|
||||
assert treffer(q="beta") == ["Alpha Spiel"] # Treffer im Verlag
|
||||
assert treffer(q="delta") == ["Gamma Spiel"] # Treffer im Autor
|
||||
assert treffer(quelle="planung") == ["Epsilon Spiel"]
|
||||
assert treffer(quelle="neuheiten") == ["Alpha Spiel", "Gamma Spiel"]
|
||||
assert treffer() == ["Alpha Spiel", "Gamma Spiel", "Epsilon Spiel"]
|
||||
|
||||
leer = sichtbar(client.get("/archiv?q=nirgends"))
|
||||
assert "Keine Treffer" in leer
|
||||
|
||||
|
||||
def test_archiv_seite_zeigt_details_und_job_status(app, client):
|
||||
melde_an(client)
|
||||
friere_plugin_uhr_ein(app, JETZT)
|
||||
fuege_neuheit_hinzu(app, titel="Anzeige-Test", bgg_id=7007,
|
||||
erscheinungsjahr=2021)
|
||||
app.state.registry.get("archiv")._archiv_job()
|
||||
|
||||
seite = sichtbar(client.get("/archiv"))
|
||||
assert "Anzeige-Test" in seite
|
||||
assert "Erscheinungsjahr 2021" in seite # Grund der Archivierung
|
||||
assert "Neuheiten" in seite # Herkunfts-Badge
|
||||
assert "Wiederherstellen" in seite
|
||||
assert "täglich automatisch" in seite # Erklärungstext
|
||||
assert "Täglicher Archiv-Job: aus" in seite # in Tests standardmäßig aus
|
||||
482
tests/test_covers.py
Normal file
482
tests/test_covers.py
Normal 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 "🎲" 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
|
||||
@@ -389,3 +389,25 @@ def test_bgg_pruefclient_toleroert_fehler():
|
||||
assert client.vorgaenger(99) == []
|
||||
finally:
|
||||
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
527
tests/test_dedup_ki.py
Normal 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 0–1 noch 0–100
|
||||
["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
550
tests/test_erinnerung.py
Normal file
@@ -0,0 +1,550 @@
|
||||
"""Tests: Plugin „erinnerung“ — Fristen, Empfänger, Doppelschutz, Rollen.
|
||||
|
||||
Die Uhr wird mit freezegun eingefroren; alle datumsabhängigen Prüfungen
|
||||
laufen damit deterministisch. benachrichtigung und audit-log werden durch
|
||||
aufzeichnende Stubs ersetzt (wie in test_planung.py); ein Test beweist
|
||||
zusätzlich die Integration mit den echten Plugins.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from redaktionskern.app import create_app
|
||||
from redaktionskern.auth.models import User
|
||||
from tests.conftest import lege_benutzer_an, melde_an
|
||||
|
||||
|
||||
# ---------------- Hilfen ----------------
|
||||
|
||||
def plugin_modul(app, name: str):
|
||||
plugin_instanz = app.state.registry.get(name)
|
||||
return sys.modules[type(plugin_instanz).__module__]
|
||||
|
||||
|
||||
def ausgabe_anlegen(app, name: str, redaktionsschluss: date) -> int:
|
||||
Ausgabe = plugin_modul(app, "erinnerung").Ausgabe
|
||||
with app.state.session_factory() as db:
|
||||
ausgabe = Ausgabe(name=name, redaktionsschluss=redaktionsschluss)
|
||||
db.add(ausgabe)
|
||||
db.commit()
|
||||
return ausgabe.id
|
||||
|
||||
|
||||
def protokoll_zeilen(app) -> set[tuple[int, int]]:
|
||||
ErinnerungsProtokoll = plugin_modul(app, "erinnerung").ErinnerungsProtokoll
|
||||
with app.state.session_factory() as db:
|
||||
return {
|
||||
(z.ausgabe_id, z.user_id)
|
||||
for z in db.scalars(select(ErinnerungsProtokoll)).all()
|
||||
}
|
||||
|
||||
|
||||
def planung_anlegen(app, titel: str, rezensent_id: int, status: str = "offen") -> int:
|
||||
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
|
||||
with app.state.session_factory() as db:
|
||||
eintrag = Planungseintrag(
|
||||
titel=titel, rezensent_id=rezensent_id, status=status, ausgabe=""
|
||||
)
|
||||
db.add(eintrag)
|
||||
db.commit()
|
||||
return eintrag.id
|
||||
|
||||
|
||||
def benutzer_objekt(app, username: str):
|
||||
with app.state.session_factory() as db:
|
||||
return db.scalar(select(User).where(User.username == username))
|
||||
|
||||
|
||||
def erinnerungs_plugin(app):
|
||||
plugin = app.state.registry.get("erinnerung")
|
||||
plugin._merke_registry(app.state.registry) # Partner sauber auflösen
|
||||
return plugin
|
||||
|
||||
|
||||
def sichtbar(antwort) -> str:
|
||||
import html
|
||||
|
||||
return html.unescape(antwort.text)
|
||||
|
||||
|
||||
# ---------------- Stubs (gemockte Abhängigkeiten) ----------------
|
||||
|
||||
@dataclass
|
||||
class Aufgezeichnet:
|
||||
daten: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class StubBenachrichtigung:
|
||||
name = "benachrichtigung"
|
||||
|
||||
def __init__(self):
|
||||
self.gesendet: list[Aufgezeichnet] = []
|
||||
|
||||
async def send_notification(self, user, titel, text, kategorie="allgemein"):
|
||||
self.gesendet.append(Aufgezeichnet({
|
||||
"user_id": user.id, "titel": titel, "text": text, "kategorie": kategorie,
|
||||
}))
|
||||
return SimpleNamespace(zugestellt=["inapp"], fehlgeschlagen=[])
|
||||
|
||||
|
||||
class StubAudit:
|
||||
name = "audit-log"
|
||||
|
||||
def __init__(self):
|
||||
self.eintraege: list[Aufgezeichnet] = []
|
||||
|
||||
async def log(self, actor, action, objekt_typ="", objekt_id=None,
|
||||
details=None, **kwargs):
|
||||
self.eintraege.append(Aufgezeichnet({
|
||||
"actor": actor, "action": action, "objekt_typ": objekt_typ,
|
||||
"objekt_id": objekt_id, "details": details,
|
||||
}))
|
||||
|
||||
def log_sync(self, *args, **kwargs):
|
||||
import asyncio
|
||||
|
||||
asyncio.run(self.log(*args, **kwargs))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stubs(app):
|
||||
"""Ersetzt benachrichtigung + audit-log im Registry-Zustand der App."""
|
||||
benachrichtigung = StubBenachrichtigung()
|
||||
audit = StubAudit()
|
||||
registry_inhalt = app.state.registry._plugins
|
||||
registry_inhalt["benachrichtigung"] = benachrichtigung
|
||||
registry_inhalt["audit-log"] = audit
|
||||
erinnerungs_plugin(app) # Registry-Referenz auffrischen
|
||||
return SimpleNamespace(benachrichtigung=benachrichtigung, audit=audit)
|
||||
|
||||
|
||||
# ---------------- Migration & Zugriff ----------------
|
||||
|
||||
def test_migration_legt_beide_tabellen_an(app):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspektion = inspect(app.state.engine)
|
||||
ausgaben_spalten = {c["name"] for c in inspektion.get_columns("erinnerung_ausgabe")}
|
||||
protokoll_spalten = {c["name"] for c in inspektion.get_columns("erinnerung_protokoll")}
|
||||
assert {"id", "name", "redaktionsschluss", "erstellt_am", "aktualisiert_am"} <= ausgaben_spalten
|
||||
assert {"id", "ausgabe_id", "user_id", "kanaele", "erstellt_am"} <= protokoll_spalten
|
||||
with app.state.engine.connect() as conn:
|
||||
versionen = [
|
||||
z[0] for z in conn.execute(
|
||||
text("SELECT version FROM schema_migrations WHERE plugin='erinnerung'")
|
||||
).fetchall()
|
||||
]
|
||||
assert versionen == ["0001_ausgaben_und_protokoll"]
|
||||
|
||||
|
||||
def test_anonym_wird_zum_login_umgeleitet(client):
|
||||
antwort = client.get("/erinnerung", follow_redirects=False)
|
||||
assert antwort.status_code == 303
|
||||
assert antwort.headers["location"].startswith("/login")
|
||||
|
||||
|
||||
# ---------------- Rollen & UI ----------------
|
||||
|
||||
def test_rezensent_sieht_seite_ohne_verwaltung(app, client):
|
||||
lege_benutzer_an(app, "rez1", "rezensent")
|
||||
melde_an(client, "rez1", "test-12345678")
|
||||
antwort = client.get("/erinnerung")
|
||||
assert antwort.status_code == 200
|
||||
inhalt = sichtbar(antwort)
|
||||
assert "Redaktionsschluss" in inhalt
|
||||
assert "Neue Ausgabe anlegen" not in inhalt # keine Admin-Formulare
|
||||
assert "Jetzt prüfen und erinnern" not in inhalt
|
||||
|
||||
|
||||
def test_admin_sieht_verwaltung_und_status(client):
|
||||
melde_an(client)
|
||||
inhalt = sichtbar(client.get("/erinnerung"))
|
||||
assert "Neue Ausgabe anlegen" in inhalt
|
||||
assert "Erinnerungsstatus" in inhalt
|
||||
|
||||
|
||||
def test_nur_admin_darf_ausgaben_verwalten(app, client):
|
||||
"""Anlegen/Bearbeiten/Löschen/Prüfen: Admin ja, Redakteur und Rezensent 403."""
|
||||
for rolle in ("redakteur", "rezensent"):
|
||||
lege_benutzer_an(app, f"user-{rolle}", rolle)
|
||||
melde_an(client, f"user-{rolle}", "test-12345678")
|
||||
for methode, url, daten in (
|
||||
("post", "/erinnerung/ausgaben", {"name": "X", "redaktionsschluss": "2030-01-01"}),
|
||||
("get", "/erinnerung/ausgaben/1/bearbeiten", None),
|
||||
("post", "/erinnerung/ausgaben/1/bearbeiten",
|
||||
{"name": "Y", "redaktionsschluss": "2030-01-01"}),
|
||||
("post", "/erinnerung/ausgaben/1/loeschen", {}),
|
||||
("post", "/erinnerung/pruefen", {}),
|
||||
):
|
||||
if methode == "get":
|
||||
antwort = client.get(url)
|
||||
else:
|
||||
antwort = client.post(url, data=daten)
|
||||
assert antwort.status_code == 403, f"{rolle} auf {url} muss 403 geben"
|
||||
client.post("/logout")
|
||||
|
||||
|
||||
# ---------------- Ausgaben-CRUD (Admin) ----------------
|
||||
|
||||
def test_admin_legt_ausgabe_an_mit_audit(client, app, stubs):
|
||||
melde_an(client)
|
||||
antwort = client.post(
|
||||
"/erinnerung/ausgaben",
|
||||
data={"name": "3/2025", "redaktionsschluss": "2030-06-15"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert antwort.status_code == 200
|
||||
assert "3/2025" in sichtbar(antwort)
|
||||
|
||||
Ausgabe = plugin_modul(app, "erinnerung").Ausgabe
|
||||
with app.state.session_factory() as db:
|
||||
ausgabe = db.scalar(select(Ausgabe))
|
||||
assert ausgabe.name == "3/2025"
|
||||
assert ausgabe.redaktionsschluss == date(2030, 6, 15)
|
||||
|
||||
assert [e.daten["action"] for e in stubs.audit.eintraege] == ["erstellt"]
|
||||
eintrag = stubs.audit.eintraege[0].daten
|
||||
assert eintrag["objekt_typ"] == "ausgabe"
|
||||
assert eintrag["details"]["name"] == "3/2025"
|
||||
|
||||
|
||||
def test_mehrere_ausgaben_parallel(app, client):
|
||||
melde_an(client)
|
||||
for name, schluss in (("3/2025", "2030-06-15"), ("4/2025", "2030-09-01")):
|
||||
client.post("/erinnerung/ausgaben", data={"name": name, "redaktionsschluss": schluss})
|
||||
inhalt = sichtbar(client.get("/erinnerung"))
|
||||
assert "3/2025" in inhalt and "4/2025" in inhalt
|
||||
|
||||
|
||||
def test_ausgabe_ohne_name_wird_abgewiesen(client, app):
|
||||
melde_an(client)
|
||||
antwort = client.post(
|
||||
"/erinnerung/ausgaben",
|
||||
data={"name": "", "redaktionsschluss": "2030-06-15"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert "Bitte einen Ausgaben-Namen angeben" in antwort.text
|
||||
|
||||
|
||||
def test_ausgabe_mit_kaputtem_datum_wird_abgewiesen(client, app):
|
||||
melde_an(client)
|
||||
antwort = client.post(
|
||||
"/erinnerung/ausgaben",
|
||||
data={"name": "X", "redaktionsschluss": "15.06.2030"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert "Ungültiges Datum" in antwort.text
|
||||
|
||||
|
||||
def test_admin_bearbeitet_ausgabe_mit_audit(client, app, stubs):
|
||||
melde_an(client)
|
||||
ausgabe_id = ausgabe_anlegen(app, "Alt", date(2030, 6, 15))
|
||||
antwort = client.post(
|
||||
f"/erinnerung/ausgaben/{ausgabe_id}/bearbeiten",
|
||||
data={"name": "Neu", "redaktionsschluss": "2030-07-01"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert "gespeichert" in antwort.text
|
||||
Ausgabe = plugin_modul(app, "erinnerung").Ausgabe
|
||||
with app.state.session_factory() as db:
|
||||
ausgabe = db.get(Ausgabe, ausgabe_id)
|
||||
assert ausgabe.name == "Neu"
|
||||
assert ausgabe.redaktionsschluss == date(2030, 7, 1)
|
||||
aktionen = [e.daten["action"] for e in stubs.audit.eintraege]
|
||||
assert aktionen == ["geaendert"] # Anlegen geschah direkt per Helper
|
||||
geaendert = stubs.audit.eintraege[-1].daten
|
||||
assert geaendert["details"]["alt"]["name"] == "Alt"
|
||||
assert geaendert["details"]["neu"]["name"] == "Neu"
|
||||
|
||||
|
||||
def test_admin_loescht_ausgabe_mit_audit(client, app, stubs):
|
||||
melde_an(client)
|
||||
ausgabe_id = ausgabe_anlegen(app, "Weg damit", date(2030, 6, 15))
|
||||
antwort = client.post(f"/erinnerung/ausgaben/{ausgabe_id}/loeschen", follow_redirects=True)
|
||||
assert "gelöscht" in antwort.text
|
||||
Ausgabe = plugin_modul(app, "erinnerung").Ausgabe
|
||||
with app.state.session_factory() as db:
|
||||
assert db.get(Ausgabe, ausgabe_id) is None
|
||||
assert [e.daten["action"] for e in stubs.audit.eintraege] == ["geloescht"]
|
||||
|
||||
|
||||
# ---------------- Erinnerungslogik mit eingefrorener Uhr ----------------
|
||||
|
||||
def _drei_beteiligte(app):
|
||||
"""Rezensent mit offenem Eintrag, Rezensent ohne Einträge; Admin existiert."""
|
||||
lege_benutzer_an(app, "rez-offen", "rezensent")
|
||||
lege_benutzer_an(app, "rez-leer", "rezensent")
|
||||
rez_offen = benutzer_objekt(app, "rez-offen")
|
||||
planung_anlegen(app, "Offenes Spiel", rez_offen.id, status="offen")
|
||||
return rez_offen, benutzer_objekt(app, "rez-leer")
|
||||
|
||||
|
||||
@freeze_time("2030-05-18") # genau 28 Tage vor dem 15.06.2030
|
||||
def test_erinnerung_genau_beim_fensterstart(client, app, stubs):
|
||||
rez_offen, rez_leer = _drei_beteiligte(app)
|
||||
ausgabe_id = ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
|
||||
melde_an(client)
|
||||
|
||||
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
|
||||
|
||||
assert bericht.gepruefte_ausgaben == 1
|
||||
assert {v["benutzer"] for v in bericht.versendet} == {"rez-offen", "rez-leer"}
|
||||
assert all(v["tage_rest"] == 28 for v in bericht.versendet)
|
||||
assert protokoll_zeilen(app) == {(ausgabe_id, rez_offen.id), (ausgabe_id, rez_leer.id)}
|
||||
# Benachrichtigungen über das Partner-Plugin, Kategorie „erinnerung“:
|
||||
assert {g.daten["user_id"] for g in stubs.benachrichtigung.gesendet} == {
|
||||
rez_offen.id, rez_leer.id,
|
||||
}
|
||||
assert all(g.daten["kategorie"] == "erinnerung" for g in stubs.benachrichtigung.gesendet)
|
||||
assert "Redaktionsschluss: 3/2025" == stubs.benachrichtigung.gesendet[0].daten["titel"]
|
||||
# Audit: jede Erinnerung als System-Ereignis protokolliert:
|
||||
aktionen = [e.daten["action"] for e in stubs.audit.eintraege]
|
||||
assert sorted(aktionen) == ["benachrichtigt", "benachrichtigt"]
|
||||
assert all(e.daten["actor"] is None for e in stubs.audit.eintraege)
|
||||
|
||||
|
||||
@freeze_time("2030-05-17") # 29 Tage vorher — Fenster noch zu
|
||||
def test_kein_versand_vor_der_vier_wochen_frist(client, app, stubs):
|
||||
_drei_beteiligte(app)
|
||||
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
|
||||
melde_an(client)
|
||||
|
||||
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
|
||||
|
||||
assert bericht.gepruefte_ausgaben == 0
|
||||
assert bericht.versendet == []
|
||||
assert stubs.benachrichtigung.gesendet == []
|
||||
assert protokoll_zeilen(app) == set()
|
||||
assert stubs.audit.eintraege == []
|
||||
|
||||
|
||||
@freeze_time("2030-06-16") # einen Tag nach dem Redaktionsschluss
|
||||
def test_kein_versand_nach_dem_redaktionsschluss(client, app, stubs):
|
||||
_drei_beteiligte(app)
|
||||
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
|
||||
melde_an(client)
|
||||
|
||||
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
|
||||
|
||||
assert bericht.gepruefte_ausgaben == 0
|
||||
assert stubs.benachrichtigung.gesendet == []
|
||||
|
||||
|
||||
@freeze_time("2030-05-25") # 21 Tage Rest → im Fenster
|
||||
def test_rezensent_mit_nur_abgeschlossenen_eintraegen_wird_nicht_erinnert(client, app, stubs):
|
||||
for name, status in (
|
||||
("rez-fertig", "abgeschlossen"),
|
||||
("x-in_arbeit", "in_bearbeitung"),
|
||||
("x-fertig", "abgeschlossen"),
|
||||
):
|
||||
lege_benutzer_an(app, name, "rezensent")
|
||||
planung_anlegen(app, f"Spiel von {name}",
|
||||
benutzer_objekt(app, name).id, status=status)
|
||||
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15)) # 21 Tage Rest → im Fenster
|
||||
melde_an(client)
|
||||
|
||||
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
|
||||
|
||||
# „offen“ heißt: nicht abgeschlossen (offen oder in Bearbeitung).
|
||||
erinnerte = {v["benutzer"] for v in bericht.versendet}
|
||||
assert erinnerte == {"x-in_arbeit"}
|
||||
|
||||
|
||||
@freeze_time("2030-05-20")
|
||||
def test_keine_doppelerinnerung_pro_ausgabe_und_benutzer(client, app, stubs):
|
||||
_drei_beteiligte(app)
|
||||
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
|
||||
melde_an(client)
|
||||
plugin = erinnerungs_plugin(app)
|
||||
|
||||
erster = plugin.pruefe_und_erinnere_sync()
|
||||
zweiter = plugin.pruefe_und_erinnere_sync()
|
||||
dritter = plugin.pruefe_und_erinnere_sync(jetzt=date(2030, 6, 14)) # später, noch im Fenster
|
||||
|
||||
assert erster.anzahl_versendet > 0
|
||||
assert zweiter.anzahl_versendet == 0
|
||||
assert zweiter.uebersprungen == erster.anzahl_versendet
|
||||
assert dritter.anzahl_versendet == 0
|
||||
assert len(stubs.benachrichtigung.gesendet) == erster.anzahl_versendet
|
||||
assert len(protokoll_zeilen(app)) == erster.anzahl_versendet
|
||||
|
||||
|
||||
@freeze_time("2030-05-20")
|
||||
def test_zweite_ausgabe_wird_unabhaengig_erinnert(client, app, stubs):
|
||||
"""Doppelschutz gilt pro Ausgabe — eine zweite Ausgabe erinnert erneut."""
|
||||
_drei_beteiligte(app)
|
||||
erste = ausgabe_anlegen(app, "3/2025", date(2030, 6, 10)) # im Fenster
|
||||
zweite = ausgabe_anlegen(app, "4/2025", date(2030, 6, 12)) # auch im Fenster
|
||||
melde_an(client)
|
||||
|
||||
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
|
||||
|
||||
assert bericht.gepruefte_ausgaben == 2
|
||||
assert {v["ausgabe"] for v in bericht.versendet} == {"3/2025", "4/2025"}
|
||||
ids = protokoll_zeilen(app)
|
||||
rez_offen = benutzer_objekt(app, "rez-offen")
|
||||
assert (erste, rez_offen.id) in ids
|
||||
assert (zweite, rez_offen.id) in ids
|
||||
|
||||
|
||||
@freeze_time("2030-05-20")
|
||||
def test_rollenfilter_admin_und_redakteur_werden_nie_erinnert(client, app, stubs):
|
||||
lege_benutzer_an(app, "chef-redakteur", "redakteur")
|
||||
redakteur = benutzer_objekt(app, "chef-redakteur")
|
||||
# Redakteur mit offenen Planungseinträgen bleibt trotzdem außen vor:
|
||||
planung_anlegen(app, "Vom Redakteur übernommen", redakteur.id, status="offen")
|
||||
|
||||
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
|
||||
melde_an(client)
|
||||
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
|
||||
|
||||
erinnerte = {v["benutzer"] for v in bericht.versendet}
|
||||
assert "chef-redakteur" not in erinnerte
|
||||
assert "admin" not in erinnerte
|
||||
|
||||
|
||||
@freeze_time("2030-05-20")
|
||||
def test_inaktiver_rezensent_wird_nicht_erinnert(app, client, stubs):
|
||||
lege_benutzer_an(app, "ex-rez", "rezensent")
|
||||
with app.state.session_factory() as db:
|
||||
ex_rez = db.scalar(select(User).where(User.username == "ex-rez"))
|
||||
ex_rez.active = False
|
||||
db.commit()
|
||||
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
|
||||
|
||||
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
|
||||
assert bericht.versendet == []
|
||||
|
||||
|
||||
@freeze_time("2030-05-20")
|
||||
def test_monkeypatched_uhr_steuert_job_pfad(client, app, stubs, monkeypatch):
|
||||
"""Auch ohne `jetzt`-Argument nutzt der Durchlauf die eingefrierbare Uhr."""
|
||||
dienst = plugin_modul(app, "erinnerung").dienst
|
||||
monkeypatch.setattr(dienst, "heute", lambda: date(2030, 5, 20))
|
||||
_drei_beteiligte(app)
|
||||
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
|
||||
|
||||
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
|
||||
assert bericht.anzahl_versendet == 2
|
||||
|
||||
|
||||
@freeze_time("2030-05-20")
|
||||
def test_manuelle_pruefung_ueber_route(client, app, stubs):
|
||||
_drei_beteiligte(app)
|
||||
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
|
||||
melde_an(client)
|
||||
antwort = client.post("/erinnerung/pruefen", follow_redirects=True)
|
||||
assert "2 Erinnerung(en) versendet" in sichtbar(antwort)
|
||||
|
||||
|
||||
@freeze_time("2030-05-20")
|
||||
def test_integration_mit_echten_plugins(settings):
|
||||
"""Echte Plugins statt Stubs: In-App-Nachricht + Audit-Eintrag in der DB."""
|
||||
from tests.conftest import ADMIN_PASSWORD
|
||||
from redaktionskern.auth.security import hash_password
|
||||
|
||||
with freeze_time("2030-05-20"):
|
||||
app2 = create_app(settings)
|
||||
with TestClient(app2):
|
||||
# Rezensenten anlegen (Admin existiert bereits als Initial-Admin):
|
||||
with app2.state.session_factory() as db:
|
||||
db.add(User(username="rez-integration",
|
||||
display_name="Rez Integration",
|
||||
password_hash=hash_password(ADMIN_PASSWORD),
|
||||
role="rezensent", active=True))
|
||||
db.commit()
|
||||
rez = None
|
||||
with app2.state.session_factory() as db:
|
||||
rez = db.scalar(select(User).where(User.username == "rez-integration"))
|
||||
|
||||
ausgabe_anlegen(app2, "3/2025", date(2030, 6, 15))
|
||||
bericht = erinnerungs_plugin(app2).pruefe_und_erinnere_sync()
|
||||
assert bericht.anzahl_versendet == 1
|
||||
assert bericht.versendet[0]["kanaele"] == ["inapp"]
|
||||
|
||||
Benachrichtigung = sys.modules[
|
||||
type(app2.state.registry.get("benachrichtigung")).__module__
|
||||
].Benachrichtigung
|
||||
AuditEintrag = sys.modules[
|
||||
type(app2.state.registry.get("audit-log")).__module__
|
||||
].AuditEintrag
|
||||
ErinnerungsProtokoll = sys.modules[
|
||||
type(app2.state.registry.get("erinnerung")).__module__
|
||||
].ErinnerungsProtokoll
|
||||
with app2.state.session_factory() as db:
|
||||
nachricht = db.scalar(select(Benachrichtigung))
|
||||
assert nachricht is not None
|
||||
assert nachricht.user_id == rez.id
|
||||
assert nachricht.titel == "Redaktionsschluss: 3/2025"
|
||||
assert "Redaktionsschluss" in nachricht.text
|
||||
|
||||
eintraege = list(db.scalars(
|
||||
select(AuditEintrag).where(AuditEintrag.action == "benachrichtigt")
|
||||
).all())
|
||||
assert len(eintraege) == 1
|
||||
assert eintraege[0].actor_name == "System"
|
||||
assert eintraege[0].objekt_typ == "erinnerung"
|
||||
assert eintraege[0].details["ausgabe"] == "3/2025"
|
||||
|
||||
zeile = db.scalar(select(ErinnerungsProtokoll))
|
||||
assert zeile.user_id == rez.id
|
||||
assert zeile.kanaele == "inapp"
|
||||
|
||||
|
||||
# ---------------- Scheduler-Lifecycle & Konfiguration ----------------
|
||||
|
||||
def test_scheduler_standardmaessig_deaktiviert_in_tests(settings):
|
||||
app = create_app(settings)
|
||||
assert app.state.registry.get("erinnerung")._scheduler is None
|
||||
|
||||
|
||||
def test_scheduler_laesst_sich_aktivieren_und_konfigurieren(monkeypatch, settings):
|
||||
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_AKTIV", "1")
|
||||
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_UHRZEIT", "7:30")
|
||||
app = create_app(settings)
|
||||
plugin = app.state.registry.get("erinnerung")
|
||||
with TestClient(app):
|
||||
assert plugin._scheduler is not None
|
||||
job = plugin._scheduler.get_job("erinnerungs-check")
|
||||
assert job is not None
|
||||
felder = {feld.name: str(feld) for feld in job.trigger.fields}
|
||||
assert felder["hour"] == "7"
|
||||
assert felder["minute"] == "30"
|
||||
assert plugin._scheduler is None # on_unload hat ihn gestoppt
|
||||
|
||||
|
||||
def test_scheduler_kaputte_uhrzeit_faellt_auf_standard(monkeypatch, settings):
|
||||
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_AKTIV", "1")
|
||||
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_UHRZEIT", "garstin:keinzahl")
|
||||
app = create_app(settings)
|
||||
plugin = app.state.registry.get("erinnerung")
|
||||
with TestClient(app):
|
||||
job = plugin._scheduler.get_job("erinnerungs-check")
|
||||
felder = {feld.name: str(feld) for feld in job.trigger.fields}
|
||||
assert felder["hour"] == "8"
|
||||
assert felder["minute"] == "0"
|
||||
|
||||
|
||||
# ---------------- Fenster-Grenzlogik (Unit) ----------------
|
||||
|
||||
def test_fenster_grenzen():
|
||||
from plugins.erinnerung.dienst import (
|
||||
ERINNERUNGSFENSTER_TAGE,
|
||||
im_erinnerungsfenster,
|
||||
tage_bis,
|
||||
)
|
||||
|
||||
schluss = date(2030, 6, 15)
|
||||
assert tage_bis(schluss, date(2030, 5, 18)) == ERINNERUNGSFENSTER_TAGE
|
||||
assert im_erinnerungsfenster(schluss, date(2030, 5, 18)) # genau 28 Tage
|
||||
assert not im_erinnerungsfenster(schluss, date(2030, 5, 17)) # 29 Tage → zu früh
|
||||
assert im_erinnerungsfenster(schluss, date(2030, 6, 15)) # am Schluss selbst
|
||||
assert not im_erinnerungsfenster(schluss, date(2030, 6, 16)) # vorbei
|
||||
319
tests/test_export.py
Normal file
319
tests/test_export.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""Tests: Plugin „export“ — CSV-Inhalt, PDF-Erzeugung, Rollen-Zugriff, UI.
|
||||
|
||||
CSV-Tests pruefen Semikolon-Trennzeichen, UTF-8-BOM und Umlaute; PDF-Tests
|
||||
pruefen den gueltigen Datei-Kopf („%PDF-“) und laufen nur, wenn WeasyPrint
|
||||
auf dem System rendern kann (Fixture `pdf_faehig` skippt sonst).
|
||||
Datenzugriffe in den Tests nutzen dieselben Module wie die App.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import itertools
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from redaktionskern.app import create_app
|
||||
from tests.conftest import lege_benutzer_an, melde_an
|
||||
|
||||
_bgg_zaehler = itertools.count(100000)
|
||||
|
||||
|
||||
# ---------------- Hilfen ----------------
|
||||
|
||||
def plugin_modul(app, name: str):
|
||||
plugin_instanz = app.state.registry.get(name) or sys.modules.get(
|
||||
f"spiele_redaktion_plugins.{name}"
|
||||
)
|
||||
return sys.modules[type(plugin_instanz).__module__]
|
||||
|
||||
|
||||
def neuheit_anlegen(app, titel, verlag=None, autor=None, jahr=None, status="neuheit"):
|
||||
Neuheit = plugin_modul(app, "neuheiten").Neuheit
|
||||
with app.state.session_factory() as db:
|
||||
eintrag = Neuheit(
|
||||
titel=titel, verlag=verlag, autor=autor,
|
||||
erscheinungsjahr=jahr, bgg_id=next(_bgg_zaehler),
|
||||
status=status,
|
||||
)
|
||||
db.add(eintrag)
|
||||
db.commit()
|
||||
return eintrag.id
|
||||
|
||||
|
||||
def planung_anlegen(app, titel, rezensent_id, **kwargs):
|
||||
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
|
||||
with app.state.session_factory() as db:
|
||||
eintrag = Planungseintrag(titel=titel, rezensent_id=rezensent_id, **kwargs)
|
||||
db.add(eintrag)
|
||||
db.commit()
|
||||
return eintrag.id
|
||||
|
||||
|
||||
def benutzer_objekt(app, username: str):
|
||||
from redaktionskern.auth.models import User
|
||||
|
||||
with app.state.session_factory() as db:
|
||||
return db.scalar(select(User).where(User.username == username))
|
||||
|
||||
|
||||
def csv_zeilen(antwort) -> list[list[str]]:
|
||||
"""Antwort-Bytes als geparste CSV-Zeilen (Semikolon, BOM-abhaengig)."""
|
||||
text_dokument = antwort.content.decode("utf-8-sig")
|
||||
return list(csv.reader(io.StringIO(text_dokument), delimiter=";"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def daten_bestand(app):
|
||||
"""Kleiner Redaktions-Bestand: Neuheiten (inkl. archiviert), Planung."""
|
||||
lege_benutzer_an(app, "rez1", "rezensent")
|
||||
rez = benutzer_objekt(app, "rez1")
|
||||
neuheit_anlegen(
|
||||
app, "Füchse im Wunderland", verlag="Hans im Glück Verlag",
|
||||
autor="Uwe Rosenberg", jahr=2025,
|
||||
)
|
||||
# Titel mit Trennzeichen/Sonderfällen für die CSV-Quoting-Prüfung.
|
||||
neuheit_anlegen(
|
||||
app, "Schätze; der Azteken", verlag='Anfänger "und" Profis',
|
||||
autor="Ärger Ödipus ß", jahr=2024,
|
||||
)
|
||||
neuheit_anlegen(app, "Alte Schachtel", verlag="Museum Verlag", jahr=1999,
|
||||
status="archiv")
|
||||
planung_anlegen(
|
||||
app, "Füchse im Wunderland", rez.id, ausgabe="3/2025",
|
||||
notizen="Rezensionsexemplar anfordern – Müller",
|
||||
)
|
||||
planung_anlegen(
|
||||
app, "Schätze; der Azteken", rez.id, ausgabe="4/2025",
|
||||
status="in_bearbeitung",
|
||||
)
|
||||
|
||||
|
||||
# ---------------- Seite & Zugriff ----------------
|
||||
|
||||
def test_export_seite_erreichbar(client):
|
||||
melde_an(client)
|
||||
antwort = client.get("/export")
|
||||
assert antwort.status_code == 200
|
||||
assert "Neuheitenliste" in antwort.text
|
||||
assert "Planungsliste" in antwort.text
|
||||
assert "Archiv" in antwort.text
|
||||
assert "CSV herunterladen" in antwort.text
|
||||
assert "PDF herunterladen" in antwort.text
|
||||
|
||||
|
||||
def test_anonym_wird_zum_login_umgeleitet(client):
|
||||
assert client.get("/export", follow_redirects=False).status_code == 303
|
||||
assert client.get("/export/planung/csv", follow_redirects=False).status_code == 303
|
||||
assert client.get("/export/neuheiten/pdf", follow_redirects=False).status_code == 303
|
||||
weiterleitung = client.get("/export/planung/csv", follow_redirects=False)
|
||||
assert weiterleitung.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_unbekannte_liste_404(client):
|
||||
melde_an(client)
|
||||
assert client.get("/export/geheim/csv").status_code == 404
|
||||
assert client.get("/export/geheim/pdf").status_code == 404
|
||||
|
||||
|
||||
# ---------------- Rollen ----------------
|
||||
|
||||
@pytest.mark.parametrize("rolle", ["admin", "redakteur"])
|
||||
@pytest.mark.parametrize("route", ["csv", "pdf"], ids=["csv", "pdf"])
|
||||
def test_redaktion_exportiert_alles(app, client, daten_bestand, rolle, route, pdf_faehig):
|
||||
lege_benutzer_an(app, f"{rolle}1", rolle)
|
||||
melde_an(client, f"{rolle}1", "test-12345678")
|
||||
for liste in ("neuheiten", "planung", "archiv"):
|
||||
antwort = client.get(f"/export/{liste}/{route}")
|
||||
assert antwort.status_code == 200, liste
|
||||
if route == "pdf":
|
||||
assert antwort.content.startswith(b"%PDF-")
|
||||
else:
|
||||
assert antwort.headers["content-type"].startswith("text/csv")
|
||||
|
||||
|
||||
def test_rezensent_nur_planung_csv(app, client, daten_bestand):
|
||||
melde_an(client, "rez1", "test-12345678")
|
||||
assert client.get("/export/planung/csv").status_code == 200
|
||||
assert client.get("/export/neuheiten/csv").status_code == 403
|
||||
assert client.get("/export/archiv/csv").status_code == 403
|
||||
|
||||
|
||||
def test_rezensent_nur_planung_pdf(app, client, daten_bestand, pdf_faehig):
|
||||
melde_an(client, "rez1", "test-12345678")
|
||||
assert client.get("/export/planung/pdf").status_code == 200
|
||||
assert client.get("/export/neuheiten/pdf").status_code == 403
|
||||
assert client.get("/export/archiv/pdf").status_code == 403
|
||||
|
||||
|
||||
def test_rezensent_sieht_gesperrte_karten(app, client):
|
||||
"""UI: Rezensent sieht alle Karten, aber Neuheiten/Archiv ohne Buttons."""
|
||||
lege_benutzer_an(app, "rez1", "rezensent")
|
||||
melde_an(client, "rez1", "test-12345678")
|
||||
antwort = client.get("/export")
|
||||
assert antwort.status_code == 200
|
||||
assert "Nur für Admins und Redakteure" in antwort.text
|
||||
assert "/export/neuheiten/csv" not in antwort.text
|
||||
assert "/export/planung/csv" in antwort.text
|
||||
|
||||
|
||||
# ---------------- CSV-Inhalt ----------------
|
||||
|
||||
def test_csv_bom_trennzeichen_umlaut(app, client, daten_bestand):
|
||||
melde_an(client)
|
||||
antwort = client.get("/export/planung/csv")
|
||||
assert antwort.status_code == 200
|
||||
assert antwort.headers["content-type"].startswith("text/csv")
|
||||
|
||||
roh = antwort.content
|
||||
# 1) UTF-8 mit Byte-Order-Mark
|
||||
assert roh.startswith(b"\xef\xbb\xbf")
|
||||
# 2) Umlaute unzerstoert als UTF-8
|
||||
assert "Füchse im Wunderland".encode("utf-8") in roh
|
||||
assert "–".encode("utf-8") in roh # Gedankenstrich aus der Notiz
|
||||
|
||||
zeilen = csv_zeilen(antwort)
|
||||
kopf = zeilen[0]
|
||||
assert kopf[:2] == ["Titel", "Verlag"]
|
||||
assert "Ausgabe" in kopf and "Rezensent" in kopf and "Status" in kopf
|
||||
daten = [zeile for zeile in zeilen[1:] if zeile]
|
||||
assert len(daten) == 2
|
||||
fuechse = next(zeile for zeile in daten if zeile[0] == "Füchse im Wunderland")
|
||||
assert fuechse[kopf.index("Ausgabe")] == "3/2025"
|
||||
assert fuechse[kopf.index("Status")] == "Offen"
|
||||
# Rezensent ist aufgeloest (Anzeigename), nicht nur die ID:
|
||||
assert fuechse[kopf.index("Rezensent")] == "Rez1"
|
||||
|
||||
|
||||
def test_csv_semikolon_getrennt_und_quoting(app, client, daten_bestand):
|
||||
"""Trennzeichen ist wirklich ';'; Felder mit ';' werden korrekt gequotet."""
|
||||
melde_an(client)
|
||||
antwort = client.get("/export/neuheiten/csv")
|
||||
zeilen = csv_zeilen(antwort)
|
||||
kopf = zeilen[0]
|
||||
assert kopf[:6] == ["Titel", "Verlag", "Autor", "Erscheinungsjahr", "Status", "Aktualisiert am"]
|
||||
schaetze = next(z for z in zeilen[1:] if z and z[0].startswith("Schätze"))
|
||||
# Der Titel enthält selbst ein Semikolon — nur EIN Feld, sauber gequotet.
|
||||
assert schaetze[0] == "Schätze; der Azteken"
|
||||
assert schaetze[1] == 'Anfänger "und" Profis'
|
||||
assert schaetze[3] == "2024"
|
||||
|
||||
|
||||
def test_csv_archiv_enthaelt_nur_archiviertes(app, client, daten_bestand):
|
||||
melde_an(client)
|
||||
zeilen = csv_zeilen(client.get("/export/archiv/csv"))
|
||||
titel = {z[0] for z in zeilen[1:] if z}
|
||||
assert titel == {"Alte Schachtel"}
|
||||
status_spalte = zeilen[0].index("Status")
|
||||
assert all(z[status_spalte] == "Archiviert" for z in zeilen[1:] if z)
|
||||
|
||||
|
||||
def test_csv_leere_liste_nur_kopfzeile(app, client):
|
||||
melde_an(client)
|
||||
antwort = client.get("/export/planung/csv")
|
||||
assert antwort.status_code == 200
|
||||
assert antwort.content.startswith(b"\xef\xbb\xbf")
|
||||
zeilen = csv_zeilen(antwort)
|
||||
assert len(zeilen) == 1 # nur Kopfzeile
|
||||
assert zeilen[0][0] == "Titel"
|
||||
|
||||
|
||||
def test_download_header_streaming(app, client, daten_bestand):
|
||||
"""StreamingResponse: Attachment-Disposition mit Dateiname."""
|
||||
melde_an(client)
|
||||
antwort = client.get("/export/planung/csv")
|
||||
disposition = antwort.headers["content-disposition"]
|
||||
assert "attachment" in disposition
|
||||
assert "filename=\"spiele-planung-export-" in disposition
|
||||
|
||||
|
||||
# ---------------- PDF ----------------
|
||||
|
||||
def test_pdf_gueltige_datei(app, client, daten_bestand, pdf_faehig):
|
||||
melde_an(client)
|
||||
antwort = client.get("/export/planung/pdf")
|
||||
assert antwort.status_code == 200
|
||||
assert antwort.headers["content-type"].startswith("application/pdf")
|
||||
# gültiger PDF-Kopf und -Abschluss:
|
||||
assert antwort.content.startswith(b"%PDF-")
|
||||
assert antwort.content.rstrip().endswith(b"%%EOF")
|
||||
assert len(antwort.content) > 500 # kein leeres Dokument
|
||||
|
||||
|
||||
def test_pdf_leere_liste_bleibt_gueltig(app, client, pdf_faehig):
|
||||
melde_an(client)
|
||||
antwort = client.get("/export/archiv/pdf")
|
||||
assert antwort.status_code == 200
|
||||
assert antwort.content.startswith(b"%PDF-")
|
||||
assert antwort.content.rstrip().endswith(b"%%EOF")
|
||||
|
||||
|
||||
def test_pdf_enthaelt_gerenderte_inhaltsstroeme(app, client, daten_bestand, pdf_faehig):
|
||||
"""WeasyPrint komprimiert Inhaltsströme (Flate) — ohne Inhalt gäbe es keine."""
|
||||
melde_an(client)
|
||||
antwort = client.get("/export/neuheiten/pdf")
|
||||
assert antwort.content.startswith(b"%PDF-")
|
||||
assert b"FlateDecode" in antwort.content # komprimierte Seiten-Inhalte vorhanden
|
||||
# Struktur: mindestens ein Seitenobjekt im Objektstrom des PDFs
|
||||
import zlib
|
||||
|
||||
strome = []
|
||||
daten = antwort.content
|
||||
start = 0
|
||||
while True:
|
||||
start = daten.find(b"stream\r?\n", start)
|
||||
if start == -1:
|
||||
break
|
||||
# robuster: alle Streams zwischen "stream\n" und "endstream" dekomprimieren
|
||||
stuecke = daten.split(b"stream\n")
|
||||
for stueck in stuecke[1:]:
|
||||
roh = stueck.split(b"endstream")[0].rstrip(b"\r\n")
|
||||
try:
|
||||
strome.append(zlib.decompress(roh))
|
||||
except Exception:
|
||||
continue
|
||||
assert strome, "Mindestens ein dekomprimierbarer Inhaltsstrom erwartet"
|
||||
assert any(b"BT" in strom for strom in strome) # Text-Zeichenoperationen
|
||||
|
||||
|
||||
# ---------------- Audit & Robustheit ----------------
|
||||
|
||||
def test_export_wird_im_audit_log_protokolliert(app, client, daten_bestand):
|
||||
melde_an(client)
|
||||
client.get("/export/planung/csv")
|
||||
with app.state.engine.connect() as conn:
|
||||
eintraege = conn.execute(
|
||||
text(
|
||||
"SELECT action, objekt_typ FROM audit_eintraege "
|
||||
"WHERE action='exportiert' ORDER BY id DESC"
|
||||
)
|
||||
).fetchall()
|
||||
assert len(eintraege) >= 1
|
||||
assert eintraege[0] == ("exportiert", "liste-planung")
|
||||
|
||||
|
||||
def test_fehlendes_partner_plugin_degradiert_sauber(settings):
|
||||
"""Ohne planungs-/neuheiten-Tabelle zeigt die Seite „nicht verfügbar“ statt Fehler."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from redaktionskern.db import Base
|
||||
|
||||
class FehlendeTabellen:
|
||||
def get(self, _name): # Base.metadata.tables-Ersatz
|
||||
return None
|
||||
|
||||
app = create_app(settings)
|
||||
with patch.object(Base.metadata, "tables", FehlendeTabellen()):
|
||||
lege_benutzer_an(app, "admin2", "admin", "test-admin-123")
|
||||
client = TestClient(app)
|
||||
melde_an(client, "admin2", "test-admin-123")
|
||||
seite = client.get("/export")
|
||||
assert seite.status_code == 200
|
||||
assert "Nicht verfügbar" in seite.text
|
||||
ziel = client.get("/export/planung/csv", follow_redirects=False)
|
||||
assert ziel.status_code == 303
|
||||
assert "fehler" in ziel.headers["location"]
|
||||
@@ -8,7 +8,14 @@ from __future__ import annotations
|
||||
import httpx
|
||||
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"?>
|
||||
<items total="3" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
|
||||
@@ -61,9 +68,11 @@ class AufzeichnenderTransport(httpx.BaseTransport):
|
||||
self.antworten = list(antworten)
|
||||
self.uhr = uhr
|
||||
self.anfragen: list[tuple[str, float]] = []
|
||||
self.auth_header: list[str | None] = [] # Authorization je Request
|
||||
|
||||
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||
self.anfragen.append((str(request.url), self.uhr()))
|
||||
self.auth_header.append(request.headers.get("Authorization"))
|
||||
element = self.antworten.pop(0)
|
||||
if isinstance(element, Exception):
|
||||
raise element
|
||||
@@ -157,6 +166,39 @@ def test_suche_sendet_type_boardgame():
|
||||
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():
|
||||
uhr = FakeUhr()
|
||||
transport = AufzeichnenderTransport([_antwort(THING_XML)], uhr)
|
||||
@@ -266,6 +308,34 @@ def test_4xx_fuehrt_zu_fehler_ohne_weitere_versuche():
|
||||
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():
|
||||
uhr = FakeUhr()
|
||||
transport = AufzeichnenderTransport([_antwort("kaputt", status_code=500)], uhr)
|
||||
|
||||
544
tests/test_neuheiten_ki_crawler.py
Normal file
544
tests/test_neuheiten_ki_crawler.py
Normal 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]
|
||||
900
tests/test_neuheiten_quellen.py
Normal file
900
tests/test_neuheiten_quellen.py
Normal 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ä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ä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 & 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,- €</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>—</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"}
|
||||
@@ -8,8 +8,10 @@ from sqlalchemy.orm import sessionmaker
|
||||
from redaktionskern.db import Base
|
||||
|
||||
from tests._neuheiten import (
|
||||
BggAuthFehler,
|
||||
BggFehler,
|
||||
BggSpiel,
|
||||
MELDUNG_401,
|
||||
Neuheit,
|
||||
SuchTreffer,
|
||||
SyncService,
|
||||
@@ -92,7 +94,9 @@ def test_sync_legt_eintraege_an(session_factory):
|
||||
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
|
||||
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"])
|
||||
|
||||
@@ -115,7 +119,9 @@ def test_sync_aktualisiert_bestehende_eintraege_statt_duplikate(session_factory)
|
||||
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
|
||||
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"])
|
||||
|
||||
@@ -140,7 +146,9 @@ def test_sync_veraenderter_titel_gleiche_bgg_id_kein_duplikat(session_factory):
|
||||
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
|
||||
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"])
|
||||
|
||||
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"),
|
||||
},
|
||||
)
|
||||
service = SyncService(session_factory, client)
|
||||
service = SyncService(
|
||||
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
|
||||
)
|
||||
|
||||
ergebnis = service.synchronisiere(["catan"])
|
||||
|
||||
@@ -198,7 +208,9 @@ def test_sync_filtert_prototypen(session_factory):
|
||||
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"])
|
||||
|
||||
@@ -214,9 +226,91 @@ def test_prototyp_heuristik():
|
||||
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 ----------
|
||||
|
||||
|
||||
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):
|
||||
class HalbKaputterClient(FakeBggClient):
|
||||
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)]},
|
||||
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"])
|
||||
|
||||
|
||||
@@ -4,11 +4,19 @@ Alle BGG-Antworten sind gemockt (FakeBggClient), kein echter Netzwerkverkehr.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
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
|
||||
|
||||
@@ -33,6 +41,18 @@ class FakeBggClient:
|
||||
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:
|
||||
plugin = client.app.state.registry.get("neuheiten")
|
||||
# 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()
|
||||
|
||||
melde_an(client)
|
||||
antwort = client.get("/neuheiten")
|
||||
antwort = client.get("/neuheiten?jahr=1990")
|
||||
|
||||
assert antwort.status_code == 200
|
||||
for text in (
|
||||
@@ -117,12 +137,31 @@ def test_sortierung_nach_jahr_abwaerts(app, client):
|
||||
db.commit()
|
||||
|
||||
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]
|
||||
position = [antwort.text.index(titel) for titel in reihenfolge]
|
||||
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):
|
||||
melde_an(client)
|
||||
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):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("SPIELE_BGG_JAHR_FILTER", "0")
|
||||
monkeypatch.setenv("SPIELE_BGG_DEUTSCHE_TITEL_FILTER", "0")
|
||||
fake = FakeBggClient(
|
||||
treffer=[SuchTreffer(13, "Catan", 1995)],
|
||||
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:
|
||||
melde_an(client)
|
||||
_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
|
||||
|
||||
monkeypatch.setenv("SPIELE_BGG_SUCHBEGRIFFE", "essen, familien")
|
||||
monkeypatch.setenv("SPIELE_BGG_TOKEN", "test-token")
|
||||
fake = FakeBggClient()
|
||||
with TestClient(create_app(settings)) as client:
|
||||
melde_an(client)
|
||||
@@ -201,6 +244,53 @@ def test_manueller_sync_ohne_suchbegriff_nutzt_konfigurierte_begriffe(monkeypatc
|
||||
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):
|
||||
melde_an(client)
|
||||
antwort = client.get("/neuheiten", headers={"HX-Request": "true"})
|
||||
|
||||
@@ -174,7 +174,10 @@ def test_migration_legt_planungsliste_an(app):
|
||||
text("SELECT version FROM schema_migrations WHERE plugin='planung'")
|
||||
).fetchall()
|
||||
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):
|
||||
|
||||
@@ -41,7 +41,10 @@ def test_plugins_haben_navigation(app):
|
||||
|
||||
# Voll implementierte Plugins tragen keinen Platzhalter-Text mehr auf ihrer
|
||||
# Seite; die Prüfung läuft weiter für alle übrigen Platzhalter.
|
||||
VOLL_IMPLEMENTIERT = {"audit-log", "neuheiten", "benachrichtigung", "dedup", "planung"}
|
||||
VOLL_IMPLEMENTIERT = {
|
||||
"audit-log", "neuheiten", "benachrichtigung", "dedup", "planung",
|
||||
"archiv", "erinnerung", "export",
|
||||
}
|
||||
PLATZHALTER_STUBS = ERWARTETE_STUBS - VOLL_IMPLEMENTIERT
|
||||
|
||||
|
||||
|
||||
381
uv.lock
generated
381
uv.lock
generated
@@ -3,7 +3,8 @@ revision = 3
|
||||
requires-python = ">=3.11"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
"python_full_version < '3.14'",
|
||||
"python_full_version == '3.13.*'",
|
||||
"python_full_version < '3.13'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -108,6 +109,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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "certifi"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssselect2"
|
||||
version = "0.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tinycss2" },
|
||||
{ name = "webencodings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/20/92eaa6b0aec7189fa4b75c890640e076e9e793095721db69c5c81142c2e1/cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb", size = 35595, upload-time = "2026-02-12T17:16:39.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.141.1"
|
||||
@@ -252,6 +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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "greenlet"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@@ -652,6 +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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydyf"
|
||||
version = "0.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/ee/fb410c5c854b6a081a49077912a9765aeffd8e07cbb0663cfda310b01fb4/pydyf-0.12.1.tar.gz", hash = "sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095", size = 17716, upload-time = "2025-12-02T14:52:14.244Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/11/47efe2f66ba848a107adfd490b508f5c0cedc82127950553dca44d29e6c4/pydyf-0.12.1-py3-none-any.whl", hash = "sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc", size = 8028, upload-time = "2025-12-02T14:52:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
@@ -661,6 +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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyphen"
|
||||
version = "0.18.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/47/8430452269cd28863d73b903d07d329d058cf762527ff211b3864ba61fc7/pyphen-0.18.1.tar.gz", hash = "sha256:dbae6fbbe4f01cb206108b43573d857c67107be9d0e38eb1b08d6fa2210634a7", size = 2116411, upload-time = "2026-08-14T11:30:12.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/1d/23801cf008f71575f0a4800463f349afa5f04b27fe783178a45cdc4d5edf/pyphen-0.18.1-py3-none-any.whl", hash = "sha256:0aa9051e15928cecadd4c632cea0258ba57215b2a197a39baa46abcdb0f47e84", size = 2116143, upload-time = "2026-08-14T11:30:10.428Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "python-dotenv"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "spiele-redaktion"
|
||||
version = "0.1.0"
|
||||
@@ -836,6 +1137,7 @@ source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "itsdangerous" },
|
||||
@@ -844,10 +1146,12 @@ dependencies = [
|
||||
{ name = "rapidfuzz" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "weasyprint" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "freezegun" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
@@ -856,6 +1160,7 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "apscheduler", specifier = ">=3.11,<4" },
|
||||
{ name = "argon2-cffi", specifier = ">=23.1" },
|
||||
{ name = "beautifulsoup4", specifier = ">=4.12" },
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "itsdangerous", specifier = ">=2.1" },
|
||||
@@ -864,10 +1169,12 @@ requires-dist = [
|
||||
{ name = "rapidfuzz", specifier = ">=3.14.5" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
||||
{ name = "weasyprint", specifier = ">=69.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "freezegun", specifier = ">=1.5.5" },
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinycss2"
|
||||
version = "1.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "webencodings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyhtml5"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "webencodings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/1f/cfe2f6b30557c92b3f31d41707e09cef5c1efbd87392bc6c0430c46b0e4d/tinyhtml5-2.1.0.tar.gz", hash = "sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67", size = 179242, upload-time = "2026-03-05T17:06:30.704Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/48/01695a036b695f83fea7aef6955d735db0f517b1c8e25ddb399ac0bdbcbf/tinyhtml5-2.1.0-py3-none-any.whl", hash = "sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a", size = 39686, upload-time = "2026-03-05T17:06:28.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
@@ -1134,6 +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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weasyprint"
|
||||
version = "69.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi" },
|
||||
{ name = "cssselect2" },
|
||||
{ name = "fonttools", extra = ["woff"] },
|
||||
{ name = "pillow" },
|
||||
{ name = "pydyf" },
|
||||
{ name = "pyphen" },
|
||||
{ name = "tinycss2" },
|
||||
{ name = "tinyhtml5" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/53/dcc3885c2f7a47faa45f6b8b801412f5f9e055173a52801ef01c09943c5a/weasyprint-69.0.tar.gz", hash = "sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c", size = 1549834, upload-time = "2026-06-02T14:42:17.765Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/93/cb/208525c6bd5033d7b2589b55e07bec23d9c61bb00703cbaf20ef52c3811f/weasyprint-69.0-py3-none-any.whl", hash = "sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6", size = 322872, upload-time = "2026-06-02T14:42:15.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webencodings"
|
||||
version = "0.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/a0/8fd707bcb776a7be556bad06a2ea5fb9bd519df78ef8e26f70ccf0f38bff/webencodings-0.6.1.tar.gz", hash = "sha256:565f9ad031c702dae404e27a099e3e09186a3ab1b9520f06d215502b651fd910", size = 15001, upload-time = "2026-08-15T14:22:57.549Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl", hash = "sha256:7fab6269c8bf237c657876b52058ccb182e861518d1c695c1a9aaa8c1c105d5b", size = 8745, upload-time = "2026-08-15T14:22:56.31Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "17.0.1"
|
||||
@@ -1248,3 +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/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/21/3b6af43a663b22b00e738bb0642931a2579e15da6852613d56c6aa535d28/zopfli-0.4.3.tar.gz", hash = "sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe", size = 179156, upload-time = "2026-06-10T09:10:19.96Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/5f/b7d81b670daf990e15a0f7551da96c3c0700f69ae6d96b0245d6a19f51f3/zopfli-0.4.3-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073", size = 291492, upload-time = "2026-06-10T09:10:06.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c8/d8d8d731e0b192024567b7198fb77b748821d355f3c8bf0109de27191f43/zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc", size = 829354, upload-time = "2026-06-10T09:10:07.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/2b/fbe8ba2ec40f5986b8983a4752f7a32672a80a10ea6e68213324a7055469/zopfli-0.4.3-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206", size = 818436, upload-time = "2026-06-10T09:10:09.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/d9/63568c54c8b68b9135f3456c5add83797a5528d596657f0e4f4910173b08/zopfli-0.4.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e", size = 1778931, upload-time = "2026-06-10T09:10:10.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/05/8f3aac10a858e89c2146d3a1f6ce33634c3db757365b4148fef1b85784d2/zopfli-0.4.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef", size = 1864132, upload-time = "2026-06-10T09:10:11.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/20/9ca59d14b91f9fbc631793b4b085b309777edadaca496aa518a180817827/zopfli-0.4.3-cp310-abi3-win32.whl", hash = "sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6", size = 271715, upload-time = "2026-06-10T09:10:12.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/3a/4ff4fdead77ef30f5832b38a47eb7a1283e98b3c678576b83f8fdfff53eb/zopfli-0.4.3-cp310-abi3-win_amd64.whl", hash = "sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357", size = 288550, upload-time = "2026-06-10T09:10:13.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/44/6264f929057236fde72dd6d271f54612b4811ce37288e002f5d5339d696a/zopfli-0.4.3-cp310-abi3-win_arm64.whl", hash = "sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42", size = 451343, upload-time = "2026-06-10T09:10:14.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/bf/403da5a753731d9a4e4d65a494c4a9ae5a0fe62e7afffe5ab49915adc9a3/zopfli-0.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0087c9a6f0c8a052be0f6d1a9bb71b6caffdd3e10201d6d6166e28d482cebe6d", size = 147045, upload-time = "2026-06-10T09:10:15.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/5f/afaa18db62ab44da01a3fc39b6cb110478d26cd2287baa83461c6454ed45/zopfli-0.4.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2e0adcf7d36c6fd0dd36cc771ef7f0c5803a05666feafcd90d7170174a4148e", size = 127265, upload-time = "2026-06-10T09:10:16.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/3a/76bdfd8b35300391666b090357d059ce4c555b9d9ce9878dd551a9ad63a0/zopfli-0.4.3-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f51dd1ab5312e837e2091284e0d9f1a138188f2e65812f9a5799dc02c45f94", size = 124288, upload-time = "2026-06-10T09:10:17.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/95/5781bfb29782c39918686070dbf2ad1425c21384c3223f5c6bd911a806f8/zopfli-0.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:62248dbf8dbcbd588ee194b210e5be9fa80bce29641f55599d6d394bd2a9d8a3", size = 304624, upload-time = "2026-06-10T09:10:18.944Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user