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.
This commit is contained in:
104
plugins/dedup/bgg.py
Normal file
104
plugins/dedup/bgg.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user