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()

104
plugins/dedup/bgg.py Normal file
View 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

38
plugins/dedup/models.py Normal file
View File

@@ -0,0 +1,38 @@
"""Datenmodell des Plugins „dedup“.
Das Plugin speichert ein Protokoll der durchgeführten Prüfungen: wer hat
wann welchen Titel prüfen lassen und was wurde befunden. Die Fachdaten der
geprüften Spiele liegen in den Plugin-Tabellen von „neuheiten“ und
„planung“ — dedup liest diese ausschließlich lesend über die gemeinsame
SQLAlchemy-Metadata und schreibt keine fremden Tabellen.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from redaktionskern.db import Base
class Pruefung(Base):
"""Protokolleintrag einer durchgeführten dedup-Prüfung."""
__tablename__ = "dedup_pruefungen"
__table_args__ = {"extend_existing": True}
id: Mapped[int] = mapped_column(primary_key=True)
titel: Mapped[str] = mapped_column(String(300))
verlag: Mapped[str | None] = mapped_column(String(300))
bgg_id: Mapped[int | None] = mapped_column(Integer)
#: Liste von Konflikt-Dicts ({art, beschreibung, details}) oder None.
konflikte: Mapped[list | None] = mapped_column(JSON)
geprueft_durch_id: Mapped[int | None] = mapped_column(Integer)
geprueft_durch: Mapped[str] = mapped_column(String(100), default="")
erstellt_am: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
return f"<Pruefung {self.titel!r}>"

413
plugins/dedup/pruefung.py Normal file
View File

@@ -0,0 +1,413 @@
"""Prüflogik des Plugins „dedup“ — die vier Prüfungen laut Fachanforderung:
a) **Verlags-Konflikt** — dasselbe Spiel ist bereits unter anderem
Verlag/Vertrieb geführt. Ergebnis enthält eine Verlagsauswahl.
b) **Titel-Varianten** — dasselbe Spiel mit unterschiedlichem Titel; die
deutsche Version wird bevorzugt (Heuristik über deutsche Titel-Indikatoren,
ergänzt um BGG Alternate-Names, falls ein Hilfsclient verfügbar ist).
c) **Spiel oder Vorgänger bereits besprochen** — abgeschlossene Einträge der
Planungsliste bilden das „besprochen“-Korpus; Vorgänger werden über die
BGG `boardgameexpansion`-Relation erkannt und zusätzlich fuzzy auf Titel
gematcht (rapidfuzz).
d) **Titel schon in Planungsliste eines anderen Rezensenten.**
Die Daten der anderen Plugins (neuheiten, planung) liest dieses Plugin
lesend über die gemeinsame SQLAlchemy-Metadata (`Base.metadata.tables`) —
so entstehen keine Import-Abhängigkeiten zwischen Plugin-Paketen. Fehlt eine
Tabelle (Plugin nicht geladen), entfällt die jeweilige Teilprüfung einfach.
"""
from __future__ import annotations
import logging
import re
import unicodedata
from collections.abc import Iterable
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from redaktionskern.db import Base
try:
from rapidfuzz import fuzz
except ImportError: # pragma: no cover - rapidfuzz ist eine harte Abhängigkeit
fuzz = None
_logger = logging.getLogger("plugins.dedup")
#: Fuzzy-Schwelle für „gemeintes Spiel“ (rapidfuzz token_sort_ratio 0100).
TITEL_SCHWELLE = 85
#: Statuswert des planung-Plugins für „bereits besprochen“ (bewusst als
#: Literal, damit keine Import-Abhängigkeit zwischen den Plugins entsteht).
STATUS_ABGESCHLOSSEN = "abgeschlossen"
#: Funktionswörter, die stark auf einen deutschen Spieltitel hindeuten.
DEUTSCHE_INDIKATOREN = frozenset({
"der", "die", "das", "den", "dem", "des",
"ein", "eine", "einen", "einem", "einer", "eines",
"und", "für", "von", "vom", "zu", "zum", "zur",
"im", "am", "beim", "auf", "mit", "aus",
})
#: Typische Begriffe deutscher Spieltitel (+2 Punkte je Treffer).
DEUTSCHE_SPIELWOERTER = frozenset({
"abenteuer", "berg", "blitz", "burg", "dorf", "drache", "drachen",
"erde", "feuer", "gärten", "haus", "himmel", "insel", "jagd",
"kaiser", "könig", "könige", "königreich", "land", "länder",
"meister", "mond", "norden", "osten", "pfad", "reich", "ritter",
"schiff", "schloss", "see", "spiel", "spiele", "spielen",
"stadt", "stern", "süden", "turm", "wald", "weg", "welt",
"westen", "wiese", "wilder", "wüste", "zauber", "zeit",
})
#: Wörter, die auf einen englischen Titeltitel hindeuten (1 Punkt je Treffer).
ENGLISCHE_INDIKATOREN = frozenset({
"the", "of", "and", "game", "games", "card", "cards", "dice", "board",
})
_UM_LAUTE = frozenset("äöüßÄÖÜ")
_WORT_MUSTER = re.compile(r"[^\W\d_]+", re.UNICODE)
# ---------------- Titel-Heuristik ----------------
def normalisiere(text: str) -> str:
"""Vereinheitlicht einen Titel für Vergleiche (Groß-/Satzzeichen egal)."""
text = unicodedata.normalize("NFC", text or "")
text = text.casefold()
text = re.sub(r"[^\w\s]+", " ", text, flags=re.UNICODE)
return re.sub(r"\s+", " ", text).strip()
def titel_aehnlich(a: str, b: str, schwelle: int = TITEL_SCHWELLE) -> bool:
"""True, wenn zwei Titel dasselbe Spiel meinen (exakt oder fuzzy).
Nach der Normalisierung zählt `fuzz.token_set_ratio`: Wortreihenfolge
und Füllwörter sind egal, damit „Die Siedler von Catan“ und
„Siedler von Catan, Die“ zusammenpassen, „Catan Erweiterung“ und
„Catan Grundspiel“ aber nicht.
"""
if not a or not b:
return False
na, nb = normalisiere(a), normalisiere(b)
if not na or not nb:
return False
if na == nb:
return True
if fuzz is None: # pragma: no cover
return False
return fuzz.token_set_ratio(na, nb) >= schwelle
def deutsch_score(titel: str) -> int:
"""Heuristik-Score: > 0 spricht für einen deutschen Titel.
Umlaute/ß zählen am stärksten (+3), deutsche Funktionswörter +1,
typische deutsche Spielbegriffe +2, englische Indikatoren 1.
"""
if not titel:
return 0
score = 0
if any(zeichen in _UM_LAUTE for zeichen in titel):
score += 3
woerter = {wort.casefold() for wort in _WORT_MUSTER.findall(titel)}
score += sum(1 for wort in woerter if wort in DEUTSCHE_INDIKATOREN)
score += sum(2 for wort in woerter if wort in DEUTSCHE_SPIELWOERTER)
score -= sum(1 for wort in woerter if wort in ENGLISCHE_INDIKATOREN)
return score
def ist_deutscher_titel(titel: str) -> bool:
return deutsch_score(titel) > 0
def bevorzuge_deutschen_titel(kandidaten: Iterable[str]) -> str | None:
"""Wählt aus Kandidaten den deutschesten Titel (bei Gleichstand den ersten)."""
bester: str | None = None
bester_score = 0
for kandidat in kandidaten:
kandidat = (kandidat or "").strip()
if not kandidat:
continue
score = deutsch_score(kandidat)
if bester is None or score > bester_score:
bester, bester_score = kandidat, score
return bester
def _verlags_schluessel(verlag: str) -> str:
"""Vergleichsschlüssel für Verlage („Kosmos“ == „kosmos “)."""
return normalisiere(verlag)
# ---------------- Prüfergebnis ----------------
#: Anzeigenamen der Konflikt-Arten (deutsche UI).
KONFLIKT_ANZEIGE: dict[str, str] = {
"verlag": "Verlags-Konflikt",
"titel": "Titel-Variante",
"besprochen": "Bereits besprochen",
"planung": "Schon in Planung",
}
@dataclass(frozen=True)
class TitelKonflikt:
"""Ein einzelner Befund der Prüfung."""
art: str # Schlüssel aus KONFLIKT_ANZEIGE
beschreibung: str
details: dict = field(default_factory=dict)
def als_dict(self) -> dict:
return {"art": self.art, "beschreibung": self.beschreibung, "details": self.details}
@property
def anzeige(self) -> str:
return KONFLIKT_ANZEIGE.get(self.art, self.art)
@dataclass
class PruefErgebnis:
"""Strukturiertes Ergebnis einer dedup-Prüfung."""
titel: str
verlag: str | None = None
bgg_id: int | None = None
konflikte: list[TitelKonflikt] = field(default_factory=list)
#: Verlage zur Auswahl bei einem Verlags-Konflikt (welcher wird geführt?).
verlags_optionen: list[str] = field(default_factory=list)
#: Empfohlener (deutschester) Titel, falls Titel-Varianten bekannt sind.
titel_empfehlung: str | None = None
@classmethod
def ohne_befund(cls, titel: str, verlag: str | None = None, bgg_id: int | None = None):
return cls(titel=titel, verlag=verlag, bgg_id=bgg_id)
@property
def hat_konflikte(self) -> bool:
return bool(self.konflikte)
@property
def hat_verlagskonflikt(self) -> bool:
return any(konflikt.art == "verlag" for konflikt in self.konflikte)
def als_liste(self) -> list[dict]:
return [konflikt.als_dict() for konflikt in self.konflikte]
def als_text(self) -> str:
if not self.konflikte:
return "Keine Treffer."
zeilen = [f"• [{k.anzeige}] {k.beschreibung}" for k in self.konflikte]
if (
self.titel_empfehlung
and normalisiere(self.titel_empfehlung) != normalisiere(self.titel)
):
zeilen.append(f"• Empfohlener Titel: „{self.titel_empfehlung}")
return "\n".join(zeilen)
# ---------------- Datenzugriff (fremde Plugin-Tabellen, nur lesend) ----------------
def _lade_zeilen(db, tabellenname: str, spalten: tuple[str, ...]) -> list[dict]:
"""Liest Zeilen einer Plugin-Tabelle über die gemeinsame Metadata.
Fehlt die Tabelle (Plugin nicht geladen bzw. Migration nicht gelaufen),
wird eine leere Liste zurückgegeben — die jeweilige Teilprüfung entfällt.
"""
tabelle = Base.metadata.tables.get(tabellenname)
if tabelle is None:
return []
try:
zeilen = db.execute(select(*(tabelle.c[name] for name in spalten))).all()
except SQLAlchemyError as exc:
_logger.warning("dedup: Tabelle '%s' nicht lesbar (%s) — Teilprüfung übersprungen.", tabellenname, exc)
return []
return [dict(zip(spalten, zeile._tuple())) for zeile in zeilen]
def _lade_benutzernamen(db) -> dict[int, str]:
benutzer = Base.metadata.tables.get("users")
namen: dict[int, str] = {}
if benutzer is None:
return namen
try:
for zeile in db.execute(
select(benutzer.c.id, benutzer.c.display_name, benutzer.c.username)
).all():
id_wert, anzeige, username = zeile._tuple()
namen[id_wert] = anzeige or username or f"Benutzer {id_wert}"
except SQLAlchemyError: # pragma: no cover - defensive
pass
return namen
def _rezensent_name(zeile: dict, namen: dict[int, str]) -> str:
id_wert = zeile.get("rezensent_id")
if id_wert is None:
return "unbekannt"
return namen.get(id_wert, f"Benutzer {id_wert}")
def _gleiches_spiel(zeile: dict, titel: str, bgg_id: int | None) -> bool:
"""Match über eindeutige BGG-ID, sonst fuzzy über den Titel."""
fremd_bgg = zeile.get("bgg_id")
if bgg_id is not None and fremd_bgg is not None and int(fremd_bgg) == int(bgg_id):
return True
return titel_aehnlich(titel, zeile.get("titel") or "")
# ---------------- Hauptprüfung ----------------
def fuehre_pruefung_durch(
session_factory,
bgg_hilfsclient,
*,
titel: str,
verlag: str | None = None,
bgg_id: int | None = None,
user_id: int | None = None,
) -> PruefErgebnis:
"""Führt alle vier Prüfungen durch und liefert ein PruefErgebnis.
`bgg_hilfsclient` darf None sein (BGG-Zusatzdaten deaktiviert) — dann
entfallen Alternate-Names und Vorgänger-Relationen, die Heuristik und
der Fuzzy-Match arbeiten weiter.
`user_id` ist der Eintragende: eigene Planungseinträge lösen keinen
„schon in Planung“-Konflikt aus.
"""
titel = (titel or "").strip()
verlag = (verlag or "").strip() or None
id_wert = int(bgg_id) if bgg_id else None
ergebnis = PruefErgebnis.ohne_befund(titel, verlag, id_wert)
if not titel:
return ergebnis
with session_factory() as db:
neuheiten = _lade_zeilen(db, "neuheiten", ("id", "titel", "verlag", "bgg_id"))
planungen = _lade_zeilen(
db, "planungsliste", ("id", "titel", "verlag", "bgg_id", "status", "rezensent_id")
)
namen = _lade_benutzernamen(db)
alternativen: list[str] = []
vorgaenger: list[tuple[int, str]] = []
if id_wert is not None and bgg_hilfsclient is not None:
try:
alternativen = list(bgg_hilfsclient.alternativen(id_wert))
except Exception as exc: # Zusatzdaten dürfen die Prüfung nie blockieren
_logger.warning("dedup: BGG-Alternativen nicht verfügbar (%s)", exc)
try:
vorgaenger = list(bgg_hilfsclient.vorgaenger(id_wert))
except Exception as exc:
_logger.warning("dedup: BGG-Vorgänger nicht verfügbar (%s)", exc)
# Kandidaten = Einträge zum selben Spiel (Neuheiten + Planungsliste).
kandidaten = [
zeile for zeile in (*neuheiten, *planungen)
if _gleiches_spiel(zeile, titel, id_wert)
]
# a) Verlags-Konflikt: gleiches Spiel unter anderem Verlag/Vertrieb.
bekannte_verlage: dict[str, str] = {}
for zeile in kandidaten:
kandidat_verlag = (zeile.get("verlag") or "").strip()
if kandidat_verlag:
bekannte_verlage.setdefault(_verlags_schluessel(kandidat_verlag), kandidat_verlag)
abweichend = [
name for schluessel, name in bekannte_verlage.items()
if verlag and schluessel != _verlags_schluessel(verlag)
]
if abweichend or len(bekannte_verlage) > 1:
optionen = list(bekannte_verlage.values())
if verlag and _verlags_schluessel(verlag) not in bekannte_verlage:
optionen.append(verlag)
ergebnis.verlags_optionen = optionen
ergebnis.konflikte.append(TitelKonflikt(
art="verlag",
beschreibung=(
"Gleiches Spiel ist bereits unter anderem Verlag geführt: "
+ ", ".join(sorted(bekannte_verlage.values()))
+ ". Bitte wählen, welcher Verlag geführt wird."
),
details={"eigener_verlag": verlag, "bekannte_verlage": list(bekannte_verlage.values())},
))
# b) Titel-Varianten → deutsche Version bevorzugen.
varianten = [
zeile["titel"] for zeile in kandidaten
if normalisiere(zeile.get("titel") or "") != normalisiere(titel)
]
ergebnis.titel_empfehlung = bevorzuge_deutschen_titel(
[titel, *varianten, *alternativen]
)
if varianten and ergebnis.titel_empfehlung \
and normalisiere(ergebnis.titel_empfehlung) != normalisiere(titel):
ergebnis.konflikte.append(TitelKonflikt(
art="titel",
beschreibung=(
f"Gleiches Spiel unter anderem Titel bekannt — empfohlen: "
f"{ergebnis.titel_empfehlung}“."
),
details={
"empfehlung": ergebnis.titel_empfehlung,
"varianten": sorted(set(varianten)),
"bgg_alternativen": alternativen[:10],
},
))
# c) Spiel oder Vorgänger bereits besprochen (Planungsstatus „abgeschlossen“).
besprochen = [z for z in planungen if z.get("status") == STATUS_ABGESCHLOSSEN]
for zeile in besprochen:
if _gleiches_spiel(zeile, titel, id_wert):
wer = _rezensent_name(zeile, namen)
ergebnis.konflikte.append(TitelKonflikt(
art="besprochen",
beschreibung=f"{zeile['titel']}“ wurde bereits besprochen ({wer}).",
details={"planungseintrag": zeile["id"], "titel": zeile["titel"], "rezensent": wer},
))
for fremd_id, fremd_titel in vorgaenger:
for zeile in besprochen:
zeilen_bgg = zeile.get("bgg_id")
passt = (
(fremd_id and zeilen_bgg is not None and int(zeilen_bgg) == fremd_id)
or titel_aehnlich(fremd_titel, zeile.get("titel") or "")
)
if passt:
wer = _rezensent_name(zeile, namen)
ergebnis.konflikte.append(TitelKonflikt(
art="besprochen",
beschreibung=(
f"Vorgänger „{fremd_titel}“ wurde bereits besprochen ({wer}) — "
"Erweiterung prüfen, ob sie noch Redaktionsstoff bietet."
),
details={
"vorgaenger_bgg_id": fremd_id,
"vorgaenger_titel": fremd_titel,
"planungseintrag": zeile["id"],
},
))
# d) Titel schon in der Planungsliste eines anderen Rezensenten.
for zeile in planungen:
if user_id is not None and zeile.get("rezensent_id") == user_id:
continue
if _gleiches_spiel(zeile, titel, id_wert):
wer = _rezensent_name(zeile, namen)
status = zeile.get("status") or ""
ergebnis.konflikte.append(TitelKonflikt(
art="planung",
beschreibung=(
f"{zeile['titel']}“ ist bereits in der Planungsliste "
f"von {wer} (Status: {status})."
),
details={
"planungseintrag": zeile["id"],
"rezensent": wer,
"status": status,
},
))
return ergebnis

View File

@@ -2,9 +2,121 @@
{% 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 &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
<p class="text-slate-600 mb-4">
Die Prüfung läuft automatisch bei jeder Eintragung in die Planungsliste.
Hier kann sie auch von Hand für einen Titel ausgeführt werden.
</p>
<div class="grid md:grid-cols-4 gap-3 mb-6 text-sm">
<div class="bg-white border border-slate-200 rounded-xl p-3">
<p class="font-medium text-slate-700 mb-1">&#127962; Verlags-Konflikt</p>
<p class="text-slate-500">Gleiches Spiel bei verschiedenen Verlagen/Vertrieb &rarr; Verlagsauswahl.</p>
</div>
<div class="bg-white border border-slate-200 rounded-xl p-3">
<p class="font-medium text-slate-700 mb-1">&#127463; Titel-Varianten</p>
<p class="text-slate-500">Deutsche Version wird bevorzugt (Heuristik + BGG Alternate-Names).</p>
</div>
<div class="bg-white border border-slate-200 rounded-xl p-3">
<p class="font-medium text-slate-700 mb-1">&#128214; Bereits besprochen</p>
<p class="text-slate-500">Spiel oder Vorg&auml;nger (BGG-Erweiterungs-Relation + Fuzzy-Match) schon in der Redaktion.</p>
</div>
<div class="bg-white border border-slate-200 rounded-xl p-3">
<p class="font-medium text-slate-700 mb-1">&#128203; Schon in Planung</p>
<p class="text-slate-500">Titel steht bereits in der Planungsliste eines anderen Rezensenten.</p>
</div>
</div>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<h2 class="font-semibold mb-3">Titel pr&uuml;fen</h2>
<form method="post" action="/dedup/pruefen" class="flex flex-wrap items-end gap-3">
<div>
<label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label>
<input type="text" id="titel" name="titel" required value="{{ ergebnis.titel if ergebnis else '' }}"
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>
<div>
<label for="verlag" class="block text-xs font-medium text-slate-500 mb-1">Verlag</label>
<input type="text" id="verlag" name="verlag" value="{{ ergebnis.verlag if ergebnis and ergebnis.verlag else '' }}"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-56 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label for="bgg_id" class="block text-xs font-medium text-slate-500 mb-1">BGG-ID (optional)</label>
<input type="text" id="bgg_id" name="bgg_id" value="{{ ergebnis.bgg_id if ergebnis and ergebnis.bgg_id else '' }}"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-32 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">
Pr&uuml;fen
</button>
</form>
{% if fehler %}
<div class="mt-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div>
{% endif %}
{% if meldung %}
<div class="mt-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div>
{% endif %}
{% if ergebnis %}
<div class="mt-4 rounded-lg border p-4 text-sm
{% if ergebnis.hat_konflikte %}bg-amber-50 border-amber-300{% else %}bg-emerald-50 border-emerald-200{% endif %}">
<p class="font-medium mb-2 {% if ergebnis.hat_konflikte %}text-amber-800{% else %}text-emerald-800{% endif %}">
{% if ergebnis.hat_konflikte %}&#9888; {{ ergebnis.konflikte|length }} Treffer f&uuml;r „{{ ergebnis.titel }}“{% else %}&#10003; Keine Treffer f&uuml;r „{{ ergebnis.titel }}“{% endif %}
</p>
<ul class="list-disc list-inside space-y-1 text-slate-700">
{% for konflikt in ergebnis.konflikte %}
<li>
<span class="inline-block px-2 py-0.5 rounded-full text-xs mr-1
{% if konflikt.art == 'verlag' %}bg-orange-200 text-orange-900
{% elif konflikt.art == 'titel' %}bg-sky-200 text-sky-900
{% elif konflikt.art == 'besprochen' %}bg-violet-200 text-violet-900
{% else %}bg-amber-200 text-amber-900{% endif %}">{{ konflikt.anzeige }}</span>
{{ konflikt.beschreibung }}
</li>
{% endfor %}
</ul>
{% if ergebnis.titel_empfehlung and ergebnis.titel_empfehlung != ergebnis.titel %}
<p class="mt-2 text-slate-600">Empfohlener Titel: <span class="font-medium">{{ ergebnis.titel_empfehlung }}</span></p>
{% endif %}
</div>
{% endif %}
</div>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-x-auto">
<h2 class="font-semibold px-4 pt-4 pb-2">Letzte Pr&uuml;fungen</h2>
<table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-2 font-medium">Zeitpunkt</th>
<th class="px-4 py-2 font-medium">Titel</th>
<th class="px-4 py-2 font-medium">Verlag</th>
<th class="px-4 py-2 font-medium">BGG-ID</th>
<th class="px-4 py-2 font-medium">Gepr&uuml;ft von</th>
<th class="px-4 py-2 font-medium">Befund</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for pruefung in protokoll %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-2 whitespace-nowrap text-slate-500">{{ pruefung.erstellt_am.strftime('%d.%m.%Y %H:%M') if pruefung.erstellt_am else '—' }}</td>
<td class="px-4 py-2 font-medium">{{ pruefung.titel }}</td>
<td class="px-4 py-2">{{ pruefung.verlag or '—' }}</td>
<td class="px-4 py-2">{{ pruefung.bgg_id or '—' }}</td>
<td class="px-4 py-2">{{ pruefung.geprueft_durch or '—' }}</td>
<td class="px-4 py-2">
{% if pruefung.konflikte %}
{% for konflikt in pruefung.konflikte %}
<span class="inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs mr-1">{{ konflikt_anzeige.get(konflikt.art, konflikt.art) }}</span>
{% endfor %}
{% else %}
<span class="text-emerald-700">Keine Treffer</span>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="6" class="px-4 py-6 text-center text-slate-400">Noch keine Pr&uuml;fungen durchgef&uuml;hrt.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}