diff --git a/AGENTS.md b/AGENTS.md index 721484f..19208e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. ## Status -Kern mit Plugin-System/Auth/Migrationen fertig; `audit-log` vollständig -implementiert; `neuheiten`/`benachrichtigung` in Arbeit; übrige Plugins Stubs. +Kern mit Plugin-System/Auth/Migrationen fertig; `audit-log`, +`benachrichtigung` und `neuheiten` (BGG-Sync) vollständig implementiert; +übrige Plugins Stubs. Live-Status: README.md → „Projekt-Fortschritt“. ## Struktur diff --git a/README.md b/README.md index 6483bab..dae6941 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ **KI-Assistenz für Spielemagazin-Redaktionen** — Multi-User-Webanwendung mit modularer Plugin-Architektur. Lauffähiger Kern mit Plugin-System, -Authentifizierung/Rollen und Migrationen; als erstes Fachplugin ist das -**Audit-Log** vollständig implementiert, weitere Plugins folgen. +Authentifizierung/Rollen und Migrationen; vollständig implementiert sind +bisher **Audit-Log**, **Benachrichtigung** und **Neuheiten (BGG-Sync)**. ## Stack @@ -12,6 +12,7 @@ Authentifizierung/Rollen und Migrationen; als erstes Fachplugin ist das - **Auth:** Session-Cookies (signiert), Passwort-Hashing mit Argon2id - **Plugins:** Entry-Points (`importlib.metadata`, Gruppe `spiele_redaktion.plugins`) plus lokales `plugins/`-Verzeichnis +- **Hintergrund-Jobs:** APScheduler (BGG-Neuheiten-Sync) - **Dependencies/Tests:** [uv](https://docs.astral.sh/uv/) + pytest ## 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_TLS` | `starttls` | `starttls`, `ssl` oder `keine` | | `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 @@ -59,7 +64,10 @@ Abgedeckt: Plugin-Loader lädt alle Plugins (inkl. Lifecycle-Hooks und Entry-Point-Pfad), Migrations-Laufzeit inkl. Idempotenz, Login/Logout, Rollen-Zugriff (admin/redakteur vs. rezensent), Benutzerverwaltung sowie das 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 @@ -75,7 +83,8 @@ src/redaktionskern/ schlanker Kern — KEINE Fachlogik plugins/ ein Ordner pro Funktion, ladbar über den Plugin-Loader ├── 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) └── … je __init__.py + templates// ``` @@ -196,6 +205,70 @@ bericht = await plugin.send_notification(user, "Titel", "Nachrichtentext", "kate einzelne Kanalausfälle werden protokolliert und im Bericht gemeldet. Die 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) 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) | | 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 | -| 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 | | 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ⏳ 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) - 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) - Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets diff --git a/plugins/neuheiten/__init__.py b/plugins/neuheiten/__init__.py index ae75024..5cb39c2 100644 --- a/plugins/neuheiten/__init__.py +++ b/plugins/neuheiten/__init__.py @@ -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: -eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle. +Umfang: +- 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 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 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 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): name = "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: 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") - def seite(request: Request, user: User = Depends(require_user)): - """Platzhalterseite des Plugins.""" + def seite( + 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( request=request, name="neuheiten/index.html", - context={ - "user": user, - "titel": self.title, - "name": self.name, - "version": self.version, - }, + context=kontext, ) + @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]: 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() diff --git a/plugins/neuheiten/bgg.py b/plugins/neuheiten/bgg.py new file mode 100644 index 0000000..f3cbc1b --- /dev/null +++ b/plugins/neuheiten/bgg.py @@ -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 `` 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 `` 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 diff --git a/plugins/neuheiten/models.py b/plugins/neuheiten/models.py new file mode 100644 index 0000000..04bdc34 --- /dev/null +++ b/plugins/neuheiten/models.py @@ -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"" diff --git a/plugins/neuheiten/sync.py b/plugins/neuheiten/sync.py new file mode 100644 index 0000000..ee1b544 --- /dev/null +++ b/plugins/neuheiten/sync.py @@ -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] diff --git a/plugins/neuheiten/templates/neuheiten/_liste.html b/plugins/neuheiten/templates/neuheiten/_liste.html new file mode 100644 index 0000000..685bf2f --- /dev/null +++ b/plugins/neuheiten/templates/neuheiten/_liste.html @@ -0,0 +1,49 @@ +
+

