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:
@@ -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()
|
||||
|
||||
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 inhalt %}
|
||||
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
||||
<p class="text-slate-600 max-w-2xl">
|
||||
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
|
||||
in Version {{ version }} ist geladen.
|
||||
Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert.
|
||||
<p class="text-slate-600 mb-4">
|
||||
Neuheitenliste aus der <a href="https://boardgamegeek.com" class="text-emerald-700 underline">BoardGameGeek</a>-Datenbank.
|
||||
Erweiterungen und Prototypen werden beim Sync automatisch gefiltert.
|
||||
</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 %}
|
||||
|
||||
Reference in New Issue
Block a user