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:
ox-alpha
2026-08-21 19:10:43 +00:00
parent 83d574d95c
commit 56cc5943b1
16 changed files with 1732 additions and 28 deletions

View File

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

265
plugins/neuheiten/bgg.py Normal file
View File

@@ -0,0 +1,265 @@
"""Client für die BoardGameGeek XML API2 (https://boardgamegeek.com/xmlapi2).
Eigenschaften:
- Rate-Limit: mindestens `mindestabstand_sekunden` (Standard 1 s) zwischen
zwei HTTP-Requests.
- Retry mit exponentiellem Backoff bei 5xx/429; HTTP 202 (BGG-Warteschlange)
wird gemäß Retry-After-Header erneut versucht.
- Robustes XML-Parsing: fehlende Elemente werden toleriert, kaputte
Antworten erzeugen eine klare `BggFehler`-Ausnahme.
- Filter: Suchanfragen und Thing-Abfragen sind auf `type=boardgame`
beschränkt; Erweiterungen (`boardgameexpansion`) werden zusätzlich auf
Elementebene herausgefiltert.
Für Tests sind HTTP-Transport, Uhr und Schlaf-Funktion injizierbar —
es gibt keine echten Netzwerk-Aufrufe in der Testsuite.
"""
from __future__ import annotations
import time
import xml.etree.ElementTree as ET
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass
from typing import Any
import httpx
BASIS_URL = "https://boardgamegeek.com/xmlapi2"
USER_AGENT = "spiele-redaktion-neuheiten/0.1 (+https://github.com/local)"
TYP_BRETTSPIEL = "boardgame"
TYP_ERWEITERUNG = "boardgameexpansion"
THING_BATCH_GROESSE = 20
class BggFehler(Exception):
"""Fehler bei der Kommunikation mit oder dem Parsen der BGG-API."""
@dataclass(frozen=True)
class SuchTreffer:
"""Ein Treffer aus der BGG-Suche."""
bgg_id: int
titel: str
erscheinungsjahr: int | None
@dataclass(frozen=True)
class BggSpiel:
"""Ein vollständiges Spiel aus der Thing-Abfrage."""
bgg_id: int
titel: str
verlag: str | None
autor: str | None
erscheinungsjahr: int | None
typ: str = TYP_BRETTSPIEL
def _attribut_wert(element: ET.Element | None, tag: str) -> str | None:
"""Liest `<tag><value>…</value></tag>` tolerant aus."""
if element is None:
return None
kind = element.find(tag)
if kind is None:
return None
wert = kind.get("value")
return wert.strip() if wert and wert.strip() else None
def _jahr(wert: str | None) -> int | None:
if not wert:
return None
try:
return int(wert)
except ValueError:
return None
def _verketten(element: ET.Element | None, link_typ: str) -> str | None:
"""Verbindet alle `<link type="" value=""/>` eines Typs zu einem String."""
if element is None:
return None
werte = [
link.get("value", "").strip()
for link in element.findall("link")
if link.get("type") == link_typ and link.get("value", "").strip()
]
return ", ".join(werte) if werte else None
def parse_suche(xml_daten: bytes | str) -> list[SuchTreffer]:
"""Parst eine Search-Antwort; nicht-Brettspiele werden verworfen."""
try:
wurzel = ET.fromstring(xml_daten)
except ET.ParseError as exc:
raise BggFehler(f"Ungültiges XML in der Suchantwort: {exc}") from exc
treffer: list[SuchTreffer] = []
for element in wurzel.findall("item"):
if element.get("type") != TYP_BRETTSPIEL:
continue # Erweiterungen/Prototyp-Typen auf Elementebene ausschließen
id_roh = element.get("id")
titel = _attribut_wert(element, "name")
if not id_roh or not titel:
continue
try:
bgg_id = int(id_roh)
except ValueError:
continue
treffer.append(
SuchTreffer(
bgg_id=bgg_id,
titel=titel,
erscheinungsjahr=_jahr(_attribut_wert(element, "yearpublished")),
)
)
return treffer
def parse_things(xml_daten: bytes | str) -> list[BggSpiel]:
"""Parst eine Thing-Antwort; Erweiterungen werden verworfen."""
try:
wurzel = ET.fromstring(xml_daten)
except ET.ParseError as exc:
raise BggFehler(f"Ungültiges XML in der Thing-Antwort: {exc}") from exc
spiele: list[BggSpiel] = []
for element in wurzel.findall("item"):
typ = element.get("type") or TYP_BRETTSPIEL
if typ != TYP_BRETTSPIEL:
continue # boardgameexpansion & Co. ausschließen
id_roh = element.get("id")
titel = _attribut_wert(element, "name")
if not id_roh or not titel:
continue
try:
bgg_id = int(id_roh)
except ValueError:
continue
spiele.append(
BggSpiel(
bgg_id=bgg_id,
titel=titel,
verlag=_verketten(element, "boardgamepublisher"),
autor=_verketten(element, "boardgamedesigner"),
erscheinungsjahr=_jahr(_attribut_wert(element, "yearpublished")),
typ=typ,
)
)
return spiele
def _stuecke(werte: Sequence[int], groesse: int) -> Iterable[Sequence[int]]:
for start in range(0, len(werte), groesse):
yield werte[start : start + groesse]
class BggClient:
"""HTTP-Client für die XML API2 mit Rate-Limit und Retry/Backoff."""
def __init__(
self,
*,
transport: httpx.BaseTransport | None = None,
mindestabstand_sekunden: float = 1.0,
max_versuche: int = 4,
backoff_basis_sekunden: float = 1.0,
backoff_maximum_sekunden: float = 8.0,
timeout_sekunden: float = 30.0,
schlaf: Callable[[float], None] = time.sleep,
uhr: Callable[[], float] = time.monotonic,
) -> None:
self.mindestabstand_sekunden = mindestabstand_sekunden
self.max_versuche = max_versuche
self.backoff_basis_sekunden = backoff_basis_sekunden
self.backoff_maximum_sekunden = backoff_maximum_sekunden
self._schlaf = schlaf
self._uhr = uhr
self._letzter_request_um: float | None = None
self._http = httpx.Client(
base_url=BASIS_URL,
timeout=timeout_sekunden,
transport=transport,
headers={"User-Agent": USER_AGENT},
)
def schliessen(self) -> None:
self._http.close()
def _rate_limit_abwarten(self) -> None:
if self._letzter_request_um is None:
return
vergangen = self._uhr() - self._letzter_request_um
rest = self.mindestabstand_sekunden - vergangen
if rest > 0:
self._schlaf(rest)
def _hole(self, pfad: str, params: dict[str, Any]) -> bytes:
letzte_fehler: Exception | None = None
for versuch in range(self.max_versuche):
self._rate_limit_abwarten()
try:
antwort = self._http.get(pfad, params=params)
except httpx.HTTPError as exc:
letzte_fehler = exc
wartezeit = min(
self.backoff_basis_sekunden * (2**versuch),
self.backoff_maximum_sekunden,
)
self._schlaf(wartezeit)
continue
self._letzter_request_um = self._uhr()
if antwort.status_code == 200:
return antwort.content
if antwort.status_code in (202, 429) or antwort.status_code >= 500:
# 202: BGG stellt die Anfrage in eine Warteschlange;
# 429/5xx: temporär nicht bedienbar → Backoff und erneut.
retry_after = antwort.headers.get("Retry-After")
if retry_after:
try:
wartezeit = float(retry_after)
except ValueError:
wartezeit = None
else:
wartezeit = None
if wartezeit is None:
wartezeit = min(
self.backoff_basis_sekunden * (2**versuch),
self.backoff_maximum_sekunden,
)
self._schlaf(wartezeit)
letzte_fehler = BggFehler(
f"HTTP {antwort.status_code} von der BGG-API (Versuch {versuch + 1})."
)
continue
raise BggFehler(
f"Unerwartete HTTP-Antwort {antwort.status_code} für {pfad}."
)
raise BggFehler(
f"BGG-API nach {self.max_versuche} Versuchen nicht erreichbar"
f" ({pfad}): {letzte_fehler}"
)
def suche(
self, suchbegriff: str, *, max_treffer: int | None = None
) -> list[SuchTreffer]:
"""Sucht Brettspiele (`type=boardgame`) nach einem Suchbegriff."""
treffer = parse_suche(
self._hole("/search", {"query": suchbegriff, "type": TYP_BRETTSPIEL})
)
return treffer[:max_treffer] if max_treffer is not None else treffer
def details(self, bgg_ids: Sequence[int]) -> list[BggSpiel]:
"""Lädt Thing-Details (in Batches) und filtert Erweiterungen heraus."""
spiele: list[BggSpiel] = []
for batch in _stuecke(list(bgg_ids), THING_BATCH_GROESSE):
xml_daten = self._hole(
"/thing",
{"id": ",".join(str(i) for i in batch), "type": TYP_BRETTSPIEL},
)
spiele.extend(parse_things(xml_daten))
return spiele

