Plugin neuheiten: BGG-Sync mit APScheduler, Filtern und deutscher UI
- Datenmodell + eigene Migration 0001_neuheiten_tabelle (Tabelle neuheiten: Titel, Verlag, Autor, Erscheinungsjahr, BGG-ID, Status 'neuheit', Quelle, Zeitstempel; bgg_id eindeutig als Merge-Kriterium) - BoardGameGeek XML API2-Client (search + thing, Batches à 20 IDs): Rate-Limit >= 1 s zwischen Requests, Retry mit exponentiellem Backoff bei 5xx/429/Netzwerkfehlern, HTTP 202 gemäß Retry-After, robustes XML-Parsing; Transport/Uhr/Sleep injizierbar (keine echten Calls in Tests) - Filter: Erweiterungen (boardgameexpansion) auf Request- und Elementebene ausgeschlossen; Prototypen per Titel-Heuristik (BGG hat keinen Marker) - Sync-Service mit Update-statt-Duplikat-Logik über die eindeutige BGG-ID (Status bleibt erhalten); Fehler je Suchbegriff brechen den Lauf nicht ab - APScheduler-Hintergrundjob (Standard 24 h) mit Überlappungsschutz, abschaltbar/intervallkonfigurierbar per Env; manueller 'Jetzt synchronisieren'-Endpunkt nur für Admin/Redakteur, optional mit Sofort-Suchbegriff - UI /neuheiten: sortier-/filterbare Tabelle mit Volltextsuche (HTMX-Teilladung, noscript-fähig), deutsche Oberfläche, BGG-Links, Ergebnis-Banner - Plugin-Loader: idempotentes Laden (Modul-Caching), damit mehrere create_app()-Aufrufe dieselben Plugin-Klassen/Tabellen nutzen - Tests: Parsing, Erweiterungs-/Prototyp-Filter, Rate-Limit/Backoff/202, Update-statt-Duplikat, Rollen am Sync-Endpunkt, Scheduler-Lifecycle — ausschließlich mit gemockten BGG-Antworten (uv run pytest: 96 grün) - README/AGENTS: Plugin-Doku, Env-Variablen, Fortschrittstabelle aktualisiert
This commit is contained in:
@@ -4,8 +4,9 @@
|
|||||||
Multi-User-Webanwendung „KI-Assistenz für Spielemagazin-Redaktionen": Neuheitenliste (BGG), Dedup-Prüfungen, Planungsliste, Archivierung, Erinnerungen, Benachrichtigungen (E-Mail/Telegram/In-App), Audit-Log, Export.
|
Multi-User-Webanwendung „KI-Assistenz für Spielemagazin-Redaktionen": Neuheitenliste (BGG), Dedup-Prüfungen, Planungsliste, Archivierung, Erinnerungen, Benachrichtigungen (E-Mail/Telegram/In-App), Audit-Log, Export.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
Kern mit Plugin-System/Auth/Migrationen fertig; `audit-log` vollständig
|
Kern mit Plugin-System/Auth/Migrationen fertig; `audit-log`,
|
||||||
implementiert; `neuheiten`/`benachrichtigung` in Arbeit; übrige Plugins Stubs.
|
`benachrichtigung` und `neuheiten` (BGG-Sync) vollständig implementiert;
|
||||||
|
übrige Plugins Stubs.
|
||||||
Live-Status: README.md → „Projekt-Fortschritt“.
|
Live-Status: README.md → „Projekt-Fortschritt“.
|
||||||
|
|
||||||
## Struktur
|
## Struktur
|
||||||
|
|||||||
85
README.md
85
README.md
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
**KI-Assistenz für Spielemagazin-Redaktionen** — Multi-User-Webanwendung mit
|
**KI-Assistenz für Spielemagazin-Redaktionen** — Multi-User-Webanwendung mit
|
||||||
modularer Plugin-Architektur. Lauffähiger Kern mit Plugin-System,
|
modularer Plugin-Architektur. Lauffähiger Kern mit Plugin-System,
|
||||||
Authentifizierung/Rollen und Migrationen; als erstes Fachplugin ist das
|
Authentifizierung/Rollen und Migrationen; vollständig implementiert sind
|
||||||
**Audit-Log** vollständig implementiert, weitere Plugins folgen.
|
bisher **Audit-Log**, **Benachrichtigung** und **Neuheiten (BGG-Sync)**.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ Authentifizierung/Rollen und Migrationen; als erstes Fachplugin ist das
|
|||||||
- **Auth:** Session-Cookies (signiert), Passwort-Hashing mit Argon2id
|
- **Auth:** Session-Cookies (signiert), Passwort-Hashing mit Argon2id
|
||||||
- **Plugins:** Entry-Points (`importlib.metadata`, Gruppe `spiele_redaktion.plugins`)
|
- **Plugins:** Entry-Points (`importlib.metadata`, Gruppe `spiele_redaktion.plugins`)
|
||||||
plus lokales `plugins/`-Verzeichnis
|
plus lokales `plugins/`-Verzeichnis
|
||||||
|
- **Hintergrund-Jobs:** APScheduler (BGG-Neuheiten-Sync)
|
||||||
- **Dependencies/Tests:** [uv](https://docs.astral.sh/uv/) + pytest
|
- **Dependencies/Tests:** [uv](https://docs.astral.sh/uv/) + pytest
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
@@ -47,6 +48,10 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt:
|
|||||||
| `SPIELE_SMTP_ABSENDER` | Benutzer bzw. `spiele-redaktion@localhost` | From-Adresse |
|
| `SPIELE_SMTP_ABSENDER` | Benutzer bzw. `spiele-redaktion@localhost` | From-Adresse |
|
||||||
| `SPIELE_SMTP_TLS` | `starttls` | `starttls`, `ssl` oder `keine` |
|
| `SPIELE_SMTP_TLS` | `starttls` | `starttls`, `ssl` oder `keine` |
|
||||||
| `SPIELE_TELEGRAM_BOT_TOKEN` | *(leer)* | Bot-Token für den Telegram-Kanal; leer = Dev-Fallback (nur Protokoll) |
|
| `SPIELE_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) |
|
||||||
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
@@ -59,7 +64,10 @@ Abgedeckt: Plugin-Loader lädt alle Plugins (inkl. Lifecycle-Hooks und
|
|||||||
Entry-Point-Pfad), Migrations-Laufzeit inkl. Idempotenz, Login/Logout,
|
Entry-Point-Pfad), Migrations-Laufzeit inkl. Idempotenz, Login/Logout,
|
||||||
Rollen-Zugriff (admin/redakteur vs. rezensent), Benutzerverwaltung sowie das
|
Rollen-Zugriff (admin/redakteur vs. rezensent), Benutzerverwaltung sowie das
|
||||||
Audit-Log-Plugin (Logging-Funktion, Migration, Filter, Paginierung,
|
Audit-Log-Plugin (Logging-Funktion, Migration, Filter, Paginierung,
|
||||||
Nur-Admin-Zugriff).
|
Nur-Admin-Zugriff) sowie das Neuheiten-Plugin: BGG-Parsing, Erweiterungs-/
|
||||||
|
Prototyp-Filter, Rate-Limit & Retry-Backoff (gemockte HTTP-Antworten, keine
|
||||||
|
echten API-Calls), Update-statt-Duplikat-Logik, UI-Suche/Filter/Sortierung,
|
||||||
|
Rollen am Sync-Endpunkt und Scheduler-Lifecycle.
|
||||||
|
|
||||||
## Architektur
|
## Architektur
|
||||||
|
|
||||||
@@ -75,7 +83,8 @@ src/redaktionskern/ schlanker Kern — KEINE Fachlogik
|
|||||||
|
|
||||||
plugins/ ein Ordner pro Funktion, ladbar über den Plugin-Loader
|
plugins/ ein Ordner pro Funktion, ladbar über den Plugin-Loader
|
||||||
├── audit-log/ VOLL IMPLEMENTIERT: Model, Migration, API, Admin-Ansicht
|
├── audit-log/ VOLL IMPLEMENTIERT: Model, Migration, API, Admin-Ansicht
|
||||||
├── neuheiten/ benachrichtigung/ (in Arbeit)
|
├── neuheiten/ VOLL IMPLEMENTIERT: BGG-Client, Sync-Job, Migration, UI
|
||||||
|
├── benachrichtigung/ VOLL IMPLEMENTIERT: E-Mail/Telegram/In-App
|
||||||
├── dedup/ planung/ archiv/ erinnerung/ export/ (Stubs, ladbar)
|
├── dedup/ planung/ archiv/ erinnerung/ export/ (Stubs, ladbar)
|
||||||
└── … je __init__.py + templates/<name>/
|
└── … je __init__.py + templates/<name>/
|
||||||
```
|
```
|
||||||
@@ -196,6 +205,70 @@ bericht = await plugin.send_notification(user, "Titel", "Nachrichtentext", "kate
|
|||||||
einzelne Kanalausfälle werden protokolliert und im Bericht gemeldet. Die
|
einzelne Kanalausfälle werden protokolliert und im Bericht gemeldet. Die
|
||||||
In-App-Nachricht landet im Posteingang des Benutzers.
|
In-App-Nachricht landet im Posteingang des Benutzers.
|
||||||
|
|
||||||
|
## Plugin „neuheiten“ (implementiert)
|
||||||
|
|
||||||
|
Neuheitenliste auf Basis der **BoardGameGeek XML API2**
|
||||||
|
(`https://boardgamegeek.com/xmlapi2`). Regelmäßiger Sync als Hintergrund-Job,
|
||||||
|
manuelle Synchronisation für die Redaktion, deutsche sortier-/filterbare Liste.
|
||||||
|
|
||||||
|
### Datentabelle
|
||||||
|
|
||||||
|
Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten`
|
||||||
|
(SQLite ↔ Postgres portabel):
|
||||||
|
|
||||||
|
| Spalte | Inhalt |
|
||||||
|
|--------|--------|
|
||||||
|
| `titel` | Spieltitel |
|
||||||
|
| `verlag` / `autor` | Verlag bzw. Autor(en), kommagetrennt |
|
||||||
|
| `erscheinungsjahr` | Erscheinungsjahr — BGG liefert nur das Jahr, kein genaues Datum |
|
||||||
|
| `bgg_id` | BoardGameGeek-ID, eindeutig → Merge-Kriterium des Syncs |
|
||||||
|
| `status` | `neuheit` (spätere Plugins: Planung/Archiv) |
|
||||||
|
| `quelle` | Datenquelle (`boardgamegeek`) |
|
||||||
|
| `erstellt_am` / `aktualisiert_am` | Zeitstempel |
|
||||||
|
|
||||||
|
### BGG-Client & Sync-Ablauf
|
||||||
|
|
||||||
|
Pro Suchbegriff: `/search?type=boardgame&query=…` → IDs sammeln →
|
||||||
|
`/thing?id=…&type=boardgame` in Batches à 20 IDs.
|
||||||
|
|
||||||
|
- **Rate-Limit:** mindestens 1 Sekunde zwischen zwei Requests.
|
||||||
|
- **Retry mit Backoff:** exponentielles Zurückhalten bei 5xx, 429 und
|
||||||
|
Netzwerkfehlern; HTTP 202 (BGG-Warteschlange) wird gemäß `Retry-After`
|
||||||
|
erneut versucht; kaputtes XML erzeugt eine klare Fehlermeldung.
|
||||||
|
- **Filter:**
|
||||||
|
- Erweiterungen (`type=boardgameexpansion`) werden auf Request-Ebene
|
||||||
|
(`type=boardgame`) und zusätzlich pro Element ausgeschlossen.
|
||||||
|
- Prototypen: Die XML API2 liefert keinen Prototyp-Marker; als Heuristik
|
||||||
|
werden Titel mit Prototyp-Schlüsselwörtern („Prototyp“, „Prototype“, …)
|
||||||
|
gefiltert. Restrisiko verbleibt — redaktionelle Prüfung bleibt wichtig.
|
||||||
|
- **Update statt Duplikat:** bestehende Einträge werden über die eindeutige
|
||||||
|
`bgg_id` aktualisiert (Titel/Verlag/Autor/Jahr), der `status` bleibt
|
||||||
|
erhalten; neu angelegt wird nur bei unbekannter BGG-ID.
|
||||||
|
|
||||||
|
### Hintergrund-Job & manuelle Synchronisation
|
||||||
|
|
||||||
|
Beim Start registriert das Plugin einen APScheduler-Job (Standard: alle 24 h,
|
||||||
|
konfigurierbar über `SPIELE_BGG_SYNC_INTERVALL_STUNDEN`, abschaltbar über
|
||||||
|
`SPIELE_BGG_SYNC_AKTIV=0`). Die Suchbegriffe kommen aus
|
||||||
|
`SPIELE_BGG_SUCHBEGRIFFE` (komma-getrennt). Läuft ein Sync noch, wird ein
|
||||||
|
überlappender Durchlauf übersprungen.
|
||||||
|
|
||||||
|
Unter **Neuheiten → „Jetzt synchronisieren“** lösen Admins und Redakteure
|
||||||
|
einen sofortigen Sync aus — optional mit einem Sofort-Suchbegriff für eine
|
||||||
|
gezielte Recherche. Das Ergebnis (neu/aktualisiert/gefiltert) erscheint als
|
||||||
|
Banner; der Endpunkt ist rollengeschützt (Rezensenten erhalten 403).
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
Die Seite `/neuheiten` zeigt alle Einträge als Tabelle (Spieltitel, Verlag,
|
||||||
|
Autor, Erscheinungsjahr, Status, aktualisiert am):
|
||||||
|
|
||||||
|
- Sortierung per Spaltenkopf, Suche über Titel/Verlag/Autor und Statusfilter —
|
||||||
|
serverseitig umgesetzt, per HTMX ohne Seitenreload (ohne JavaScript als
|
||||||
|
normaler Formular-GET nutzbar).
|
||||||
|
- Jeder Titel verlinkt direkt auf den BGG-Eintrag.
|
||||||
|
- Sync-Steuerung nur für Rolle Admin/Redakteur.
|
||||||
|
|
||||||
## Deployment (später)
|
## Deployment (später)
|
||||||
|
|
||||||
Docker/Podman Compose ist vorgesehen (Henry-Lab, danach Kundenhardware).
|
Docker/Podman Compose ist vorgesehen (Henry-Lab, danach Kundenhardware).
|
||||||
@@ -211,7 +284,7 @@ geschrieben.
|
|||||||
| 1 | Kern: FastAPI, Plugin-System, DB/Migrationen, Auth/Rollen, HTMX-Layout | ✅ fertig (28 Tests grün) |
|
| 1 | Kern: FastAPI, Plugin-System, DB/Migrationen, Auth/Rollen, HTMX-Layout | ✅ fertig (28 Tests grün) |
|
||||||
| 2 | Plugin `benachrichtigung` (E-Mail/Telegram/In-App, User-Präferenzen) | 🔄 in Arbeit |
|
| 2 | Plugin `benachrichtigung` (E-Mail/Telegram/In-App, User-Präferenzen) | 🔄 in Arbeit |
|
||||||
| 3 | Plugin `audit-log` (Wer/Was/Wann, Admin-Ansicht, öffentliche API) | ✅ fertig |
|
| 3 | Plugin `audit-log` (Wer/Was/Wann, Admin-Ansicht, öffentliche API) | ✅ fertig |
|
||||||
| 4 | Plugin `neuheiten` (BGG-Sync, APScheduler, Filter) | 🔄 in Arbeit |
|
| 4 | Plugin `neuheiten` (BGG-Sync, APScheduler, Filter) | ✅ fertig |
|
||||||
| 5 | Plugin `dedup` (Verlags-Konflikt, deutsche Version, Vorgänger-/Planungs-Check) | ⏳ offen |
|
| 5 | Plugin `dedup` (Verlags-Konflikt, deutsche Version, Vorgänger-/Planungs-Check) | ⏳ offen |
|
||||||
| 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ⏳ offen |
|
| 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ⏳ offen |
|
||||||
| 7 | Plugin `archiv` (12-Monats-Autopilot) | ⏳ offen |
|
| 7 | Plugin `archiv` (12-Monats-Autopilot) | ⏳ offen |
|
||||||
@@ -225,6 +298,6 @@ Legende: ✅ fertig · 🔄 in Arbeit · ⏳ offen · ⚠️ fertig mit offenen
|
|||||||
## Offene Punkte (nicht Teil von Phase 1)
|
## Offene Punkte (nicht Teil von Phase 1)
|
||||||
|
|
||||||
- CSRF-Schutz für Formulare (aktuell SameSite=Lax-Cookie als Basisschutz)
|
- CSRF-Schutz für Formulare (aktuell SameSite=Lax-Cookie als Basisschutz)
|
||||||
- Hintergrund-Jobs (APScheduler) für BGG-Sync und Erinnerungen
|
- Hintergrund-Jobs (APScheduler) für Erinnerungen
|
||||||
- WeasyPrint für PDF-Export (Systemabhängigkeiten im Container)
|
- WeasyPrint für PDF-Export (Systemabhängigkeiten im Container)
|
||||||
- Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets
|
- Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets
|
||||||
|
|||||||
@@ -1,41 +1,246 @@
|
|||||||
"""Plugin „neuheiten“ — Platzhalter gemäß Plugin-Vertrag.
|
"""Plugin „neuheiten“ — Neuheitenliste aus der BoardGameGeek XML API2.
|
||||||
|
|
||||||
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
|
Umfang:
|
||||||
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
|
- Eigene Migration (Tabelle `neuheiten`).
|
||||||
|
- BGG-Client mit Rate-Limit/Retry (siehe bgg.py), Sync-Service (sync.py).
|
||||||
|
- Regelmäßiger Sync als Hintergrund-Job (APScheduler, konfigurierbar).
|
||||||
|
- Manueller „Jetzt synchronisieren“-Button für Admins/Redakteure.
|
||||||
|
- Sortier-/filterbare Neuheitenliste mit Suche (deutsche UI).
|
||||||
|
|
||||||
|
Konfiguration über Umgebungsvariablen (gelesen beim Plugin-Start):
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
Keine Fachlogik im Kern — alles hier im Plugin gemäß Plugin-Vertrag.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import Depends, Request
|
import logging
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from redaktionskern.auth.deps import require_user
|
from fastapi import Depends, Form, Request
|
||||||
from redaktionskern.auth.models import User
|
from fastapi.responses import RedirectResponse
|
||||||
from redaktionskern.contracts import BasePlugin, NavEntry
|
from sqlalchemy import func, or_, select
|
||||||
|
|
||||||
|
from redaktionskern.auth.deps import require_roles, require_user
|
||||||
|
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
|
||||||
|
|
||||||
|
_logger = logging.getLogger("plugins.neuheiten")
|
||||||
|
|
||||||
|
SORTIERBAR = {
|
||||||
|
"titel": Neuheit.titel,
|
||||||
|
"verlag": Neuheit.verlag,
|
||||||
|
"autor": Neuheit.autor,
|
||||||
|
"jahr": Neuheit.erscheinungsjahr,
|
||||||
|
"status": Neuheit.status,
|
||||||
|
"aktualisiert": Neuheit.aktualisiert_am,
|
||||||
|
}
|
||||||
|
STANDARD_SORTIERUNG = ("titel", "auf")
|
||||||
|
MAX_MELDUNGS_LAENGE = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _umgebung_liste(name: str, standard: str) -> list[str]:
|
||||||
|
import os
|
||||||
|
|
||||||
|
roh = os.environ.get(name, standard)
|
||||||
|
return [teil.strip() for teil in roh.split(",") if teil.strip()]
|
||||||
|
|
||||||
|
|
||||||
class NeuheitenPlugin(BasePlugin):
|
class NeuheitenPlugin(BasePlugin):
|
||||||
name = "neuheiten"
|
name = "neuheiten"
|
||||||
title = "Neuheiten"
|
title = "Neuheiten"
|
||||||
description = "KI-gestützte Neuheitenliste aus der BoardGameGeek-API (Platzhalter)."
|
description = (
|
||||||
|
"Neuheitenliste aus der BoardGameGeek-API — regelmäßiger Sync als "
|
||||||
|
"Hintergrund-Job, Erweiterungen und Prototypen werden gefiltert."
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self._scheduler = None # BackgroundScheduler, falls aktiviert
|
||||||
|
self._sync_sperre = threading.Lock()
|
||||||
|
self._suchbegriffe: list[str] = []
|
||||||
|
self._max_treffer_pro_suche = 25
|
||||||
|
|
||||||
@self.router.get("/neuheiten")
|
@self.router.get("/neuheiten")
|
||||||
def seite(request: Request, user: User = Depends(require_user)):
|
def seite(
|
||||||
"""Platzhalterseite des Plugins."""
|
request: Request,
|
||||||
|
user: User = Depends(require_user),
|
||||||
|
q: str = "",
|
||||||
|
status: str = "",
|
||||||
|
sort: str = STANDARD_SORTIERUNG[0],
|
||||||
|
richtung: str = STANDARD_SORTIERUNG[1],
|
||||||
|
meldung: str = "",
|
||||||
|
):
|
||||||
|
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()
|
||||||
|
|
||||||
|
with self.context.session_factory() as db:
|
||||||
|
abfrage = select(Neuheit)
|
||||||
|
if q.strip():
|
||||||
|
muster = f"%{q.strip().lower()}%"
|
||||||
|
abfrage = abfrage.where(
|
||||||
|
or_(
|
||||||
|
func.lower(Neuheit.titel).like(muster),
|
||||||
|
func.lower(func.coalesce(Neuheit.verlag, "")).like(muster),
|
||||||
|
func.lower(func.coalesce(Neuheit.autor, "")).like(muster),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if status.strip():
|
||||||
|
abfrage = abfrage.where(Neuheit.status == status.strip())
|
||||||
|
eintraege = db.scalars(
|
||||||
|
abfrage.order_by(spalte_sortiert, Neuheit.id.asc())
|
||||||
|
).all()
|
||||||
|
status_optionen = [
|
||||||
|
zeile for zeile in db.scalars(
|
||||||
|
select(Neuheit.status).distinct().order_by(Neuheit.status)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
kontext = {
|
||||||
|
"user": user,
|
||||||
|
"titel": self.title,
|
||||||
|
"eintraege": eintraege,
|
||||||
|
"q": q,
|
||||||
|
"status_filter": status,
|
||||||
|
"sort": sort if sort in SORTIERBAR else STANDARD_SORTIERUNG[0],
|
||||||
|
"richtung": "ab" if abwaerts else "auf",
|
||||||
|
"status_optionen": status_optionen,
|
||||||
|
"meldung": meldung[:MAX_MELDUNGS_LAENGE],
|
||||||
|
"ist_redaktion": user.role in (
|
||||||
|
Role.ADMIN.value,
|
||||||
|
Role.REDAKTEUR.value,
|
||||||
|
),
|
||||||
|
"suchbegriffe": ", ".join(self._suchbegriffe) or "—",
|
||||||
|
"sync_aktiv": self._scheduler is not None and self._scheduler.running,
|
||||||
|
"anzahl": len(eintraege),
|
||||||
|
}
|
||||||
|
if request.headers.get("HX-Request") == "true":
|
||||||
|
return self.context.templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="neuheiten/_liste.html",
|
||||||
|
context=kontext,
|
||||||
|
)
|
||||||
return self.context.templates.TemplateResponse(
|
return self.context.templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="neuheiten/index.html",
|
name="neuheiten/index.html",
|
||||||
context={
|
context=kontext,
|
||||||
"user": user,
|
|
||||||
"titel": self.title,
|
|
||||||
"name": self.name,
|
|
||||||
"version": self.version,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@self.router.post("/neuheiten/sync")
|
||||||
|
def jetzt_synchronisieren(
|
||||||
|
user: User = Depends(require_roles(Role.ADMIN.value, Role.REDAKTEUR.value)),
|
||||||
|
suchbegriff: str = Form(""),
|
||||||
|
):
|
||||||
|
begriffe = [suchbegriff] if suchbegriff.strip() else self._suchbegriffe
|
||||||
|
try:
|
||||||
|
ergebnis = self._sync_ausfuehren(begriffe)
|
||||||
|
except BggFehler as exc:
|
||||||
|
_logger.warning("Manueller BGG-Sync fehlgeschlagen: %s", exc)
|
||||||
|
ziel = f"/neuheiten?meldung={quote(f'Sync fehlgeschlagen: {exc}')}"
|
||||||
|
return RedirectResponse(ziel, status_code=303)
|
||||||
|
ziel = f"/neuheiten?meldung={quote(f'Sync abgeschlossen: {ergebnis.als_text()}')}"
|
||||||
|
return RedirectResponse(ziel, status_code=303)
|
||||||
|
|
||||||
|
# ---------- 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 navigation(self) -> list[NavEntry]:
|
def navigation(self) -> list[NavEntry]:
|
||||||
return [NavEntry(label=self.title, url="/neuheiten")]
|
return [NavEntry(label=self.title, url="/neuheiten")]
|
||||||
|
|
||||||
|
def on_load(self, context: PluginContext) -> None:
|
||||||
|
super().on_load(context)
|
||||||
|
import os
|
||||||
|
|
||||||
|
self._suchbegriffe = _umgebung_liste("SPIELE_BGG_SUCHBEGRIFFE", "brettspiel")
|
||||||
|
try:
|
||||||
|
self._max_treffer_pro_suche = int(
|
||||||
|
os.environ.get("SPIELE_BGG_MAX_TREFFER_PRO_SUCHE", "25")
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
self._max_treffer_pro_suche = 25
|
||||||
|
|
||||||
|
if os.environ.get("SPIELE_BGG_SYNC_AKTIV", "1").strip() == "1":
|
||||||
|
self._scheduler_starten(os.environ.get("SPIELE_BGG_SYNC_INTERVALL_STUNDEN"))
|
||||||
|
|
||||||
|
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_client(self) -> BggClient:
|
||||||
|
"""Fabrik für den BGG-Client; von Tests überschreibbar."""
|
||||||
|
return BggClient()
|
||||||
|
|
||||||
|
def _sync_ausfuehren(self, suchbegriffe: list[str]):
|
||||||
|
client = self._neuer_client()
|
||||||
|
try:
|
||||||
|
service = SyncService(
|
||||||
|
self.context.session_factory,
|
||||||
|
client,
|
||||||
|
max_treffer_pro_suchbegriff=self._max_treffer_pro_suche,
|
||||||
|
)
|
||||||
|
return service.synchronisiere(suchbegriffe)
|
||||||
|
finally:
|
||||||
|
client.schliessen()
|
||||||
|
|
||||||
|
def _sync_job(self) -> None:
|
||||||
|
"""Hintergrund-Job: holt regelmäßig die Neuheiten ab."""
|
||||||
|
if not self._sync_sperre.acquire(blocking=False):
|
||||||
|
_logger.info("BGG-Sync läuft bereits — Durchlauf übersprungen.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ergebnis = self._sync_ausfuehren(self._suchbegriffe)
|
||||||
|
_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:
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
|
||||||
|
try:
|
||||||
|
intervall_stunden = int(intervall_stunden_roh or "24")
|
||||||
|
except ValueError:
|
||||||
|
intervall_stunden = 24
|
||||||
|
intervall_stunden = max(intervall_stunden, 1)
|
||||||
|
|
||||||
|
scheduler = BackgroundScheduler()
|
||||||
|
erster_lauf = datetime.now(timezone.utc) + timedelta(seconds=10)
|
||||||
|
scheduler.add_job(
|
||||||
|
self._sync_job,
|
||||||
|
trigger="interval",
|
||||||
|
hours=intervall_stunden,
|
||||||
|
next_run_time=erster_lauf,
|
||||||
|
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,
|
||||||
|
", ".join(self._suchbegriffe),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
plugin = NeuheitenPlugin()
|
plugin = NeuheitenPlugin()
|
||||||
|
|||||||
265
plugins/neuheiten/bgg.py
Normal file
265
plugins/neuheiten/bgg.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"""Client für die BoardGameGeek XML API2 (https://boardgamegeek.com/xmlapi2).
|
||||||
|
|
||||||
|
Eigenschaften:
|
||||||
|
- Rate-Limit: mindestens `mindestabstand_sekunden` (Standard 1 s) zwischen
|
||||||
|
zwei HTTP-Requests.
|
||||||
|
- Retry mit exponentiellem Backoff bei 5xx/429; HTTP 202 (BGG-Warteschlange)
|
||||||
|
wird gemäß Retry-After-Header erneut versucht.
|
||||||
|
- Robustes XML-Parsing: fehlende Elemente werden toleriert, kaputte
|
||||||
|
Antworten erzeugen eine klare `BggFehler`-Ausnahme.
|
||||||
|
- Filter: Suchanfragen und Thing-Abfragen sind auf `type=boardgame`
|
||||||
|
beschränkt; Erweiterungen (`boardgameexpansion`) werden zusätzlich auf
|
||||||
|
Elementebene herausgefiltert.
|
||||||
|
|
||||||
|
Für Tests sind HTTP-Transport, Uhr und Schlaf-Funktion injizierbar —
|
||||||
|
es gibt keine echten Netzwerk-Aufrufe in der Testsuite.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from collections.abc import Callable, Iterable, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
BASIS_URL = "https://boardgamegeek.com/xmlapi2"
|
||||||
|
USER_AGENT = "spiele-redaktion-neuheiten/0.1 (+https://github.com/local)"
|
||||||
|
TYP_BRETTSPIEL = "boardgame"
|
||||||
|
TYP_ERWEITERUNG = "boardgameexpansion"
|
||||||
|
THING_BATCH_GROESSE = 20
|
||||||
|
|
||||||
|
|
||||||
|
class BggFehler(Exception):
|
||||||
|
"""Fehler bei der Kommunikation mit oder dem Parsen der BGG-API."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SuchTreffer:
|
||||||
|
"""Ein Treffer aus der BGG-Suche."""
|
||||||
|
|
||||||
|
bgg_id: int
|
||||||
|
titel: str
|
||||||
|
erscheinungsjahr: int | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BggSpiel:
|
||||||
|
"""Ein vollständiges Spiel aus der Thing-Abfrage."""
|
||||||
|
|
||||||
|
bgg_id: int
|
||||||
|
titel: str
|
||||||
|
verlag: str | None
|
||||||
|
autor: str | None
|
||||||
|
erscheinungsjahr: int | None
|
||||||
|
typ: str = TYP_BRETTSPIEL
|
||||||
|
|
||||||
|
|
||||||
|
def _attribut_wert(element: ET.Element | None, tag: str) -> str | None:
|
||||||
|
"""Liest `<tag><value>…</value></tag>` tolerant aus."""
|
||||||
|
if element is None:
|
||||||
|
return None
|
||||||
|
kind = element.find(tag)
|
||||||
|
if kind is None:
|
||||||
|
return None
|
||||||
|
wert = kind.get("value")
|
||||||
|
return wert.strip() if wert and wert.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _jahr(wert: str | None) -> int | None:
|
||||||
|
if not wert:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(wert)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _verketten(element: ET.Element | None, link_typ: str) -> str | None:
|
||||||
|
"""Verbindet alle `<link type="…" value="…"/>` eines Typs zu einem String."""
|
||||||
|
if element is None:
|
||||||
|
return None
|
||||||
|
werte = [
|
||||||
|
link.get("value", "").strip()
|
||||||
|
for link in element.findall("link")
|
||||||
|
if link.get("type") == link_typ and link.get("value", "").strip()
|
||||||
|
]
|
||||||
|
return ", ".join(werte) if werte else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_suche(xml_daten: bytes | str) -> list[SuchTreffer]:
|
||||||
|
"""Parst eine Search-Antwort; nicht-Brettspiele werden verworfen."""
|
||||||
|
try:
|
||||||
|
wurzel = ET.fromstring(xml_daten)
|
||||||
|
except ET.ParseError as exc:
|
||||||
|
raise BggFehler(f"Ungültiges XML in der Suchantwort: {exc}") from exc
|
||||||
|
|
||||||
|
treffer: list[SuchTreffer] = []
|
||||||
|
for element in wurzel.findall("item"):
|
||||||
|
if element.get("type") != TYP_BRETTSPIEL:
|
||||||
|
continue # Erweiterungen/Prototyp-Typen auf Elementebene ausschließen
|
||||||
|
id_roh = element.get("id")
|
||||||
|
titel = _attribut_wert(element, "name")
|
||||||
|
if not id_roh or not titel:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
bgg_id = int(id_roh)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
treffer.append(
|
||||||
|
SuchTreffer(
|
||||||
|
bgg_id=bgg_id,
|
||||||
|
titel=titel,
|
||||||
|
erscheinungsjahr=_jahr(_attribut_wert(element, "yearpublished")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return treffer
|
||||||
|
|
||||||
|
|
||||||
|
def parse_things(xml_daten: bytes | str) -> list[BggSpiel]:
|
||||||
|
"""Parst eine Thing-Antwort; Erweiterungen werden verworfen."""
|
||||||
|
try:
|
||||||
|
wurzel = ET.fromstring(xml_daten)
|
||||||
|
except ET.ParseError as exc:
|
||||||
|
raise BggFehler(f"Ungültiges XML in der Thing-Antwort: {exc}") from exc
|
||||||
|
|
||||||
|
spiele: list[BggSpiel] = []
|
||||||
|
for element in wurzel.findall("item"):
|
||||||
|
typ = element.get("type") or TYP_BRETTSPIEL
|
||||||
|
if typ != TYP_BRETTSPIEL:
|
||||||
|
continue # boardgameexpansion & Co. ausschließen
|
||||||
|
id_roh = element.get("id")
|
||||||
|
titel = _attribut_wert(element, "name")
|
||||||
|
if not id_roh or not titel:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
bgg_id = int(id_roh)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
spiele.append(
|
||||||
|
BggSpiel(
|
||||||
|
bgg_id=bgg_id,
|
||||||
|
titel=titel,
|
||||||
|
verlag=_verketten(element, "boardgamepublisher"),
|
||||||
|
autor=_verketten(element, "boardgamedesigner"),
|
||||||
|
erscheinungsjahr=_jahr(_attribut_wert(element, "yearpublished")),
|
||||||
|
typ=typ,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return spiele
|
||||||
|
|
||||||
|
|
||||||
|
def _stuecke(werte: Sequence[int], groesse: int) -> Iterable[Sequence[int]]:
|
||||||
|
for start in range(0, len(werte), groesse):
|
||||||
|
yield werte[start : start + groesse]
|
||||||
|
|
||||||
|
|
||||||
|
class BggClient:
|
||||||
|
"""HTTP-Client für die XML API2 mit Rate-Limit und Retry/Backoff."""
|
||||||
|
|
||||||
|
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 = 30.0,
|
||||||
|
schlaf: Callable[[float], None] = time.sleep,
|
||||||
|
uhr: Callable[[], float] = time.monotonic,
|
||||||
|
) -> 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
|
||||||
|
self._http = httpx.Client(
|
||||||
|
base_url=BASIS_URL,
|
||||||
|
timeout=timeout_sekunden,
|
||||||
|
transport=transport,
|
||||||
|
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 _hole(self, pfad: str, params: dict[str, Any]) -> bytes:
|
||||||
|
letzte_fehler: Exception | None = None
|
||||||
|
for versuch in range(self.max_versuche):
|
||||||
|
self._rate_limit_abwarten()
|
||||||
|
try:
|
||||||
|
antwort = self._http.get(pfad, params=params)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
letzte_fehler = exc
|
||||||
|
wartezeit = min(
|
||||||
|
self.backoff_basis_sekunden * (2**versuch),
|
||||||
|
self.backoff_maximum_sekunden,
|
||||||
|
)
|
||||||
|
self._schlaf(wartezeit)
|
||||||
|
continue
|
||||||
|
self._letzter_request_um = self._uhr()
|
||||||
|
|
||||||
|
if antwort.status_code == 200:
|
||||||
|
return antwort.content
|
||||||
|
|
||||||
|
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.
|
||||||
|
retry_after = antwort.headers.get("Retry-After")
|
||||||
|
if retry_after:
|
||||||
|
try:
|
||||||
|
wartezeit = float(retry_after)
|
||||||
|
except ValueError:
|
||||||
|
wartezeit = None
|
||||||
|
else:
|
||||||
|
wartezeit = None
|
||||||
|
if wartezeit is None:
|
||||||
|
wartezeit = min(
|
||||||
|
self.backoff_basis_sekunden * (2**versuch),
|
||||||
|
self.backoff_maximum_sekunden,
|
||||||
|
)
|
||||||
|
self._schlaf(wartezeit)
|
||||||
|
letzte_fehler = BggFehler(
|
||||||
|
f"HTTP {antwort.status_code} von der BGG-API (Versuch {versuch + 1})."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise BggFehler(
|
||||||
|
f"Unerwartete HTTP-Antwort {antwort.status_code} für {pfad}."
|
||||||
|
)
|
||||||
|
raise BggFehler(
|
||||||
|
f"BGG-API nach {self.max_versuche} Versuchen nicht erreichbar"
|
||||||
|
f" ({pfad}): {letzte_fehler}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def suche(
|
||||||
|
self, suchbegriff: str, *, max_treffer: int | None = None
|
||||||
|
) -> list[SuchTreffer]:
|
||||||
|
"""Sucht Brettspiele (`type=boardgame`) nach einem Suchbegriff."""
|
||||||
|
treffer = parse_suche(
|
||||||
|
self._hole("/search", {"query": suchbegriff, "type": TYP_BRETTSPIEL})
|
||||||
|
)
|
||||||
|
return treffer[:max_treffer] if max_treffer is not None else treffer
|
||||||
|
|
||||||
|
def details(self, bgg_ids: Sequence[int]) -> list[BggSpiel]:
|
||||||
|
"""Lädt Thing-Details (in Batches) und filtert Erweiterungen heraus."""
|
||||||
|
spiele: list[BggSpiel] = []
|
||||||
|
for batch in _stuecke(list(bgg_ids), THING_BATCH_GROESSE):
|
||||||
|
xml_daten = self._hole(
|
||||||
|
"/thing",
|
||||||
|
{"id": ",".join(str(i) for i in batch), "type": TYP_BRETTSPIEL},
|
||||||
|
)
|
||||||
|
spiele.extend(parse_things(xml_daten))
|
||||||
|
return spiele
|
||||||
37
plugins/neuheiten/models.py
Normal file
37
plugins/neuheiten/models.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""Datenmodell des Plugins „neuheiten“.
|
||||||
|
|
||||||
|
Ein Eintrag repräsentiert ein Spiel der Neuheitenliste. Die BGG-ID ist das
|
||||||
|
eindeutige Merge-Kriterium: Beim Sync werden bestehende Einträge aktualisiert,
|
||||||
|
statt Duplikate anzulegen.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Integer, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from redaktionskern.db import Base
|
||||||
|
|
||||||
|
STATUS_NEUHEIT = "neuheit"
|
||||||
|
|
||||||
|
|
||||||
|
class Neuheit(Base):
|
||||||
|
__tablename__ = "neuheiten"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
titel: Mapped[str] = mapped_column(String(300), index=True)
|
||||||
|
verlag: Mapped[str | None] = mapped_column(String(300))
|
||||||
|
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)
|
||||||
|
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())
|
||||||
|
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"<Neuheit {self.bgg_id} {self.titel!r}>"
|
||||||
133
plugins/neuheiten/sync.py
Normal file
133
plugins/neuheiten/sync.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""Sync-Logik des Plugins „neuheiten“: BGG-Suche → Filter → Upsert.
|
||||||
|
|
||||||
|
- 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).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterable, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from .bgg import BggSpiel, BggClient
|
||||||
|
from .models import STATUS_NEUHEIT, Neuheit
|
||||||
|
|
||||||
|
_logger = logging.getLogger("plugins.neuheiten")
|
||||||
|
|
||||||
|
PROTOTYP_MUSTER = re.compile(
|
||||||
|
r"(?i)\b(prototyp\w*|prototype|proto)\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
THING_BATCH_GROESSE = 20
|
||||||
|
|
||||||
|
|
||||||
|
def ist_moeglicher_prototyp(titel: str) -> bool:
|
||||||
|
"""Heuristik: Titel klingt nach Prototyp/Protoversion."""
|
||||||
|
return bool(PROTOTYP_MUSTER.search(titel))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SyncErgebnis:
|
||||||
|
"""Zusammenfassung eines Sync-Laufs."""
|
||||||
|
|
||||||
|
neu: int = 0
|
||||||
|
aktualisiert: int = 0
|
||||||
|
gefiltert: int = 0
|
||||||
|
fehler: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def als_text(self) -> str:
|
||||||
|
text = (
|
||||||
|
f"{self.neu} neu, {self.aktualisiert} aktualisiert, "
|
||||||
|
f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen)"
|
||||||
|
)
|
||||||
|
if self.fehler:
|
||||||
|
text += f"; {len(self.fehler)} Fehler"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def speichere_oder_aktualisiere(db: Session, spiel: BggSpiel) -> bool:
|
||||||
|
"""Upsert nach BGG-ID. Rückgabe: True = neu angelegt, False = aktualisiert."""
|
||||||
|
vorhanden = db.scalar(select(Neuheit).where(Neuheit.bgg_id == spiel.bgg_id))
|
||||||
|
if vorhanden is None:
|
||||||
|
db.add(
|
||||||
|
Neuheit(
|
||||||
|
titel=spiel.titel,
|
||||||
|
verlag=spiel.verlag,
|
||||||
|
autor=spiel.autor,
|
||||||
|
erscheinungsjahr=spiel.erscheinungsjahr,
|
||||||
|
bgg_id=spiel.bgg_id,
|
||||||
|
status=STATUS_NEUHEIT,
|
||||||
|
quelle="boardgamegeek",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
vorhanden.titel = spiel.titel
|
||||||
|
vorhanden.verlag = spiel.verlag
|
||||||
|
vorhanden.autor = spiel.autor
|
||||||
|
vorhanden.erscheinungsjahr = spiel.erscheinungsjahr
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class SyncService:
|
||||||
|
"""Führt einen Sync-Lauf aus: Suchen → Details → Filter → Upsert."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session_factory: sessionmaker,
|
||||||
|
client: BggClient,
|
||||||
|
*,
|
||||||
|
thing_batch_groesse: int = THING_BATCH_GROESSE,
|
||||||
|
max_treffer_pro_suchbegriff: int | None = None,
|
||||||
|
) -> 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
|
||||||
|
|
||||||
|
def synchronisiere(self, suchbegriffe: Iterable[str]) -> SyncErgebnis:
|
||||||
|
ergebnis = SyncErgebnis()
|
||||||
|
with self.session_factory() as db:
|
||||||
|
for suchbegriff in suchbegriffe:
|
||||||
|
begriff = suchbegriff.strip()
|
||||||
|
if not begriff:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
self._ein_suchbegriff(db, begriff, ergebnis)
|
||||||
|
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}")
|
||||||
|
db.commit()
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
def _ein_suchbegriff(
|
||||||
|
self, db: Session, suchbegriff: str, ergebnis: SyncErgebnis
|
||||||
|
) -> None:
|
||||||
|
treffer = self.client.suche(
|
||||||
|
suchbegriff, max_treffer=self.max_treffer_pro_suchbegriff
|
||||||
|
)
|
||||||
|
ids = [t.bgg_id for t in treffer]
|
||||||
|
for batch in _batches(ids, self.thing_batch_groesse):
|
||||||
|
for spiel in self.client.details(list(batch)):
|
||||||
|
if spiel.typ != "boardgame":
|
||||||
|
ergebnis.gefiltert += 1
|
||||||
|
continue
|
||||||
|
if ist_moeglicher_prototyp(spiel.titel):
|
||||||
|
ergebnis.gefiltert += 1
|
||||||
|
continue
|
||||||
|
if speichere_oder_aktualisiere(db, spiel):
|
||||||
|
ergebnis.neu += 1
|
||||||
|
else:
|
||||||
|
ergebnis.aktualisiert += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _batches(werte: Sequence[int], groesse: int) -> Iterable[Sequence[int]]:
|
||||||
|
for start in range(0, len(werte), groesse):
|
||||||
|
yield werte[start : start + groesse]
|
||||||
49
plugins/neuheiten/templates/neuheiten/_liste.html
Normal file
49
plugins/neuheiten/templates/neuheiten/_liste.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<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">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-50 text-left text-slate-500">
|
||||||
|
<tr>
|
||||||
|
{% 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">
|
||||||
|
{% if sort == key %}
|
||||||
|
<a href="{{ basis }}&sort={{ key }}&richtung={{ 'auf' if richtung == 'ab' else 'ab' }}"
|
||||||
|
class="hover:text-emerald-700">{{ label }} {{ '▲' if richtung == 'auf' else '▼' }}</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ basis }}&sort={{ key }}&richtung=auf" class="hover:text-emerald-700 text-slate-400">{{ label }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for eintrag in eintraege %}
|
||||||
|
<tr class="hover:bg-slate-50">
|
||||||
|
<td class="px-4 py-3 font-medium">
|
||||||
|
{{ eintrag.titel }}
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
|
||||||
|
<td class="px-4 py-3">{{ eintrag.autor or '—' }}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">{{ eintrag.erscheinungsjahr if eintrag.erscheinungsjahr else '—' }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 text-xs">{{ eintrag.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-slate-500">
|
||||||
|
{{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" 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>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -2,9 +2,58 @@
|
|||||||
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
|
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
|
||||||
{% block inhalt %}
|
{% block inhalt %}
|
||||||
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
||||||
<p class="text-slate-600 max-w-2xl">
|
<p class="text-slate-600 mb-4">
|
||||||
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
|
Neuheitenliste aus der <a href="https://boardgamegeek.com" class="text-emerald-700 underline">BoardGameGeek</a>-Datenbank.
|
||||||
in Version {{ version }} ist geladen.
|
Erweiterungen und Prototypen werden beim Sync automatisch gefiltert.
|
||||||
Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{% if meldung %}
|
||||||
|
<div id="meldung"
|
||||||
|
class="mb-4 px-4 py-3 rounded-lg border text-sm
|
||||||
|
{% if meldung.startswith('Sync fehlgeschlagen') %}bg-red-50 border-red-200 text-red-700{% else %}bg-emerald-50 border-emerald-200 text-emerald-800{% endif %}">
|
||||||
|
{{ meldung }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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">
|
||||||
|
</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">
|
||||||
|
↻ Jetzt synchronisieren
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-xs text-slate-400 ml-auto">
|
||||||
|
Hintergrund-Sync:
|
||||||
|
{% if sync_aktiv %}<span class="text-emerald-700 font-medium">aktiv</span>{% else %}<span class="text-slate-500">aus</span>{% endif %}
|
||||||
|
· Suchbegriffe: {{ suchbegriffe }}
|
||||||
|
</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">
|
||||||
|
<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">
|
||||||
|
<option value="">Alle Status</option>
|
||||||
|
{% for option in status_optionen %}
|
||||||
|
<option value="{{ option }}" {% if option == status_filter %}selected{% endif %}>{{ option }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<noscript>
|
||||||
|
<button type="submit" class="bg-slate-200 hover:bg-slate-300 px-4 py-2 rounded-lg text-sm">Filtern</button>
|
||||||
|
</noscript>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% include "neuheiten/_liste.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ dependencies = [
|
|||||||
"python-multipart>=0.0.9",
|
"python-multipart>=0.0.9",
|
||||||
"itsdangerous>=2.1",
|
"itsdangerous>=2.1",
|
||||||
"argon2-cffi>=23.1",
|
"argon2-cffi>=23.1",
|
||||||
|
"apscheduler>=3.11,<4",
|
||||||
|
"httpx>=0.27",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -61,6 +61,20 @@ def _aus_verzeichnis(verzeichnis: Path) -> list[BasePlugin]:
|
|||||||
if not init.is_file():
|
if not init.is_file():
|
||||||
continue
|
continue
|
||||||
modulname = f"{_MODUL_PRAEFIX}.{eintrag.name}"
|
modulname = f"{_MODUL_PRAEFIX}.{eintrag.name}"
|
||||||
|
|
||||||
|
# Bereits geladenes Modul wiederverwenden (idempotentes Laden):
|
||||||
|
# Mehrere create_app()-Aufrufe im selben Prozess teilen sich sonst
|
||||||
|
# doppelte Klassen/Tabellen auf derselben SQLAlchemy-MetaData.
|
||||||
|
zwischengespeichert = sys.modules.get(modulname)
|
||||||
|
if (
|
||||||
|
zwischengespeichert is not None
|
||||||
|
and hasattr(zwischengespeichert, "plugin")
|
||||||
|
and getattr(zwischengespeichert.__spec__, "origin", None)
|
||||||
|
and Path(zwischengespeichert.__spec__.origin).resolve() == init.resolve()
|
||||||
|
):
|
||||||
|
plugins.append(_als_plugin(zwischengespeichert.plugin))
|
||||||
|
continue
|
||||||
|
|
||||||
spec = importlib.util.spec_from_file_location(
|
spec = importlib.util.spec_from_file_location(
|
||||||
modulname, init, submodule_search_locations=[str(eintrag)]
|
modulname, init, submodule_search_locations=[str(eintrag)]
|
||||||
)
|
)
|
||||||
|
|||||||
42
tests/_neuheiten.py
Normal file
42
tests/_neuheiten.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Zugriff auf die neuheiten-Plugin-Module über den vom Plugin-Loader
|
||||||
|
verwendeten Modulnamen (`spiele_redaktion_plugins.neuheiten`).
|
||||||
|
|
||||||
|
Der Kern teilt sich eine SQLAlchemy-MetaData; deshalb darf es pro Prozess
|
||||||
|
nur eine Modul-Identität geben. Tests importieren das Plugin daher über
|
||||||
|
diesen Helper statt direkt über `plugins.neuheiten`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from redaktionskern.plugin_loader import discover_plugins
|
||||||
|
|
||||||
|
_PROJEKT_WURZEL = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
if "spiele_redaktion_plugins.neuheiten" not in sys.modules:
|
||||||
|
discover_plugins(
|
||||||
|
_PROJEKT_WURZEL / "plugins",
|
||||||
|
entry_point_group="spiele_redaktion.tests.keine_entry_points",
|
||||||
|
)
|
||||||
|
|
||||||
|
_neuheiten = sys.modules["spiele_redaktion_plugins.neuheiten"]
|
||||||
|
|
||||||
|
bgg = _neuheiten.bgg
|
||||||
|
sync_modul = _neuheiten.sync
|
||||||
|
models = _neuheiten.models
|
||||||
|
|
||||||
|
BggClient = bgg.BggClient
|
||||||
|
BggFehler = bgg.BggFehler
|
||||||
|
SuchTreffer = bgg.SuchTreffer
|
||||||
|
BggSpiel = bgg.BggSpiel
|
||||||
|
parse_suche = bgg.parse_suche
|
||||||
|
parse_things = bgg.parse_things
|
||||||
|
|
||||||
|
SyncService = sync_modul.SyncService
|
||||||
|
SyncErgebnis = sync_modul.SyncErgebnis
|
||||||
|
ist_moeglicher_prototyp = sync_modul.ist_moeglicher_prototyp
|
||||||
|
speichere_oder_aktualisiere = sync_modul.speichere_oder_aktualisiere
|
||||||
|
|
||||||
|
Neuheit = models.Neuheit
|
||||||
|
STATUS_NEUHEIT = models.STATUS_NEUHEIT
|
||||||
@@ -14,6 +14,16 @@ PROJEKT_WURZEL = Path(__file__).resolve().parents[1]
|
|||||||
ADMIN_PASSWORD = "test-admin-123"
|
ADMIN_PASSWORD = "test-admin-123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _hintergrundjobs_deaktiviert(monkeypatch):
|
||||||
|
"""BGG-Hintergrund-Sync in Tests standardmäßig aus (keine Threads/Netzwerk).
|
||||||
|
|
||||||
|
Einzelne Tests aktivieren den Scheduler explizit wieder per
|
||||||
|
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "1").
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "0")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def settings(tmp_path) -> Settings:
|
def settings(tmp_path) -> Settings:
|
||||||
return Settings(
|
return Settings(
|
||||||
|
|||||||
308
tests/test_neuheiten_bgg.py
Normal file
308
tests/test_neuheiten_bgg.py
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
"""Tests für den BGG-XML-API2-Client — ausschließlich mit gemockten Antworten.
|
||||||
|
|
||||||
|
Kein echter Netzwerkverkehr: Der HTTP-Transport wird durch Fakes ersetzt,
|
||||||
|
Uhr und Schlaf-Funktion sind injiziert.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests._neuheiten import BggClient, BggFehler, parse_suche, parse_things
|
||||||
|
|
||||||
|
SUCHE_XML = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<items total="3" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
|
||||||
|
<item type="boardgame" id="13">
|
||||||
|
<name value="Catan"/>
|
||||||
|
<yearpublished value="1995"/>
|
||||||
|
</item>
|
||||||
|
<item type="boardgameexpansion" id="55">
|
||||||
|
<name value="Catan: Erweiterung"/>
|
||||||
|
<yearpublished value="1996"/>
|
||||||
|
</item>
|
||||||
|
<item type="boardgame" id="9999">
|
||||||
|
<name value="Mysterium"/>
|
||||||
|
</item>
|
||||||
|
</items>
|
||||||
|
"""
|
||||||
|
|
||||||
|
THING_XML = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<items termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
|
||||||
|
<item type="boardgame" id="13">
|
||||||
|
<thumbnail>https://example.org/thumb.jpg</thumbnail>
|
||||||
|
<name type="primary" sortindex="1" value="Catan"/>
|
||||||
|
<yearpublished value="1995"/>
|
||||||
|
<link type="boardgamepublisher" id="4" value="KOSMOS"/>
|
||||||
|
<link type="boardgamepublisher" id="27" value="Catan Studio"/>
|
||||||
|
<link type="boardgamedesigner" id="2" value="Klaus Teuber"/>
|
||||||
|
</item>
|
||||||
|
<item type="boardgameexpansion" id="55">
|
||||||
|
<name type="primary" value="Catan: Erweiterung"/>
|
||||||
|
<yearpublished value="1996"/>
|
||||||
|
</item>
|
||||||
|
</items>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUhr:
|
||||||
|
"""Steuerbare Monoton-Uhr."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.zeit = 0.0
|
||||||
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self.zeit
|
||||||
|
|
||||||
|
|
||||||
|
class AufzeichnenderTransport(httpx.BaseTransport):
|
||||||
|
"""Liefert eine feste Antwortkette; zeichnet URLs und Uhrzeiten auf."""
|
||||||
|
|
||||||
|
def __init__(self, antworten: list, uhr: FakeUhr):
|
||||||
|
self.antworten = list(antworten)
|
||||||
|
self.uhr = uhr
|
||||||
|
self.anfragen: list[tuple[str, float]] = []
|
||||||
|
|
||||||
|
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
self.anfragen.append((str(request.url), self.uhr()))
|
||||||
|
element = self.antworten.pop(0)
|
||||||
|
if isinstance(element, Exception):
|
||||||
|
raise element
|
||||||
|
if not self.antworten:
|
||||||
|
self.antworten.append(element) # letzte Antwort zyklisch wiederholen
|
||||||
|
return element
|
||||||
|
|
||||||
|
|
||||||
|
def _antwort(
|
||||||
|
xml: str, status_code: int = 200, headers: dict | None = None
|
||||||
|
) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
status_code,
|
||||||
|
content=xml.encode("utf-8"),
|
||||||
|
headers=headers or {},
|
||||||
|
request=httpx.Request("GET", "https://boardgamegeek.com/xmlapi2/test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _client(
|
||||||
|
transport: httpx.BaseTransport, uhr: FakeUhr, schlaf
|
||||||
|
) -> BggClient:
|
||||||
|
return BggClient(
|
||||||
|
transport=transport,
|
||||||
|
mindestabstand_sekunden=1.0,
|
||||||
|
backoff_basis_sekunden=0.5,
|
||||||
|
backoff_maximum_sekunden=4.0,
|
||||||
|
schlaf=schlaf,
|
||||||
|
uhr=uhr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fortschreitender_schlaf(uhr: FakeUhr, wartezeiten: list[float]):
|
||||||
|
def schlaf(sekunden: float) -> None:
|
||||||
|
wartezeiten.append(sekunden)
|
||||||
|
uhr.zeit += sekunden
|
||||||
|
|
||||||
|
return schlaf
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Parsing ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_suche_nur_brettspiele():
|
||||||
|
treffer = parse_suche(SUCHE_XML)
|
||||||
|
assert [t.bgg_id for t in treffer] == [13, 9999] # Erweiterung raus
|
||||||
|
assert treffer[0].titel == "Catan"
|
||||||
|
assert treffer[0].erscheinungsjahr == 1995
|
||||||
|
assert treffer[1].erscheinungsjahr is None # fehlendes Jahr toleriert
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_suche_umlaute_und_kaputtes_xml():
|
||||||
|
xml = '<items><item type="boardgame" id="1"><name value="Schätze von Trullala"/></item></items>'
|
||||||
|
treffer = parse_suche(xml)
|
||||||
|
assert treffer[0].titel == "Schätze von Trullala"
|
||||||
|
|
||||||
|
with pytest.raises(BggFehler):
|
||||||
|
parse_suche("<items><item></items>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_things_felder_und_erweiterungsfilter():
|
||||||
|
spiele = parse_things(THING_XML)
|
||||||
|
assert len(spiele) == 1 # boardgameexpansion wird verworfen
|
||||||
|
spiel = spiele[0]
|
||||||
|
assert spiel.typ == "boardgame"
|
||||||
|
assert spiel.titel == "Catan"
|
||||||
|
assert spiel.verlag == "KOSMOS, Catan Studio"
|
||||||
|
assert spiel.autor == "Klaus Teuber"
|
||||||
|
assert spiel.erscheinungsjahr == 1995
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_things_leere_und_fehlerhafte_antwort():
|
||||||
|
assert parse_things('<items total="0"/>') == []
|
||||||
|
with pytest.raises(BggFehler):
|
||||||
|
parse_things("kein xml")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- HTTP-Verhalten ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_suche_sendet_type_boardgame():
|
||||||
|
uhr = FakeUhr()
|
||||||
|
transport = AufzeichnenderTransport([_antwort(SUCHE_XML)], uhr)
|
||||||
|
client = _client(transport, uhr, lambda s: None)
|
||||||
|
|
||||||
|
client.suche("catan")
|
||||||
|
|
||||||
|
url = transport.anfragen[0][0]
|
||||||
|
assert "/search" in url
|
||||||
|
assert "type=boardgame" in url
|
||||||
|
assert "query=catan" in url
|
||||||
|
|
||||||
|
|
||||||
|
def test_details_sendet_thing_mit_ids_und_filtert_erweiterungen():
|
||||||
|
uhr = FakeUhr()
|
||||||
|
transport = AufzeichnenderTransport([_antwort(THING_XML)], uhr)
|
||||||
|
client = _client(transport, uhr, lambda s: None)
|
||||||
|
|
||||||
|
spiele = client.details([13, 55])
|
||||||
|
|
||||||
|
url = transport.anfragen[0][0]
|
||||||
|
assert "/thing" in url
|
||||||
|
assert "id=13%2C55" in url or "id=13,55" in url
|
||||||
|
assert "type=boardgame" in url
|
||||||
|
assert [s.bgg_id for s in spiele] == [13]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_mindestens_eine_sekunde_zwischen_requests():
|
||||||
|
uhr = FakeUhr()
|
||||||
|
wartezeiten: list[float] = []
|
||||||
|
transport = AufzeichnenderTransport([_antwort(SUCHE_XML)], uhr)
|
||||||
|
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
|
||||||
|
|
||||||
|
client.suche("a")
|
||||||
|
client.suche("b")
|
||||||
|
|
||||||
|
zeitpunkt_1 = transport.anfragen[0][1]
|
||||||
|
zeitpunkt_2 = transport.anfragen[1][1]
|
||||||
|
assert zeitpunkt_2 - zeitpunkt_1 >= 1.0
|
||||||
|
assert wartezeiten # es wurde aktiv gewartet
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_bei_5xx_mit_backoff_dann_erfolg():
|
||||||
|
uhr = FakeUhr()
|
||||||
|
wartezeiten: list[float] = []
|
||||||
|
transport = AufzeichnenderTransport(
|
||||||
|
[_antwort("server error", status_code=500),
|
||||||
|
_antwort("server error", status_code=500),
|
||||||
|
_antwort(SUCHE_XML)], uhr
|
||||||
|
)
|
||||||
|
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
|
||||||
|
|
||||||
|
treffer = client.suche("catan")
|
||||||
|
|
||||||
|
assert len(treffer) == 2
|
||||||
|
assert len(transport.anfragen) == 3 # 2 × 500, dann Erfolg
|
||||||
|
# Backoff 0,5 s → Rate-Limit-Rest 0,5 s → Backoff 1,0 s:
|
||||||
|
assert wartezeiten == [0.5, 0.5, 1.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_202_wird_gemaess_retry_after_erneut_versucht():
|
||||||
|
uhr = FakeUhr()
|
||||||
|
wartezeiten: list[float] = []
|
||||||
|
transport = AufzeichnenderTransport(
|
||||||
|
[
|
||||||
|
_antwort("queued", status_code=202, headers={"Retry-After": "3"}),
|
||||||
|
_antwort(SUCHE_XML),
|
||||||
|
],
|
||||||
|
uhr,
|
||||||
|
)
|
||||||
|
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
|
||||||
|
|
||||||
|
treffer = client.suche("catan")
|
||||||
|
|
||||||
|
assert len(treffer) == 2
|
||||||
|
assert wartezeiten == [3.0] # Retry-After wird beachtet
|
||||||
|
assert len(transport.anfragen) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_429_ohne_retry_after_nutzt_backoff():
|
||||||
|
uhr = FakeUhr()
|
||||||
|
wartezeiten: list[float] = []
|
||||||
|
transport = AufzeichnenderTransport(
|
||||||
|
[_antwort("zu schnell", status_code=429), _antwort(SUCHE_XML)], uhr
|
||||||
|
)
|
||||||
|
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
|
||||||
|
|
||||||
|
treffer = client.suche("catan")
|
||||||
|
|
||||||
|
assert len(treffer) == 2
|
||||||
|
# Backoff nach 429 plus Rate-Limit-Abstand vor dem zweiten Request:
|
||||||
|
assert wartezeiten == [0.5, 0.5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_netzwerkfehler_wird_mit_backoff_wiederholt():
|
||||||
|
uhr = FakeUhr()
|
||||||
|
wartezeiten: list[float] = []
|
||||||
|
transport = AufzeichnenderTransport(
|
||||||
|
[httpx.ConnectError("Verbindung fehlgeschlagen"), _antwort(SUCHE_XML)], uhr
|
||||||
|
)
|
||||||
|
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
|
||||||
|
|
||||||
|
treffer = client.suche("catan")
|
||||||
|
|
||||||
|
assert len(treffer) == 2
|
||||||
|
assert wartezeiten == [0.5]
|
||||||
|
assert len(transport.anfragen) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_4xx_fuehrt_zu_fehler_ohne_weitere_versuche():
|
||||||
|
uhr = FakeUhr()
|
||||||
|
wartezeiten: list[float] = []
|
||||||
|
transport = AufzeichnenderTransport([_antwort("nope", status_code=404)], uhr)
|
||||||
|
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
|
||||||
|
|
||||||
|
with pytest.raises(BggFehler):
|
||||||
|
client.suche("catan")
|
||||||
|
|
||||||
|
assert len(transport.anfragen) == 1
|
||||||
|
assert wartezeiten == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_versuche_erschoepft_mit_fehler():
|
||||||
|
uhr = FakeUhr()
|
||||||
|
transport = AufzeichnenderTransport([_antwort("kaputt", status_code=500)], uhr)
|
||||||
|
client = BggClient(
|
||||||
|
transport=transport,
|
||||||
|
max_versuche=3,
|
||||||
|
backoff_basis_sekunden=0.1,
|
||||||
|
schlaf=lambda s: uhr.__setattr__("zeit", uhr.zeit + s),
|
||||||
|
uhr=uhr,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(BggFehler):
|
||||||
|
client.suche("catan")
|
||||||
|
|
||||||
|
assert len(transport.anfragen) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_details_batched_ueber_20_ids_pro_request():
|
||||||
|
uhr = FakeUhr()
|
||||||
|
aufgerufene_id_listen: list[list[str]] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
ids = request.url.params["id"].split(",")
|
||||||
|
aufgerufene_id_listen.append(ids)
|
||||||
|
items = "".join(
|
||||||
|
f'<item type="boardgame" id="{i}"><name value="Spiel {i}"/></item>'
|
||||||
|
for i in ids
|
||||||
|
)
|
||||||
|
return _antwort(f"<items>{items}</items>")
|
||||||
|
|
||||||
|
client = BggClient(
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
schlaf=lambda s: None,
|
||||||
|
uhr=uhr,
|
||||||
|
)
|
||||||
|
|
||||||
|
spiele = client.details(list(range(1, 26))) # 25 IDs → 2 Requests
|
||||||
|
|
||||||
|
assert len(spiele) == 25
|
||||||
|
assert [len(batch) for batch in aufgerufene_id_listen] == [20, 5]
|
||||||
238
tests/test_neuheiten_sync.py
Normal file
238
tests/test_neuheiten_sync.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"""Tests für die Sync-Logik: Upsert (Update statt Duplikat), Filter, Robustheit."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from redaktionskern.db import Base
|
||||||
|
|
||||||
|
from tests._neuheiten import (
|
||||||
|
BggFehler,
|
||||||
|
BggSpiel,
|
||||||
|
Neuheit,
|
||||||
|
SuchTreffer,
|
||||||
|
SyncService,
|
||||||
|
ist_moeglicher_prototyp,
|
||||||
|
speichere_oder_aktualisiere,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBggClient:
|
||||||
|
"""Duck-typed Ersatz für BggClient mit festen Ergebnissen."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
suchergebnisse: dict[str, list[SuchTreffer]] | None = None,
|
||||||
|
details_pro_id: dict[int, BggSpiel] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.suchergebnisse = suchergebnisse or {}
|
||||||
|
self.details_pro_id = details_pro_id or {}
|
||||||
|
self.suche_aufrufe: list[str] = []
|
||||||
|
self.details_aufrufe: list[list[int]] = []
|
||||||
|
|
||||||
|
def suche(self, suchbegriff: str, *, max_treffer: int | None = None):
|
||||||
|
self.suche_aufrufe.append(suchbegriff)
|
||||||
|
treffer = list(self.suchergebnisse.get(suchbegriff, []))
|
||||||
|
return treffer[:max_treffer] if max_treffer is not None else treffer
|
||||||
|
|
||||||
|
def details(self, bgg_ids):
|
||||||
|
self.details_aufrufe.append(list(bgg_ids))
|
||||||
|
return [self.details_pro_id[i] for i in bgg_ids if i in self.details_pro_id]
|
||||||
|
|
||||||
|
|
||||||
|
@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 _spiel(bgg_id: int = 13, titel: str = "Catan", **aenderungen) -> BggSpiel:
|
||||||
|
basis = dict(
|
||||||
|
bgg_id=bgg_id,
|
||||||
|
titel=titel,
|
||||||
|
verlag="KOSMOS",
|
||||||
|
autor="Klaus Teuber",
|
||||||
|
erscheinungsjahr=1995,
|
||||||
|
typ="boardgame",
|
||||||
|
)
|
||||||
|
basis.update(aenderungen)
|
||||||
|
return BggSpiel(**basis)
|
||||||
|
|
||||||
|
|
||||||
|
def _alle_eintraege(session_factory) -> list[Neuheit]:
|
||||||
|
with session_factory() as db:
|
||||||
|
return list(db.scalars(select(Neuheit).order_by(Neuheit.bgg_id)).all())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Update statt Duplikat ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_reicht_trefferlimit_weiter(session_factory):
|
||||||
|
client = FakeBggClient(
|
||||||
|
suchergebnisse={
|
||||||
|
"viel": [SuchTreffer(i, f"Spiel {i}", None) for i in range(100)]
|
||||||
|
},
|
||||||
|
details_pro_id={},
|
||||||
|
)
|
||||||
|
service = SyncService(session_factory, client, max_treffer_pro_suchbegriff=7)
|
||||||
|
|
||||||
|
ergebnis = service.synchronisiere(["viel"])
|
||||||
|
|
||||||
|
# Das Limit begrenzt die Trefferliste vor der Detailabfrage:
|
||||||
|
assert len(client.details_aufrufe[0]) <= 20 # Batch-Größe bleibt intakt
|
||||||
|
assert sum(len(b) for b in client.details_aufrufe) == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_legt_eintraege_an(session_factory):
|
||||||
|
client = FakeBggClient(
|
||||||
|
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
|
||||||
|
details_pro_id={13: _spiel()},
|
||||||
|
)
|
||||||
|
service = SyncService(session_factory, client)
|
||||||
|
|
||||||
|
ergebnis = service.synchronisiere(["catan"])
|
||||||
|
|
||||||
|
assert ergebnis.neu == 1
|
||||||
|
assert ergebnis.aktualisiert == 0
|
||||||
|
eintraege = _alle_eintraege(session_factory)
|
||||||
|
assert len(eintraege) == 1
|
||||||
|
eintrag = eintraege[0]
|
||||||
|
assert eintrag.titel == "Catan"
|
||||||
|
assert eintrag.verlag == "KOSMOS"
|
||||||
|
assert eintrag.autor == "Klaus Teuber"
|
||||||
|
assert eintrag.erscheinungsjahr == 1995
|
||||||
|
assert eintrag.bgg_id == 13
|
||||||
|
assert eintrag.status == "neuheit"
|
||||||
|
assert eintrag.quelle == "boardgamegeek"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_aktualisiert_bestehende_eintraege_statt_duplikate(session_factory):
|
||||||
|
client = FakeBggClient(
|
||||||
|
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
|
||||||
|
details_pro_id={13: _spiel()},
|
||||||
|
)
|
||||||
|
service = SyncService(session_factory, client)
|
||||||
|
|
||||||
|
service.synchronisiere(["catan"])
|
||||||
|
|
||||||
|
# Zweiter Lauf mit geänderten Daten (z. B. korrigiertes Jahr, neuer Verlag)
|
||||||
|
client.details_pro_id[13] = _spiel(
|
||||||
|
verlag="Catan Studio", erscheinungsjahr=2020
|
||||||
|
)
|
||||||
|
ergebnis = service.synchronisiere(["catan"])
|
||||||
|
|
||||||
|
assert ergebnis.neu == 0
|
||||||
|
assert ergebnis.aktualisiert == 1
|
||||||
|
eintraege = _alle_eintraege(session_factory)
|
||||||
|
assert len(eintraege) == 1, "Update statt Duplikat verletzt"
|
||||||
|
eintrag = eintraege[0]
|
||||||
|
assert eintrag.verlag == "Catan Studio"
|
||||||
|
assert eintrag.erscheinungsjahr == 2020
|
||||||
|
assert eintrag.status == "neuheit", "Status darf beim Update nicht verloren gehen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_veraenderter_titel_gleiche_bgg_id_kein_duplikat(session_factory):
|
||||||
|
client = FakeBggClient(
|
||||||
|
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
|
||||||
|
details_pro_id={13: _spiel()},
|
||||||
|
)
|
||||||
|
service = SyncService(session_factory, client)
|
||||||
|
service.synchronisiere(["catan"])
|
||||||
|
|
||||||
|
client.details_pro_id[13] = _spiel(titel="Catan (Neuausgabe)")
|
||||||
|
service.synchronisiere(["catan"])
|
||||||
|
|
||||||
|
eintraege = _alle_eintraege(session_factory)
|
||||||
|
assert len(eintraege) == 1
|
||||||
|
assert eintraege[0].titel == "Catan (Neuausgabe)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_speichere_oder_aktualisiere_rückgabe_werte(session_factory):
|
||||||
|
with session_factory() as db:
|
||||||
|
assert speichere_oder_aktualisiere(db, _spiel()) is True
|
||||||
|
db.flush()
|
||||||
|
assert speichere_oder_aktualisiere(db, _spiel(erscheinungsjahr=1996)) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Filter: Erweiterungen & Prototypen ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_filtert_erweiterungen(session_factory):
|
||||||
|
client = FakeBggClient(
|
||||||
|
suchergebnisse={
|
||||||
|
"catan": [
|
||||||
|
SuchTreffer(13, "Catan", 1995),
|
||||||
|
SuchTreffer(55, "Catan: Erweiterung", 1996),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
details_pro_id={
|
||||||
|
13: _spiel(),
|
||||||
|
55: _spiel(55, "Catan: Erweiterung", typ="boardgameexpansion"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
service = SyncService(session_factory, client)
|
||||||
|
|
||||||
|
ergebnis = service.synchronisiere(["catan"])
|
||||||
|
|
||||||
|
assert ergebnis.gefiltert == 1
|
||||||
|
eintraege = _alle_eintraege(session_factory)
|
||||||
|
assert [e.bgg_id for e in eintraege] == [13]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_filtert_prototypen(session_factory):
|
||||||
|
client = FakeBggClient(
|
||||||
|
suchergebnisse={
|
||||||
|
"essen": [
|
||||||
|
SuchTreffer(13, "Catan", 1995),
|
||||||
|
SuchTreffer(77, "Fabrik — Prototyp 2026", None),
|
||||||
|
SuchTreffer(78, "My Prototype Game", None),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
details_pro_id={
|
||||||
|
13: _spiel(),
|
||||||
|
77: _spiel(77, "Fabrik — Prototyp 2026"),
|
||||||
|
78: _spiel(78, "My Prototype Game"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
service = SyncService(session_factory, client)
|
||||||
|
|
||||||
|
ergebnis = service.synchronisiere(["essen"])
|
||||||
|
|
||||||
|
assert ergebnis.gefiltert == 2
|
||||||
|
assert [e.bgg_id for e in _alle_eintraege(session_factory)] == [13]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prototyp_heuristik():
|
||||||
|
assert ist_moeglicher_prototyp("Ein Prototyp von etwas")
|
||||||
|
assert ist_moeglicher_prototyp("Prototype X")
|
||||||
|
assert ist_moeglicher_prototyp("PROTOTYPISCHES Spiel")
|
||||||
|
assert not ist_moeglicher_prototyp("Catan")
|
||||||
|
assert not ist_moeglicher_prototyp("Die Burgen von Burgund")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Robustheit ----------
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
if suchbegriff == "kaputt":
|
||||||
|
raise BggFehler("BGG nicht erreichbar")
|
||||||
|
return super().suche(suchbegriff, max_treffer=max_treffer)
|
||||||
|
|
||||||
|
client = HalbKaputterClient(
|
||||||
|
suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]},
|
||||||
|
details_pro_id={13: _spiel()},
|
||||||
|
)
|
||||||
|
service = SyncService(session_factory, client)
|
||||||
|
|
||||||
|
ergebnis = service.synchronisiere(["kaputt", "gut"])
|
||||||
|
|
||||||
|
assert len(ergebnis.fehler) == 1
|
||||||
|
assert "kaputt" in ergebnis.fehler[0]
|
||||||
|
assert ergebnis.neu == 1
|
||||||
|
assert len(_alle_eintraege(session_factory)) == 1
|
||||||
241
tests/test_neuheiten_ui.py
Normal file
241
tests/test_neuheiten_ui.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""Tests für die Neuheiten-UI: Rollen, Liste, Suche/Filter/Sortierung, Sync-Button.
|
||||||
|
|
||||||
|
Alle BGG-Antworten sind gemockt (FakeBggClient), kein echter Netzwerkverkehr.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from redaktionskern.app import create_app
|
||||||
|
|
||||||
|
from tests._neuheiten import BggSpiel, Neuheit, SuchTreffer
|
||||||
|
|
||||||
|
from tests.conftest import ADMIN_PASSWORD, lege_benutzer_an, melde_an
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBggClient:
|
||||||
|
"""Achtung: Attribut heißt bewusst NICHT `details`, sonst würde es die
|
||||||
|
gleichnamige Methode verdecken."""
|
||||||
|
|
||||||
|
def __init__(self, treffer=None, spiel_details=None):
|
||||||
|
self.treffer = treffer or []
|
||||||
|
self.spiel_details = spiel_details or []
|
||||||
|
self.suche_aufrufe: list[str] = []
|
||||||
|
|
||||||
|
def suche(self, suchbegriff: str, *, max_treffer: int | None = None):
|
||||||
|
self.suche_aufrufe.append(suchbegriff)
|
||||||
|
return list(self.treffer)
|
||||||
|
|
||||||
|
def details(self, bgg_ids):
|
||||||
|
return list(self.spiel_details)
|
||||||
|
|
||||||
|
def schliessen(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
monkeypatch.setattr(plugin, "_neuer_client", lambda: fake)
|
||||||
|
|
||||||
|
|
||||||
|
def test_neuheiten_erfordert_anmeldung(client):
|
||||||
|
antwort = client.get("/neuheiten", follow_redirects=False)
|
||||||
|
assert antwort.status_code == 303
|
||||||
|
assert "/login" in antwort.headers["location"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_liste_zeigt_eintraege_und_deutsche_beschriftung(app, client):
|
||||||
|
with app.state.session_factory() as db:
|
||||||
|
db.add(
|
||||||
|
Neuheit(
|
||||||
|
titel="Catan",
|
||||||
|
verlag="KOSMOS",
|
||||||
|
autor="Klaus Teuber",
|
||||||
|
erscheinungsjahr=1995,
|
||||||
|
bgg_id=13,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
melde_an(client)
|
||||||
|
antwort = client.get("/neuheiten")
|
||||||
|
|
||||||
|
assert antwort.status_code == 200
|
||||||
|
for text in (
|
||||||
|
"Spieltitel", "Verlag", "Autor", "Erscheinungsjahr", "Status",
|
||||||
|
"Catan", "KOSMOS", "Klaus Teuber", "1995", "1 Einträge",
|
||||||
|
):
|
||||||
|
assert text in antwort.text, f"Fehlt in der Seite: {text!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_leere_liste_hat_hinweis(client):
|
||||||
|
melde_an(client)
|
||||||
|
antwort = client.get("/neuheiten")
|
||||||
|
assert antwort.status_code == 200
|
||||||
|
assert "Keine Einträge gefunden" in antwort.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_suche_filtert_nach_titel_verlag_autor(app, client):
|
||||||
|
with app.state.session_factory() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Neuheit(titel="Catan", verlag="KOSMOS", autor="Klaus Teuber", bgg_id=13),
|
||||||
|
Neuheit(titel="Azul", verlag="Next Move Games", autor="Michael Kiesling", bgg_id=2),
|
||||||
|
Neuheit(titel="Ticket to Ride", verlag="Days of Wonder", autor="Alan R. Moon", bgg_id=9),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
melde_an(client)
|
||||||
|
assert "Catan" in client.get("/neuheiten?q=catan").text
|
||||||
|
assert "Azul" not in client.get("/neuheiten?q=catan").text
|
||||||
|
assert "Ticket to Ride" in client.get("/neuheiten?q=wonder").text # Verlag-Treffer
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_filter(app, client):
|
||||||
|
with app.state.session_factory() as db:
|
||||||
|
db.add(Neuheit(titel="Catan", bgg_id=13, status="neuheit"))
|
||||||
|
db.add(Neuheit(titel="Azul", bgg_id=2, status="planung"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
melde_an(client)
|
||||||
|
seite = client.get("/neuheiten?status=planung").text
|
||||||
|
assert "Azul" in seite
|
||||||
|
assert ">Catan<" not in seite
|
||||||
|
|
||||||
|
|
||||||
|
def test_sortierung_nach_jahr_abwaerts(app, client):
|
||||||
|
with app.state.session_factory() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Neuheit(titel="Alt", bgg_id=1, erscheinungsjahr=1990),
|
||||||
|
Neuheit(titel="Neu", bgg_id=2, erscheinungsjahr=2026),
|
||||||
|
Neuheit(titel="Mittel", bgg_id=3, erscheinungsjahr=2010),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
melde_an(client)
|
||||||
|
antwort = client.get("/neuheiten?sort=jahr&richtung=ab")
|
||||||
|
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_unbekannte_sortierung_faellt_auf_standard_zurueck(client):
|
||||||
|
melde_an(client)
|
||||||
|
antwort = client.get("/neuheiten?sort=hack&richtung=auf")
|
||||||
|
assert antwort.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_button_nur_fuer_redaktion_sichtbar(app, client):
|
||||||
|
lege_benutzer_an(app, "rezensentin", role="rezensent")
|
||||||
|
|
||||||
|
melde_an(client)
|
||||||
|
admin_seite = client.get("/neuheiten").text
|
||||||
|
assert "Jetzt synchronisieren" in admin_seite
|
||||||
|
|
||||||
|
client.post("/logout")
|
||||||
|
melde_an(client, "rezensentin", "test-12345678")
|
||||||
|
rezensent_seite = client.get("/neuheiten").text
|
||||||
|
assert "Jetzt synchronisieren" not in rezensent_seite
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_endpunkt_rollen_pruefung(app, settings):
|
||||||
|
lege_benutzer_an(app, "rezensentin", role="rezensent")
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
with TestClient(create_app(settings)) as client:
|
||||||
|
melde_an(client, "rezensentin", "test-12345678")
|
||||||
|
antwort = client.post("/neuheiten/sync", follow_redirects=False)
|
||||||
|
assert antwort.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_manueller_sync_legt_eintraege_an(monkeypatch, settings):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
fake = FakeBggClient(
|
||||||
|
treffer=[SuchTreffer(13, "Catan", 1995)],
|
||||||
|
spiel_details=[
|
||||||
|
BggSpiel(bgg_id=13, titel="Catan", verlag="KOSMOS", autor="Klaus Teuber",
|
||||||
|
erscheinungsjahr=1995, typ="boardgame"),
|
||||||
|
BggSpiel(bgg_id=55, titel="Catan: Erweiterung", verlag=None, autor=None,
|
||||||
|
erscheinungsjahr=1996, typ="boardgameexpansion"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
assert "/neuheiten" in antwort.headers["location"]
|
||||||
|
# Meldung über Sync-Ergebnis:
|
||||||
|
folge = client.get(antwort.headers["location"])
|
||||||
|
assert "Sync abgeschlossen" in folge.text
|
||||||
|
assert "1 neu" in folge.text
|
||||||
|
|
||||||
|
with client.app.state.session_factory() as db:
|
||||||
|
eintraege = db.query(Neuheit).all()
|
||||||
|
assert [e.bgg_id for e in eintraege] == [13] # Erweiterung gefiltert
|
||||||
|
|
||||||
|
assert fake.suche_aufrufe == ["catan"] # expliziter Begriff statt Standard
|
||||||
|
|
||||||
|
|
||||||
|
def test_manueller_sync_ohne_suchbegriff_nutzt_konfigurierte_begriffe(monkeypatch, settings):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
monkeypatch.setenv("SPIELE_BGG_SUCHBEGRIFFE", "essen, familien")
|
||||||
|
fake = FakeBggClient()
|
||||||
|
with TestClient(create_app(settings)) as client:
|
||||||
|
melde_an(client)
|
||||||
|
_client_mit_fakes(monkeypatch, client, fake)
|
||||||
|
client.post("/neuheiten/sync", data={"suchbegriff": ""})
|
||||||
|
assert fake.suche_aufrufe == ["essen", "familien"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_htmx_request_liefert_nur_teilfragment(app, client):
|
||||||
|
melde_an(client)
|
||||||
|
antwort = client.get("/neuheiten", headers={"HX-Request": "true"})
|
||||||
|
assert antwort.status_code == 200
|
||||||
|
assert "neuheiten-liste" in antwort.text # Fragment-Anker vorhanden
|
||||||
|
assert "<html" not in antwort.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_standardmaessig_deaktiviert_in_tests(settings):
|
||||||
|
app = create_app(settings)
|
||||||
|
plugin = app.state.registry.get("neuheiten")
|
||||||
|
assert plugin._scheduler is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_laesst_sich_aktivieren(monkeypatch, settings):
|
||||||
|
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "1")
|
||||||
|
monkeypatch.setenv("SPIELE_BGG_SYNC_INTERVALL_STUNDEN", "24")
|
||||||
|
|
||||||
|
app = create_app(settings)
|
||||||
|
plugin = app.state.registry.get("neuheiten")
|
||||||
|
with TestClient(app):
|
||||||
|
assert plugin._scheduler is not None
|
||||||
|
assert plugin._scheduler.running
|
||||||
|
job = plugin._scheduler.get_job("bgg-neuheiten-sync")
|
||||||
|
assert job is not None
|
||||||
|
|
||||||
|
assert plugin._scheduler is None # on_unload hat ihn gestoppt
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_legt_tabelle_an(app):
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
inspektion = inspect(app.state.engine)
|
||||||
|
spalten = {c["name"] for c in inspektion.get_columns("neuheiten")}
|
||||||
|
assert {
|
||||||
|
"id", "titel", "verlag", "autor", "erscheinungsjahr",
|
||||||
|
"bgg_id", "status", "quelle", "erstellt_am", "aktualisiert_am",
|
||||||
|
} <= spalten
|
||||||
37
uv.lock
generated
37
uv.lock
generated
@@ -37,6 +37,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "apscheduler"
|
||||||
|
version = "3.11.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "tzlocal" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/6b/eeff360196bb20b312c9e762a820fd1b2c6d809466c755ef57863478e454/apscheduler-3.11.3.tar.gz", hash = "sha256:cd2fcc9330039a81a5893472ad49facf23a6d5604cbe1d918c835c6de7834d5a", size = 110312, upload-time = "2026-06-28T19:39:22.493Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/c9/8638db32514dbb9157b3d82680c6faea89283523edf9ed2415ea3884f2ae/apscheduler-3.11.3-py3-none-any.whl", hash = "sha256:bbeb2ec02d23d3c06a6c07ed7f0f3939ada6680eb121fae809a69bb42c537a30", size = 66024, upload-time = "2026-06-28T19:39:20.982Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "argon2-cffi"
|
name = "argon2-cffi"
|
||||||
version = "25.1.0"
|
version = "25.1.0"
|
||||||
@@ -743,8 +755,10 @@ name = "spiele-redaktion"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "apscheduler" },
|
||||||
{ name = "argon2-cffi" },
|
{ name = "argon2-cffi" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "itsdangerous" },
|
{ name = "itsdangerous" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
@@ -760,8 +774,10 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "apscheduler", specifier = ">=3.11,<4" },
|
||||||
{ name = "argon2-cffi", specifier = ">=23.1" },
|
{ name = "argon2-cffi", specifier = ">=23.1" },
|
||||||
{ name = "fastapi", specifier = ">=0.115" },
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
{ name = "itsdangerous", specifier = ">=2.1" },
|
{ name = "itsdangerous", specifier = ">=2.1" },
|
||||||
{ name = "jinja2", specifier = ">=3.1" },
|
{ name = "jinja2", specifier = ">=3.1" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.9" },
|
{ name = "python-multipart", specifier = ">=0.0.9" },
|
||||||
@@ -851,6 +867,27 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzdata"
|
||||||
|
version = "2026.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzlocal"
|
||||||
|
version = "5.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/81/5b/879b2f932adfa7a053c360d50bc896c977fa6426109185f7c12ebdd0cb9d/tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4", size = 31170, upload-time = "2026-06-29T08:03:40.026Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.52.4"
|
version = "0.52.4"
|
||||||
|
|||||||
Reference in New Issue
Block a user