Files
spiele-redaktion/plugins/neuheiten/__init__.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

249 lines
9.3 KiB
Python

"""Plugin „neuheiten“ — Neuheitenliste aus der BoardGameGeek XML API2.
Umfang:
- Eigene Migration (Tabelle `neuheiten`).
- BGG-Client mit Rate-Limit/Retry (siehe bgg.py), Sync-Service (sync.py).
- Regelmäßiger Sync als Hintergrund-Job (APScheduler, konfigurierbar).
- Manueller „Jetzt synchronisieren“-Button für Admins/Redakteure.
- Sortier-/filterbare Neuheitenliste mit Suche (deutsche UI).
Konfiguration über Umgebungsvariablen (gelesen beim Plugin-Start):
- SPIELE_BGG_SYNC_AKTIV „1“ (Standard) = Hintergrund-Job an
- SPIELE_BGG_SYNC_INTERVALL_STUNDEN Intervall in Stunden (Standard 24)
- SPIELE_BGG_SUCHBEGRIFFE Komma-getrennte Suchbegriffe
(Standard: „brettspiel“)
- SPIELE_BGG_MAX_TREFFER_PRO_SUCHE Obergrenze Treffer/Suchbegriff (25)
Keine Fachlogik im Kern — alles hier im Plugin gemäß Plugin-Vertrag.
"""
from __future__ import annotations
import logging
import threading
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
from fastapi import Depends, Form, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import func, or_, select
from redaktionskern.auth.deps import require_roles, require_user
from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext
from .bgg import BggClient, BggFehler
from .models import Neuheit
from .sync import SyncService
_logger = logging.getLogger("plugins.neuheiten")
SORTIERBAR = {
"titel": Neuheit.titel,
"verlag": Neuheit.verlag,
"autor": Neuheit.autor,
"jahr": Neuheit.erscheinungsjahr,
"status": Neuheit.status,
"aktualisiert": Neuheit.aktualisiert_am,
}
STANDARD_SORTIERUNG = ("titel", "auf")
MAX_MELDUNGS_LAENGE = 300
def _umgebung_liste(name: str, standard: str) -> list[str]:
import os
roh = os.environ.get(name, standard)
return [teil.strip() for teil in roh.split(",") if teil.strip()]
class NeuheitenPlugin(BasePlugin):
name = "neuheiten"
title = "Neuheiten"
description = (
"Neuheitenliste aus der BoardGameGeek-API — regelmäßiger Sync als "
"Hintergrund-Job, Erweiterungen und Prototypen werden gefiltert."
)
def __init__(self) -> None:
super().__init__()
self._scheduler = None # BackgroundScheduler, falls aktiviert
self._sync_sperre = threading.Lock()
self._suchbegriffe: list[str] = []
self._max_treffer_pro_suche = 25
@self.router.get("/neuheiten")
def seite(
request: Request,
user: User = Depends(require_user),
q: str = "",
status: str = "",
sort: str = STANDARD_SORTIERUNG[0],
richtung: str = STANDARD_SORTIERUNG[1],
meldung: str = "",
fehler: str = "",
):
spalte = SORTIERBAR.get(sort, SORTIERBAR[STANDARD_SORTIERUNG[0]])
abwaerts = richtung == "ab" if sort in SORTIERBAR else False
spalte_sortiert = spalte.desc() if abwaerts else spalte.asc()
with self.context.session_factory() as db:
abfrage = select(Neuheit)
if q.strip():
muster = f"%{q.strip().lower()}%"
abfrage = abfrage.where(
or_(
func.lower(Neuheit.titel).like(muster),
func.lower(func.coalesce(Neuheit.verlag, "")).like(muster),
func.lower(func.coalesce(Neuheit.autor, "")).like(muster),
)
)
if status.strip():
abfrage = abfrage.where(Neuheit.status == status.strip())
eintraege = db.scalars(
abfrage.order_by(spalte_sortiert, Neuheit.id.asc())
).all()
status_optionen = [
zeile for zeile in db.scalars(
select(Neuheit.status).distinct().order_by(Neuheit.status)
)
]
kontext = {
"user": user,
"titel": self.title,
"eintraege": eintraege,
"q": q,
"status_filter": status,
"sort": sort if sort in SORTIERBAR else STANDARD_SORTIERUNG[0],
"richtung": "ab" if abwaerts else "auf",
"status_optionen": status_optionen,
"meldung": meldung[:MAX_MELDUNGS_LAENGE],
"fehler": fehler[:MAX_MELDUNGS_LAENGE],
"ist_redaktion": user.role in (
Role.ADMIN.value,
Role.REDAKTEUR.value,
),
"suchbegriffe": ", ".join(self._suchbegriffe) or "",
"sync_aktiv": self._scheduler is not None and self._scheduler.running,
"anzahl": len(eintraege),
}
if request.headers.get("HX-Request") == "true":
return self.context.templates.TemplateResponse(
request=request,
name="neuheiten/_liste.html",
context=kontext,
)
return self.context.templates.TemplateResponse(
request=request,
name="neuheiten/index.html",
context=kontext,
)
@self.router.post("/neuheiten/sync")
def jetzt_synchronisieren(
user: User = Depends(require_roles(Role.ADMIN.value, Role.REDAKTEUR.value)),
suchbegriff: str = Form(""),
):
begriffe = [suchbegriff] if suchbegriff.strip() else self._suchbegriffe
try:
ergebnis = self._sync_ausfuehren(begriffe)
except BggFehler as exc:
_logger.warning("Manueller BGG-Sync fehlgeschlagen: %s", exc)
ziel = f"/neuheiten?meldung={quote(f'Sync fehlgeschlagen: {exc}')}"
return RedirectResponse(ziel, status_code=303)
ziel = f"/neuheiten?meldung={quote(f'Sync abgeschlossen: {ergebnis.als_text()}')}"
return RedirectResponse(ziel, status_code=303)
# ---------- Plugin-Vertrag ----------
def migrations(self) -> list[Migration]:
def neuheiten_tabelle(conn) -> None:
Neuheit.__table__.create(conn, checkfirst=True)
return [Migration(version="0001_neuheiten_tabelle", up=neuheiten_tabelle)]
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/neuheiten")]
def on_load(self, context: PluginContext) -> None:
super().on_load(context)
import os
self._suchbegriffe = _umgebung_liste("SPIELE_BGG_SUCHBEGRIFFE", "brettspiel")
try:
self._max_treffer_pro_suche = int(
os.environ.get("SPIELE_BGG_MAX_TREFFER_PRO_SUCHE", "25")
)
except ValueError:
self._max_treffer_pro_suche = 25
if os.environ.get("SPIELE_BGG_SYNC_AKTIV", "1").strip() == "1":
self._scheduler_starten(os.environ.get("SPIELE_BGG_SYNC_INTERVALL_STUNDEN"))
def on_unload(self) -> None:
if self._scheduler is not None:
self._scheduler.shutdown(wait=False)
self._scheduler = None
super().on_unload()
# ---------- Internas ----------
def _neuer_client(self) -> BggClient:
"""Fabrik für den BGG-Client; von Tests überschreibbar."""
return BggClient()
def _sync_ausfuehren(self, suchbegriffe: list[str]):
client = self._neuer_client()
try:
service = SyncService(
self.context.session_factory,
client,
max_treffer_pro_suchbegriff=self._max_treffer_pro_suche,
)
return service.synchronisiere(suchbegriffe)
finally:
client.schliessen()
def _sync_job(self) -> None:
"""Hintergrund-Job: holt regelmäßig die Neuheiten ab."""
if not self._sync_sperre.acquire(blocking=False):
_logger.info("BGG-Sync läuft bereits — Durchlauf übersprungen.")
return
try:
ergebnis = self._sync_ausfuehren(self._suchbegriffe)
_logger.info("BGG-Sync abgeschlossen: %s", ergebnis.als_text())
except Exception as exc:
_logger.warning("BGG-Sync fehlgeschlagen: %s", exc)
finally:
self._sync_sperre.release()
def _scheduler_starten(self, intervall_stunden_roh: str | None) -> None:
from apscheduler.schedulers.background import BackgroundScheduler
try:
intervall_stunden = int(intervall_stunden_roh or "24")
except ValueError:
intervall_stunden = 24
intervall_stunden = max(intervall_stunden, 1)
scheduler = BackgroundScheduler()
erster_lauf = datetime.now(timezone.utc) + timedelta(seconds=10)
scheduler.add_job(
self._sync_job,
trigger="interval",
hours=intervall_stunden,
next_run_time=erster_lauf,
id="bgg-neuheiten-sync",
replace_existing=True,
)
scheduler.start()
self._scheduler = scheduler
_logger.info(
"BGG-Neuheiten-Sync aktiv: alle %s h, Suchbegriffe: %s",
intervall_stunden,
", ".join(self._suchbegriffe),
)
plugin = NeuheitenPlugin()