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.
249 lines
9.3 KiB
Python
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()
|