{{ anzahl }} Einträge

+
+ + + + {% 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')] %} + + {% endfor %} + + + + {% for eintrag in eintraege %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
+ {% if sort == key %} + {{ label }} {{ '▲' if richtung == 'auf' else '▼' }} + {% else %} + {{ label }} + {% endif %} +
+ {{ eintrag.titel }} + BGG {{ eintrag.bgg_id }} + {{ eintrag.verlag or '—' }}{{ eintrag.autor or '—' }}{{ eintrag.erscheinungsjahr if eintrag.erscheinungsjahr else '—' }} + {{ eintrag.status }} + + {{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }} +
+ Keine Einträge gefunden. + {% if ist_redaktion %}Nutze „Jetzt synchronisieren“, um die Liste aus BoardGameGeek zu befüllen.{% endif %} +
+
+
diff --git a/plugins/neuheiten/templates/neuheiten/index.html b/plugins/neuheiten/templates/neuheiten/index.html index cc09663..a27ad2a 100644 --- a/plugins/neuheiten/templates/neuheiten/index.html +++ b/plugins/neuheiten/templates/neuheiten/index.html @@ -2,9 +2,58 @@ {% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %} {% block inhalt %}

{{ titel }}

-

- Plugin {{ name }} - in Version {{ version }} ist geladen. - Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert. +

+ Neuheitenliste aus der BoardGameGeek-Datenbank. + Erweiterungen und Prototypen werden beim Sync automatisch gefiltert.

+ +{% if meldung %} +
+ {{ meldung }} +
+{% endif %} + +
+ {% if ist_redaktion %} +
+
+ + +
+ +
+ {% endif %} +

+ Hintergrund-Sync: + {% if sync_aktiv %}aktiv{% else %}aus{% endif %} + · Suchbegriffe: {{ suchbegriffe }} +

+
+ +
+ + + +
+ +{% include "neuheiten/_liste.html" %} {% endblock %} diff --git a/pyproject.toml b/pyproject.toml index acdd610..3b8e72b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,8 @@ dependencies = [ "python-multipart>=0.0.9", "itsdangerous>=2.1", "argon2-cffi>=23.1", + "apscheduler>=3.11,<4", + "httpx>=0.27", ] [dependency-groups] diff --git a/src/redaktionskern/plugin_loader.py b/src/redaktionskern/plugin_loader.py index b49afa3..3f36712 100644 --- a/src/redaktionskern/plugin_loader.py +++ b/src/redaktionskern/plugin_loader.py @@ -61,6 +61,20 @@ def _aus_verzeichnis(verzeichnis: Path) -> list[BasePlugin]: if not init.is_file(): continue 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( modulname, init, submodule_search_locations=[str(eintrag)] ) diff --git a/tests/_neuheiten.py b/tests/_neuheiten.py new file mode 100644 index 0000000..02c99b8 --- /dev/null +++ b/tests/_neuheiten.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index f8e0263..cab519d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,16 @@ PROJEKT_WURZEL = Path(__file__).resolve().parents[1] 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 def settings(tmp_path) -> Settings: return Settings( diff --git a/tests/test_neuheiten_bgg.py b/tests/test_neuheiten_bgg.py new file mode 100644 index 0000000..7a47502 --- /dev/null +++ b/tests/test_neuheiten_bgg.py @@ -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 = """ + + + + + + + + + + + + + +""" + +THING_XML = """ + + + https://example.org/thumb.jpg + + + + + + + + + + + +""" + + +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 = '' + treffer = parse_suche(xml) + assert treffer[0].titel == "Schätze von Trullala" + + with pytest.raises(BggFehler): + parse_suche("") + + +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('') == [] + 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'' + for i in ids + ) + return _antwort(f"{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] diff --git a/tests/test_neuheiten_sync.py b/tests/test_neuheiten_sync.py new file mode 100644 index 0000000..8d1da49 --- /dev/null +++ b/tests/test_neuheiten_sync.py @@ -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 diff --git a/tests/test_neuheiten_ui.py b/tests/test_neuheiten_ui.py new file mode 100644 index 0000000..637a510 --- /dev/null +++ b/tests/test_neuheiten_ui.py @@ -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 "