Plugin neuheiten: BGG-Sync mit APScheduler, Filtern und deutscher UI
- Datenmodell + eigene Migration 0001_neuheiten_tabelle (Tabelle neuheiten: Titel, Verlag, Autor, Erscheinungsjahr, BGG-ID, Status 'neuheit', Quelle, Zeitstempel; bgg_id eindeutig als Merge-Kriterium) - BoardGameGeek XML API2-Client (search + thing, Batches à 20 IDs): Rate-Limit >= 1 s zwischen Requests, Retry mit exponentiellem Backoff bei 5xx/429/Netzwerkfehlern, HTTP 202 gemäß Retry-After, robustes XML-Parsing; Transport/Uhr/Sleep injizierbar (keine echten Calls in Tests) - Filter: Erweiterungen (boardgameexpansion) auf Request- und Elementebene ausgeschlossen; Prototypen per Titel-Heuristik (BGG hat keinen Marker) - Sync-Service mit Update-statt-Duplikat-Logik über die eindeutige BGG-ID (Status bleibt erhalten); Fehler je Suchbegriff brechen den Lauf nicht ab - APScheduler-Hintergrundjob (Standard 24 h) mit Überlappungsschutz, abschaltbar/intervallkonfigurierbar per Env; manueller 'Jetzt synchronisieren'-Endpunkt nur für Admin/Redakteur, optional mit Sofort-Suchbegriff - UI /neuheiten: sortier-/filterbare Tabelle mit Volltextsuche (HTMX-Teilladung, noscript-fähig), deutsche Oberfläche, BGG-Links, Ergebnis-Banner - Plugin-Loader: idempotentes Laden (Modul-Caching), damit mehrere create_app()-Aufrufe dieselben Plugin-Klassen/Tabellen nutzen - Tests: Parsing, Erweiterungs-/Prototyp-Filter, Rate-Limit/Backoff/202, Update-statt-Duplikat, Rollen am Sync-Endpunkt, Scheduler-Lifecycle — ausschließlich mit gemockten BGG-Antworten (uv run pytest: 96 grün) - README/AGENTS: Plugin-Doku, Env-Variablen, Fortschrittstabelle aktualisiert
This commit is contained in:
@@ -1,41 +1,246 @@
|
||||
"""Plugin „neuheiten“ — Platzhalter gemäß Plugin-Vertrag.
|
||||
"""Plugin „neuheiten“ — Neuheitenliste aus der BoardGameGeek XML API2.
|
||||
|
||||
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
|
||||
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
|
||||
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
|
||||
|
||||
from fastapi import Depends, Request
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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 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 = "KI-gestützte Neuheitenliste aus der BoardGameGeek-API (Platzhalter)."
|
||||
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)):
|
||||
"""Platzhalterseite des Plugins."""
|
||||
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 = "",
|
||||
):
|
||||
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],
|
||||
"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={
|
||||
"user": user,
|
||||
"titel": self.title,
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
},
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user