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:
ox-alpha
2026-08-21 19:10:43 +00:00
parent 83d574d95c
commit 56cc5943b1
16 changed files with 1732 additions and 28 deletions

133
plugins/neuheiten/sync.py Normal file
View 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]