View File

@@ -0,0 +1,37 @@
"""Datenmodell des Plugins „neuheiten“.
Ein Eintrag repräsentiert ein Spiel der Neuheitenliste. Die BGG-ID ist das
eindeutige Merge-Kriterium: Beim Sync werden bestehende Einträge aktualisiert,
statt Duplikate anzulegen.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from redaktionskern.db import Base
STATUS_NEUHEIT = "neuheit"
class Neuheit(Base):
__tablename__ = "neuheiten"
id: Mapped[int] = mapped_column(primary_key=True)
titel: Mapped[str] = mapped_column(String(300), index=True)
verlag: Mapped[str | None] = mapped_column(String(300))
autor: Mapped[str | None] = mapped_column(String(300))
# BoardGameGeek liefert nur das Erscheinungsjahr (kein genaues Datum).
erscheinungsjahr: Mapped[int | None] = mapped_column(Integer)
bgg_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
status: Mapped[str] = mapped_column(String(50), default=STATUS_NEUHEIT, index=True)
quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek")
erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
aktualisiert_am: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
return f"<Neuheit {self.bgg_id} {self.titel!r}>"

133
plugins/neuheiten/sync.py Normal file
View File

@@ -0,0 +1,133 @@
"""Sync-Logik des Plugins „neuheiten“: BGG-Suche → Filter → Upsert.
- Update statt Duplikat: bestehende Einträge werden über die eindeutige
BGG-ID gefunden und aktualisiert (Status bleibt erhalten).
- Erweiterungen werden im Client und hier auf Elementebene gefiltert.
- Prototypen: Die BGG-XML-API2 liefert keinen verlässlichen Prototyp-Marker;
als Heuristik werden Titel mit Prototyp-Schlüsselwörtern ausgeschlossen
(dokumentierte Einschränkung, siehe README).
"""
from __future__ import annotations
import logging
import re
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from .bgg import BggSpiel, BggClient
from .models import STATUS_NEUHEIT, Neuheit
_logger = logging.getLogger("plugins.neuheiten")
PROTOTYP_MUSTER = re.compile(
r"(?i)\b(prototyp\w*|prototype|proto)\b"
)
THING_BATCH_GROESSE = 20
def ist_moeglicher_prototyp(titel: str) -> bool:
"""Heuristik: Titel klingt nach Prototyp/Protoversion."""
return bool(PROTOTYP_MUSTER.search(titel))
@dataclass
class SyncErgebnis:
"""Zusammenfassung eines Sync-Laufs."""
neu: int = 0
aktualisiert: int = 0
gefiltert: int = 0
fehler: list[str] = field(default_factory=list)
def als_text(self) -> str:
text = (
f"{self.neu} neu, {self.aktualisiert} aktualisiert, "
f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen)"
)
if self.fehler:
text += f"; {len(self.fehler)} Fehler"
return text
def speichere_oder_aktualisiere(db: Session, spiel: BggSpiel) -> bool:
"""Upsert nach BGG-ID. Rückgabe: True = neu angelegt, False = aktualisiert."""
vorhanden = db.scalar(select(Neuheit).where(Neuheit.bgg_id == spiel.bgg_id))
if vorhanden is None:
db.add(
Neuheit(
titel=spiel.titel,
verlag=spiel.verlag,
autor=spiel.autor,
erscheinungsjahr=spiel.erscheinungsjahr,
bgg_id=spiel.bgg_id,
status=STATUS_NEUHEIT,
quelle="boardgamegeek",
)
)
return True
vorhanden.titel = spiel.titel
vorhanden.verlag = spiel.verlag
vorhanden.autor = spiel.autor
vorhanden.erscheinungsjahr = spiel.erscheinungsjahr
return False
class SyncService:
"""Führt einen Sync-Lauf aus: Suchen → Details → Filter → Upsert."""
def __init__(
self,
session_factory: sessionmaker,
client: BggClient,
*,
thing_batch_groesse: int = THING_BATCH_GROESSE,
max_treffer_pro_suchbegriff: int | None = None,
) -> None:
self.session_factory = session_factory
self.client = client
self.thing_batch_groesse = thing_batch_groesse
self.max_treffer_pro_suchbegriff = max_treffer_pro_suchbegriff
def synchronisiere(self, suchbegriffe: Iterable[str]) -> SyncErgebnis:
ergebnis = SyncErgebnis()
with self.session_factory() as db:
for suchbegriff in suchbegriffe:
begriff = suchbegriff.strip()
if not begriff:
continue
try:
self._ein_suchbegriff(db, begriff, ergebnis)
except Exception as exc: # ein Begriff darf den Lauf nicht abbrechen
_logger.warning("BGG-Sync fehlgeschlagen für %r: %s", begriff, exc)
ergebnis.fehler.append(f"{begriff}: {exc}")
db.commit()
return ergebnis
def _ein_suchbegriff(
self, db: Session, suchbegriff: str, ergebnis: SyncErgebnis
) -> None:
treffer = self.client.suche(
suchbegriff, max_treffer=self.max_treffer_pro_suchbegriff
)
ids = [t.bgg_id for t in treffer]
for batch in _batches(ids, self.thing_batch_groesse):
for spiel in self.client.details(list(batch)):
if spiel.typ != "boardgame":
ergebnis.gefiltert += 1
continue
if ist_moeglicher_prototyp(spiel.titel):
ergebnis.gefiltert += 1
continue
if speichere_oder_aktualisiere(db, spiel):
ergebnis.neu += 1
else:
ergebnis.aktualisiert += 1
def _batches(werte: Sequence[int], groesse: int) -> Iterable[Sequence[int]]:
for start in range(0, len(werte), groesse):
yield werte[start : start + groesse]

View File

@@ -0,0 +1,49 @@
<div id="neuheiten-liste">
<p class="text-xs text-slate-500 mb-2">{{ anzahl }} Eintr&auml;ge</p>
<div class="overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
{% set basis = '/neuheiten?q=' ~ (q | urlencode) ~ '&status=' ~ (status_filter | urlencode) %}
{% for key, label in [('titel', 'Spieltitel'), ('verlag', 'Verlag'), ('autor', 'Autor'), ('jahr', 'Erscheinungsjahr'), ('status', 'Status'), ('aktualisiert', 'Aktualisiert')] %}
<th class="px-4 py-3 font-medium whitespace-nowrap">
{% if sort == key %}
<a href="{{ basis }}&sort={{ key }}&richtung={{ 'auf' if richtung == 'ab' else 'ab' }}"
class="hover:text-emerald-700">{{ label }} {{ '&#9650;' if richtung == 'auf' else '&#9660;' }}</a>
{% else %}
<a href="{{ basis }}&sort={{ key }}&richtung=auf" class="hover:text-emerald-700 text-slate-400">{{ label }}</a>
{% endif %}
</th>
{% endfor %}
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for eintrag in eintraege %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-medium">
{{ eintrag.titel }}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
</td>
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
<td class="px-4 py-3">{{ eintrag.autor or '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ eintrag.erscheinungsjahr if eintrag.erscheinungsjahr else '—' }}</td>
<td class="px-4 py-3">
<span class="inline-block px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 text-xs">{{ eintrag.status }}</span>
</td>
<td class="px-4 py-3 whitespace-nowrap text-slate-500">
{{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="px-4 py-8 text-center text-slate-400">
Keine Eintr&auml;ge gefunden.
{% if ist_redaktion %}Nutze &bdquo;Jetzt synchronisieren&ldquo;, um die Liste aus BoardGameGeek zu befüllen.{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>

View File

@@ -2,9 +2,58 @@
{% 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">
Neuheitenliste aus der <a href="https://boardgamegeek.com" class="text-emerald-700 underline">BoardGameGeek</a>-Datenbank.
Erweiterungen und Prototypen werden beim Sync automatisch gefiltert.
</p>
{% if meldung %}
<div id="meldung"
class="mb-4 px-4 py-3 rounded-lg border text-sm
{% if meldung.startswith('Sync fehlgeschlagen') %}bg-red-50 border-red-200 text-red-700{% else %}bg-emerald-50 border-emerald-200 text-emerald-800{% endif %}">
{{ meldung }}
</div>
{% endif %}
<div class="flex flex-wrap items-end gap-3 mb-4">
{% if ist_redaktion %}
<form method="post" action="/neuheiten/sync" class="flex items-end gap-2">
<div>
<label for="suchbegriff" class="block text-xs font-medium text-slate-500 mb-1">
Sofort auf BoardGameGeek suchen &amp; importieren (optional)
</label>
<input type="text" id="suchbegriff" name="suchbegriff" placeholder="Spielname oder Suchbegriff …"
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>
<button type="submit"
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
&#8635; Jetzt synchronisieren
</button>
</form>
{% endif %}
<p class="text-xs text-slate-400 ml-auto">
Hintergrund-Sync:
{% if sync_aktiv %}<span class="text-emerald-700 font-medium">aktiv</span>{% else %}<span class="text-slate-500">aus</span>{% endif %}
&middot; Suchbegriffe: {{ suchbegriffe }}
</p>
</div>
<form method="get" action="/neuheiten"
hx-get="/neuheiten" hx-target="#neuheiten-liste" hx-swap="outerHTML"
hx-trigger="input changed delay:300ms from:find input[name='q'], change from:find select[name='status']"
class="flex flex-wrap gap-3 mb-4">
<input type="text" name="q" value="{{ q }}" placeholder="Suche in Titel, Verlag, Autor …"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-80 focus:outline-none focus:ring-2 focus:ring-emerald-500">
<select name="status" class="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white">
<option value="">Alle Status</option>
{% for option in status_optionen %}
<option value="{{ option }}" {% if option == status_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
<noscript>
<button type="submit" class="bg-slate-200 hover:bg-slate-300 px-4 py-2 rounded-lg text-sm">Filtern</button>
</noscript>
</form>
{% include "neuheiten/_liste.html" %}
{% endblock %}