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,529 @@
"""Plugin „planung“ — Platzhalter gemäß Plugin-Vertrag.
"""Plugin „planung“ — Planungsliste mit Zuordnung an Rezensenten.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
Zwei Eintragswege, beide laufen automatisch durch die dedup-Prüfung des
dedup-Plugins:
1. **Verschiebung aus der Neuheitenliste** — Button „Zur Planung“ in der
Neuheitenliste ruft `POST /planung/uebernehmen/<neuheit_id>` auf; der
Neuheiten-Eintrag wechselt dabei in den Status „planung“.
2. **Händisches Nachtragen** über das Formular auf `/planung`.
Bei einem Prüftreffer wird eine Benachrichtigung an den eintragenden
Rezensenten verschickt (Plugin „benachrichtigung“, `send_notification`)
und ein Audit-Log-Eintrag geschrieben (Plugin „audit-log“, `log`). Bei
einem Verlags-Konflikt erscheint zuerst ein Auswahl-Dialog, welcher Verlag
geführt wird; erst nach der Bestätigung wird der Eintrag angelegt.
Rezensenten sehen die komplette Liste (mit Zuordnung), dürfen aber nur
ihre eigenen Einträge bearbeiten; Admins und Redakteure alle.
Beide Partner-Plugins werden über die Registry geholt (`registry.get(...)`);
fehlen sie, entfallen Benachrichtigung/Audit bzw. die Prüfung, ohne dass
der Eintrage-Vorgang bricht.
"""
from __future__ import annotations
from fastapi import Depends, Request
import logging
from urllib.parse import quote
from redaktionskern.auth.deps import require_user
from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, NavEntry
from fastapi import Depends, Form, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from redaktionskern.auth.deps import AccessDenied, get_db, require_user
from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry
from redaktionskern.db import Base
from .models import (
GUELTIGE_STATUS,
STATUS_ANZEIGE,
Planungseintrag,
)
_logger = logging.getLogger("plugins.planung")
REDAKTION = (Role.ADMIN.value, Role.REDAKTEUR.value)
#: Konflikt-Anzeigenamen für UI/Benachrichtigung (bewusst als Kopie ohne
#: Import-Abhängigkeit zum dedup-Plugin).
KONFLIKT_ANZEIGE = {
"verlag": "Verlags-Konflikt",
"titel": "Titel-Variante",
"besprochen": "Bereits besprochen",
"planung": "Schon in Planung",
}
def _planungs_tabelle(conn) -> None:
"""Migration 0001: legt die Tabelle `planungsliste` an."""
Planungseintrag.__table__.create(conn, checkfirst=True)
def _neuheiten_tabelle():
"""Neuheiten-Tabelle über die gemeinsame Metadata (nur lesend/schreibend
auf den Status — kein Import des neuheiten-Pakets nötig)."""
return Base.metadata.tables.get("neuheiten")
class PlanungPlugin(BasePlugin):
name = "planung"
title = "Planung"
description = "Planungsliste mit Zuordnung an Rezensenten (Platzhalter)."
description = (
"Planungsliste mit Zuordnung an Rezensenten — Verschiebung aus den "
"Neuheiten und händisches Nachtragen, automatisch durch die "
"dedup-Prüfung."
)
def __init__(self) -> None:
super().__init__()
self._routen_registrieren()
# ---------- Plugin-Vertrag ----------
def migrations(self) -> list[Migration]:
return [Migration(version="0001_planungsliste", up=_planungs_tabelle)]
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/planung")]
# ---------- Hilfsfunktionen ----------
@staticmethod
def _darf_bearbeiten(user: User, eintrag: Planungseintrag) -> bool:
return user.role in REDAKTION or eintrag.rezensent_id == user.id
@classmethod
def _pruefe_bearbeitbar(cls, user: User, eintrag: Planungseintrag) -> None:
if not cls._darf_bearbeiten(user, eintrag):
raise AccessDenied(
"Rezensenten dürfen nur ihre eigenen Planungseinträge ändern."
)
@staticmethod
def _hole_dedup(request: Request):
return request.app.state.registry.get("dedup")
@staticmethod
async def _benachrichtige(
request: Request, user: User, titel: str, ergebnis
) -> None:
"""Versendet bei Prüftreffern eine Nachricht an den Eintragenden."""
benachrichtigung = request.app.state.registry.get("benachrichtigung")
if benachrichtigung is None:
return
zeilen = [
f"Die Dedup-Prüfung für „{titel}“ hat {len(ergebnis.konflikte)} Treffer:"
]
for konflikt in ergebnis.konflikte:
anzeige = KONFLIKT_ANZEIGE.get(konflikt.art, konflikt.art)
zeilen.append(f"• [{anzeige}] {konflikt.beschreibung}")
try:
await benachrichtigung.send_notification(
user,
f"Dedup-Hinweis: {titel}",
"\n".join(zeilen),
"dedup",
)
except Exception:
# Eine fehlgeschlagene Benachrichtigung darf den Eintrag nicht blockieren.
_logger.exception("Benachrichtigung über Dedup-Treffer fehlgeschlagen.")
@staticmethod
async def _audit(
request: Request,
actor,
action: str,
objekt_id,
details: dict | None = None,
) -> None:
"""Schreibt einen Audit-Log-Eintrag (best effort, wenn Plugin geladen)."""
audit = request.app.state.registry.get("audit-log")
if audit is None:
return
try:
await audit.log(actor, action, "planungseintrag", objekt_id, details)
except Exception:
_logger.exception("Audit-Log-Eintrag für Planung fehlgeschlagen.")
@staticmethod
def _meldung(url: str, text: str, art: str = "meldung") -> RedirectResponse:
return RedirectResponse(f"{url}?{art}={quote(text)}", status_code=303)
def _dialog_antwort(
self,
request: Request,
user: User,
*,
titel: str,
ergebnis,
aktion: str,
versteckt: dict[str, str],
abbrechen_url: str = "/planung",
):
"""Auswahl-Dialog bei Verlags-Konflikt: Welcher Verlag wird geführt?"""
return self.context.templates.TemplateResponse(
request=request,
name="planung/pruefung.html",
context={
"user": user,
"titel_headline": f"Dedup-Prüfung — {titel}",
"spiel_titel": titel,
"konflikte": ergebnis.konflikte,
"konflikt_anzeige": KONFLIKT_ANZEIGE,
"titel_empfehlung": ergebnis.titel_empfehlung,
"verlags_optionen": ergebnis.verlags_optionen,
"aktion": aktion,
"versteckt": versteckt,
"abbrechen_url": abbrechen_url,
},
)
# ---------- Routen ----------
def _routen_registrieren(self) -> None:
@self.router.get("/planung")
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
def liste(
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
status: str = "",
meldung: str = "",
fehler: str = "",
):
"""Planungsliste: alle Einträge mit Zuordnung und Statuswechsel."""
abfrage = select(Planungseintrag)
if status in GUELTIGE_STATUS:
abfrage = abfrage.where(Planungseintrag.status == status)
eintraege = db.scalars(
abfrage.order_by(
Planungseintrag.aktualisiert_am.desc(), Planungseintrag.id.desc()
)
).all()
namen = {
benutzer.id: (benutzer.display_name or benutzer.username)
for benutzer in db.scalars(select(User)).all()
}
return self.context.templates.TemplateResponse(
request=request,
name="planung/index.html",
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
"eintraege": eintraege,
"namen": namen,
"status_anzeige": STATUS_ANZEIGE,
"status_filter": status if status in GUELTIGE_STATUS else "",
"status_optionen": GUELTIGE_STATUS,
"kann_bearbeiten": lambda eintrag: self._darf_bearbeiten(user, eintrag),
"ist_redaktion": user.role in REDAKTION,
"meldung": meldung[:300],
"fehler": fehler[:300],
"anzahl": len(eintraege),
},
)
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/planung")]
@self.router.post("/planung/neu")
async def neu_anlegen(
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
titel: str = Form(""),
verlag: str = Form(""),
ausgabe: str = Form(""),
bgg_id: str = Form(""),
verlag_wahl: str = Form(""),
verlag_bestaetigt: str = Form(""),
):
"""Händisches Nachtragen eines Titels (durchläuft die dedup-Prüfung)."""
roh_titel = titel.strip()
if not roh_titel:
return self._meldung("/planung", "Bitte einen Spieltitel angeben.", "fehler")
try:
id_wert = int(bgg_id.strip()) if bgg_id.strip() else None
except ValueError:
return self._meldung("/planung", "Die BGG-ID muss eine Zahl sein.", "fehler")
dedup = self._hole_dedup(request)
ergebnis = (
await dedup.check_titel(roh_titel, verlag.strip() or None, id_wert, user=user)
if dedup is not None
else None
)
hat_verlagskonflikt = bool(ergebnis and ergebnis.hat_verlagskonflikt)
# Verlags-Konflikt → Auswahl-Dialog statt sofortigem Anlegen.
if hat_verlagskonflikt and verlag_bestaetigt != "1":
return self._dialog_antwort(
request,
user,
titel=roh_titel,
ergebnis=ergebnis,
aktion="/planung/neu",
versteckt={
"titel": roh_titel,
"ausgabe": ausgabe.strip(),
"bgg_id": bgg_id.strip(),
"verlag_bestaetigt": "1",
},
)
gewaehlter_verlag = (verlag_wahl.strip() or verlag.strip()) or None
eintrag = Planungseintrag(
titel=roh_titel,
verlag=gewaehlter_verlag,
ausgabe=ausgabe.strip(),
bgg_id=id_wert,
rezensent_id=user.id,
status="offen",
quelle="manuell",
pruefung=(ergebnis.als_liste() or None) if ergebnis is not None else None,
)
db.add(eintrag)
db.commit()
details = {
"titel": eintrag.titel,
"verlag": eintrag.verlag,
"ausgabe": eintrag.ausgabe,
"quelle": eintrag.quelle,
"rezensent_id": eintrag.rezensent_id,
}
if ergebnis is not None and ergebnis.hat_konflikte:
details["dedup_konflikte"] = ergebnis.als_liste()
await self._audit(request, user, "erstellt", eintrag.id, details)
if ergebnis is not None and ergebnis.hat_konflikte:
await self._benachrichtige(request, user, eintrag.titel, ergebnis)
meldung = (
f"{eintrag.titel}“ wurde eingetragen. Die Dedup-Prüfung hatte "
"Treffer — du hast eine Benachrichtigung erhalten."
)
else:
meldung = f"{eintrag.titel}“ wurde eingetragen."
return self._meldung("/planung", meldung)
@self.router.post("/planung/uebernehmen/{neuheit_id}")
async def uebernehmen(
request: Request,
neuheit_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_user),
verlag_wahl: str = Form(""),
verlag_bestaetigt: str = Form(""),
):
"""Verschiebung Neuheiten → Planung (durchläuft die dedup-Prüfung)."""
tabelle = _neuheiten_tabelle()
if tabelle is None:
return self._meldung(
"/planung",
"Neuheitenliste nicht verfügbar — Verschiebung nicht möglich.",
"fehler",
)
zeile = db.execute(
select(tabelle).where(tabelle.c.id == neuheit_id)
).first()
if zeile is None:
return self._meldung(
"/neuheiten", "Dieser Neuheiten-Eintrag existiert nicht mehr.", "fehler"
)
if zeile.status != "neuheit":
return self._meldung(
"/neuheiten",
f"{zeile.titel}“ ist nicht mehr als Neuheit verfügbar.",
"fehler",
)
dedup = self._hole_dedup(request)
ergebnis = (
await dedup.check_titel(zeile.titel, zeile.verlag, zeile.bgg_id, user=user)
if dedup is not None
else None
)
hat_verlagskonflikt = bool(ergebnis and ergebnis.hat_verlagskonflikt)
# Verlags-Konflikt → Auswahl-Dialog statt sofortiger Übernahme.
if hat_verlagskonflikt and verlag_bestaetigt != "1":
return self._dialog_antwort(
request,
user,
titel=zeile.titel,
ergebnis=ergebnis,
aktion=f"/planung/uebernehmen/{neuheit_id}",
versteckt={"neuheit_id": str(neuheit_id), "verlag_bestaetigt": "1"},
abbrechen_url="/neuheiten",
)
gewaehlter_verlag = (verlag_wahl.strip() or (zeile.verlag or "")) or None
eintrag = Planungseintrag(
titel=zeile.titel,
verlag=gewaehlter_verlag,
autor=zeile.autor,
bgg_id=zeile.bgg_id,
ausgabe="",
rezensent_id=user.id,
status="offen",
quelle="neuheiten",
pruefung=(ergebnis.als_liste() or None) if ergebnis is not None else None,
)
db.add(eintrag)
db.execute(
update(tabelle)
.where(tabelle.c.id == neuheit_id)
.values(status="planung")
)
db.commit()
details = {
"titel": eintrag.titel,
"verlag": eintrag.verlag,
"ausgabe": eintrag.ausgabe,
"quelle": eintrag.quelle,
"rezensent_id": eintrag.rezensent_id,
"von": "neuheiten",
"neuheit_id": neuheit_id,
}
if ergebnis is not None and ergebnis.hat_konflikte:
details["dedup_konflikte"] = ergebnis.als_liste()
await self._audit(request, user, "verschoben", eintrag.id, details)
if ergebnis is not None and ergebnis.hat_konflikte:
await self._benachrichtige(request, user, eintrag.titel, ergebnis)
meldung = (
f"{eintrag.titel}“ wurde in deine Planungsliste übernommen. "
"Die Dedup-Prüfung hatte Treffer — du hast eine Benachrichtigung erhalten."
)
else:
meldung = f"{eintrag.titel}“ wurde in deine Planungsliste übernommen."
return self._meldung("/planung", meldung)
@self.router.post("/planung/{eintrag_id}/status")
async def status_wechseln(
request: Request,
eintrag_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_user),
status: str = Form(""),
):
"""Statuswechsel (offen → in Bearbeitung → abgeschlossen)."""
eintrag = db.get(Planungseintrag, eintrag_id)
if eintrag is None:
return self._meldung("/planung", "Eintrag nicht gefunden.", "fehler")
self._pruefe_bearbeitbar(user, eintrag)
if status not in GUELTIGE_STATUS:
return self._meldung("/planung", "Unbekannter Status.", "fehler")
alt = eintrag.status
eintrag.status = status
db.commit()
await self._audit(
request,
user,
"geaendert",
eintrag.id,
{"alt": {"status": alt}, "neu": {"status": status}},
)
return self._meldung(
"/planung",
f"Status von „{eintrag.titel}“: {STATUS_ANZEIGE.get(status, status)}.",
)
@self.router.get("/planung/{eintrag_id}/bearbeiten")
def bearbeiten_formular(
request: Request,
eintrag_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
eintrag = db.get(Planungseintrag, eintrag_id)
if eintrag is None:
return self._meldung("/planung", "Eintrag nicht gefunden.", "fehler")
self._pruefe_bearbeitbar(user, eintrag)
return self.context.templates.TemplateResponse(
request=request,
name="planung/bearbeiten.html",
context={
"user": user,
"titel_headline": f"Eintrag bearbeiten — {eintrag.titel}",
"eintrag": eintrag,
"abbrechen_url": "/planung",
},
)
@self.router.post("/planung/{eintrag_id}/bearbeiten")
async def bearbeiten_speichern(
request: Request,
eintrag_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_user),
titel: str = Form(""),
verlag: str = Form(""),
ausgabe: str = Form(""),
notizen: str = Form(""),
):
eintrag = db.get(Planungseintrag, eintrag_id)
if eintrag is None:
return self._meldung("/planung", "Eintrag nicht gefunden.", "fehler")
self._pruefe_bearbeitbar(user, eintrag)
roh_titel = titel.strip()
if not roh_titel:
return self._meldung("/planung", "Bitte einen Spieltitel angeben.", "fehler")
alt = {
"titel": eintrag.titel,
"verlag": eintrag.verlag,
"ausgabe": eintrag.ausgabe,
"notizen": eintrag.notizen,
}
eintrag.titel = roh_titel
eintrag.verlag = verlag.strip() or None
eintrag.ausgabe = ausgabe.strip()
eintrag.notizen = notizen.strip() or None
neu = {
"titel": eintrag.titel,
"verlag": eintrag.verlag,
"ausgabe": eintrag.ausgabe,
"notizen": eintrag.notizen,
}
geaendert = [k for k in alt if alt[k] != neu[k]]
db.commit()
if geaendert:
# Nur geänderte Felder, im alt/neu-Format des Audit-Log-Plugins.
await self._audit(
request,
user,
"geaendert",
eintrag.id,
{
"alt": {k: alt[k] for k in geaendert},
"neu": {k: neu[k] for k in geaendert},
},
)
return self._meldung("/planung", f"{eintrag.titel}“ wurde gespeichert.")
@self.router.post("/planung/{eintrag_id}/loeschen")
async def loeschen(
request: Request,
eintrag_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
eintrag = db.get(Planungseintrag, eintrag_id)
if eintrag is None:
return self._meldung("/planung", "Eintrag nicht gefunden.", "fehler")
self._pruefe_bearbeitbar(user, eintrag)
snapshot = {
"titel": eintrag.titel,
"verlag": eintrag.verlag,
"ausgabe": eintrag.ausgabe,
"status": eintrag.status,
"rezensent_id": eintrag.rezensent_id,
}
db.delete(eintrag)
db.commit()
await self._audit(request, user, "geloescht", eintrag_id, snapshot)
return self._meldung("/planung", f"{snapshot['titel']}“ wurde gelöscht.")
plugin = PlanungPlugin()