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:
Flo Hartmann
2026-08-21 19:51:53 +00:00
parent 56cc5943b1
commit 0f12cd952b
21 changed files with 2838 additions and 53 deletions

View File

@@ -1,41 +1,221 @@
"""Plugin „dedup“ — Platzhalter gemäß Plugin-Vertrag.
"""Plugin „dedup“ — Prüfungen bei Import und händischem Eintrag.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
Öffentliche API für andere Plugins (z. B. „planung“):
dedup = request.app.state.registry.get("dedup")
ergebnis = await dedup.check_titel(titel, verlag, bgg_id, user=user)
# ergebnis.hat_konflikte / ergebnis.konflikte / ergebnis.verlags_optionen
# ergebnis.titel_empfehlung / ergebnis.als_text()
Die vier Prüfungen (Verlags-Konflikt, deutsche Titel-Variante, bereits
besprochen inkl. Vorgänger-Erkennung, schon in fremder Planungsliste) sind
in `pruefung.py` beschrieben. Jede Prüfung wird in der eigenen Tabelle
`dedup_pruefungen` protokolliert.
Zusatzdaten von BoardGameGeek (Alternate-Names, Erweiterungs-Relationen)
können über SPIELE_DEDUP_BGG_AKTIV=0 abgeschaltet werden (Standard: an);
ohne Netzwerk degradiert die Prüfung automatisch auf die Heuristik.
"""
from __future__ import annotations
from fastapi import Depends, Request
import logging
import os
from redaktionskern.auth.deps import require_user
from fastapi import Depends, Form, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_user
from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, NavEntry
from redaktionskern.contracts import BasePlugin, Migration, NavEntry
from .bgg import BggPruefClient
from .models import Pruefung
from .pruefung import KONFLIKT_ANZEIGE, PruefErgebnis, fuehre_pruefung_durch
_logger = logging.getLogger("plugins.dedup")
ANZAHL_PROTOKOLL = 20
def _protokoll_tabelle(conn) -> None:
"""Migration 0001: legt die Protokoll-Tabelle an (portabel, idempotent)."""
Pruefung.__table__.create(conn, checkfirst=True)
class DedupPlugin(BasePlugin):
name = "dedup"
title = "Dedup-Prüfung"
description = "Prüfungen bei Import und händischem Eintrag (Platzhalter)."
description = (
"Prüfungen bei Import und händigem Eintrag: Verlags-Konflikt, "
"Titel-Varianten (deutsche Version bevorzugt), besprochene Spiele "
"inkl. Vorgänger und fremde Planungseinträge."
)
def __init__(self) -> None:
super().__init__()
self._bgg_aktiv = True
self._bgg_client: BggPruefClient | None = None
#: Von Tests überschreibbare Fabrik für den BGG-Hilfsclient.
self.bgg_client_fabrik = None
@self.router.get("/dedup")
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
return self.context.templates.TemplateResponse(
request=request,
name="dedup/index.html",
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
},
)
self._routen_registrieren()
# ---------- Plugin-Vertrag ----------
def migrations(self) -> list[Migration]:
return [Migration(version="0001_pruefprotokoll", up=_protokoll_tabelle)]
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/dedup")]
def on_load(self, context) -> None:
super().on_load(context)
self._bgg_aktiv = os.environ.get("SPIELE_DEDUP_BGG_AKTIV", "1").strip() != "0"
def on_unload(self) -> None:
if self._bgg_client is not None:
self._bgg_client.schliessen()
self._bgg_client = None
super().on_unload()
# ---------- Öffentliche Prüf-API für andere Plugins ----------
async def check_titel(
self,
titel: str,
verlag: str | None = None,
bgg_id: int | str | None = None,
*,
user: User | None = None,
) -> PruefErgebnis:
"""Prüft einen Titel gegen Neuheiten- und Planungsliste.
Liefert ein strukturiertes `PruefErgebnis` mit Konflikt-Details;
wirft nicht (BGG-Probleme werden protokolliert und degradieren die
Prüfung auf die Titel-Heuristik). Jede Prüfung landet im Protokoll.
"""
id_wert: int | None
try:
id_wert = int(bgg_id) if bgg_id not in (None, "") else None
except (TypeError, ValueError):
id_wert = None
client = self._hilfsclient()
try:
ergebnis = fuehre_pruefung_durch(
self.context.session_factory,
client,
titel=titel,
verlag=verlag,
bgg_id=id_wert,
user_id=user.id if user is not None else None,
)
finally:
if client is not None:
client.schliessen()
self._protokolliere(ergebnis, user)
return ergebnis
# ---------- Internas ----------
def _hilfsclient(self) -> BggPruefClient | None:
if self.bgg_client_fabrik is not None:
return self.bgg_client_fabrik()
if not self._bgg_aktiv or self.context is None:
return None
if self._bgg_client is None:
self._bgg_client = BggPruefClient()
return self._bgg_client
def _protokolliere(self, ergebnis: PruefErgebnis, user: User | None) -> None:
"""Schreibt den Prüfbefund ins eigene Protokoll (best effort)."""
try:
with self.context.session_factory() as db:
db.add(
Pruefung(
titel=ergebnis.titel[:300],
verlag=ergebnis.verlag,
bgg_id=ergebnis.bgg_id,
konflikte=ergebnis.als_liste() or None,
geprueft_durch_id=user.id if user is not None else None,
geprueft_durch=user.username if user is not None else "System",
)
)
db.commit()
except Exception:
_logger.exception("dedup: Prüfung konnte nicht protokolliert werden.")
def _seite_rendern(
self,
request: Request,
user: User,
*,
ergebnis: PruefErgebnis | None = None,
meldung: str = "",
fehler: str = "",
):
with self.context.session_factory() as db:
protokoll = db.scalars(
select(Pruefung)
.order_by(Pruefung.erstellt_am.desc(), Pruefung.id.desc())
.limit(ANZAHL_PROTOKOLL)
).all()
return self.context.templates.TemplateResponse(
request=request,
name="dedup/index.html",
context={
"user": user,
"titel": self.title,
"ergebnis": ergebnis,
"konflikt_anzeige": KONFLIKT_ANZEIGE,
"protokoll": protokoll,
"meldung": meldung[:300],
"fehler": fehler[:300],
},
)
def _routen_registrieren(self) -> None:
@self.router.get("/dedup")
def seite(request: Request, user: User = Depends(require_user)):
"""Prüfseite: Manuelle Prüfung + Protokoll der letzten Prüfungen."""
return self._seite_rendern(request, user)
@self.router.post("/dedup/pruefen")
async def pruefen(
request: Request,
user: User = Depends(require_user),
titel: str = Form(""),
verlag: str = Form(""),
bgg_id: str = Form(""),
):
"""Führt eine manuelle Prüfung durch und zeigt das Ergebnis."""
roh_titel = titel.strip()
if not roh_titel:
return self._seite_rendern(
request, user, fehler="Bitte einen Spieltitel angeben."
)
try:
id_wert = int(bgg_id.strip()) if bgg_id.strip() else None
except ValueError:
return self._seite_rendern(
request, user,
fehler="Die BGG-ID muss eine Zahl sein.",
meldung="",
)
ergebnis = await self.check_titel(
roh_titel, verlag.strip() or None, id_wert, user=user
)
return self._seite_rendern(
request,
user,
ergebnis=ergebnis,
meldung="Prüfung abgeschlossen."
if not ergebnis.hat_konflikte
else "",
)
plugin = DedupPlugin()