Files
spiele-redaktion/plugins/dedup/bgg.py
Flo Hartmann 0f12cd952b Plugins dedup + planung: Prüf-API, Verzahnung mit Benachrichtigung und Audit-Log
Plugin dedup:
- Öffentliche Prüf-API check_titel(titel, verlag, bgg_id, user) mit
  strukturiertem PruefErgebnis (Konflikte, Verlagsauswahl, Titel-Empfehlung)
- Vier Prüfungen: Verlags-Konflikt, Titel-Varianten (deutsche Version
  bevorzugt: Umlaut-/Wort-Heuristik + BGG Alternate-Names), Spiel oder
  Vorgänger bereits besprochen (BGG boardgameexpansion-Relation +
  rapidfuzz-Fuzzy-Match), Titel in fremder Planungsliste
- BGG-Hilfsclient mit Rate-Limit, netzwerkfrei testbar, degradiert defensiv
- Eigene Migration (Prüfprotokoll dedup_pruefungen) + Prüfseite /dedup
- Fremde Plugin-Tabellen werden nur lesend über die gemeinsame Metadata
  gelesen — keine Import-Abhängigkeiten zwischen Plugins

Plugin planung:
- Migration planungsliste: Titel, Verlag, Ausgabe, Rezensent, Status
  (offen/in_bearbeitung/abgeschlossen), Quelle, Prüf-Befund (JSON)
- Beide Eintragswege durch die dedup-Prüfung: Verschiebung aus den
  Neuheiten (Button „→ Zur Planung“, Neuheit wechselt auf Status planung)
  und händisches Nachtragen im Formular
- Bei Treffern: send_notification an den eintragenden Rezensenten
  (benachrichtigung-Plugin) + Audit-Log-Eintrag (audit-log-Plugin)
- Verlags-Konflikt → Auswahl-Dialog „Welcher Verlag wird geführt?“,
  Entscheidung wird auditiert
- Planungsliste mit Zuordnung, Statuswechsel, Bearbeiten/Löschen;
  Rezensenten nur eigene Einträge, Admin/Redakteur alle

Tests: 45 neue Tests (Heuristik, alle vier Prüfungen mit Fake-BGG-Client,
Protokoll, beide Eintragswege mit Stub-gemockten Abhängigkeiten, Rollen,
Integration mit echten Plugins) — 141 Tests grün.
2026-08-21 19:51:53 +00:00

105 lines
3.8 KiB
Python

"""Hilfsclient des Plugins „dedup“ für die BoardGameGeek XML API2.
Besorgt zwei Zusatzinformationen für die Prüfung:
- Alternate-Names eines Spiels (für die deutsche Titel-Empfehlung),
- die `boardgameexpansion`-Relation — welches Basisspiel eine Erweiterung
ergänzt (für die Vorgänger-Erkennung).
Der Client ist bewusst defensiv: Fehlt das Netzwerk oder liefert BGG
Unsinn, geben die Methoden leere Listen zurück und die Prüfung fällt auf
die reine Titel-Heuristik zurück, statt den Eintrage-Vorgang zu blockieren.
Rate-Limit wie im Neuheiten-Plugin: mindestens 1 Sekunde zwischen Requests;
Uhr, Schlaf-Funktion und HTTP-Transport sind injizierbar, damit Tests
netzwerkfrei bleiben.
"""
from __future__ import annotations
import logging
import time
import xml.etree.ElementTree as ET
from collections.abc import Callable
import httpx
_logger = logging.getLogger("plugins.dedup")
BASIS_URL = "https://boardgamegeek.com/xmlapi2"
class BggPruefClient:
"""Liest Alternate-Names und Erweiterungs-Relationen von BGG."""
def __init__(
self,
mindestabstand_sekunden: float = 1.0,
*,
transport: httpx.BaseTransport | None = None,
uhr: Callable[[], float] = time.monotonic,
schlafen: Callable[[float], None] = time.sleep,
timeout: float = 10.0,
) -> None:
self._mindestabstand = max(0.0, mindestabstand_sekunden)
self._zuletzt: float | None = None
self._uhr = uhr
self._schlafen = schlafen
self._client = httpx.Client(
base_url=BASIS_URL, timeout=timeout, transport=transport
)
def schliessen(self) -> None:
self._client.close()
# ---------- Internas ----------
def _hole(self, pfad: str, parameter: dict[str, str]) -> ET.Element | None:
"""Ein GET mit Rate-Limit; bei jedem Fehler None (Prüfung degradiert)."""
if self._zuletzt is not None:
vergangen = self._uhr() - self._zuletzt
if vergangen < self._mindestabstand:
self._schlafen(self._mindestabstand - vergangen)
self._zuletzt = self._uhr()
try:
antwort = self._client.get(pfad, params=parameter)
antwort.raise_for_status()
return ET.fromstring(antwort.content)
except Exception as exc:
_logger.warning("BGG-Hilfsabruf fehlgeschlagen (%s %s): %s", pfad, parameter, exc)
return None
# ---------- Öffentliche API ----------
def alternativen(self, bgg_id: int) -> list[str]:
"""Alternate-Names des Spiels laut BGG (leer bei Problemen)."""
wurzel = self._hole("/thing", {"id": str(bgg_id), "type": "boardgame"})
namen: list[str] = []
if wurzel is None:
return namen
for element in wurzel.iter("name"):
if element.get("type") == "alternate":
wert = (element.get("value") or "").strip()
if wert:
namen.append(wert)
return namen
def vorgaenger(self, bgg_id: int) -> list[tuple[int, str]]:
"""Basisspiele, die dieses Spiel ergänzt — als (bgg_id, titel).
Die XML API2 listet auf der Seite einer Erweiterung die Links vom
Typ `boardgameexpansion` auf das jeweils ergänzte Basisspiel.
"""
wurzel = self._hole("/thing", {"id": str(bgg_id), "type": "boardgame"})
ergebnis: list[tuple[int, str]] = []
if wurzel is None:
return ergebnis
for element in wurzel.iter("link"):
if element.get("type") != "boardgameexpansion":
continue
try:
fremd_id = int(element.get("id") or "")
except ValueError:
continue
titel = (element.get("value") or "").strip()
if titel:
ergebnis.append((fremd_id, titel))
return ergebnis