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

@@ -4,8 +4,9 @@
Multi-User-Webanwendung „KI-Assistenz für Spielemagazin-Redaktionen": Neuheitenliste (BGG), Dedup-Prüfungen, Planungsliste, Archivierung, Erinnerungen, Benachrichtigungen (E-Mail/Telegram/In-App), Audit-Log, Export. Multi-User-Webanwendung „KI-Assistenz für Spielemagazin-Redaktionen": Neuheitenliste (BGG), Dedup-Prüfungen, Planungsliste, Archivierung, Erinnerungen, Benachrichtigungen (E-Mail/Telegram/In-App), Audit-Log, Export.
## Status ## Status
Kern mit Plugin-System/Auth/Migrationen fertig; `audit-log` vollständig Kern mit Plugin-System/Auth/Migrationen fertig; `audit-log`,
implementiert; `neuheiten`/`benachrichtigung` in Arbeit; übrige Plugins Stubs. `benachrichtigung` und `neuheiten` (BGG-Sync) vollständig implementiert;
übrige Plugins Stubs.
Live-Status: README.md → „Projekt-Fortschritt“. Live-Status: README.md → „Projekt-Fortschritt“.
## Struktur ## Struktur

View File

@@ -2,8 +2,8 @@
**KI-Assistenz für Spielemagazin-Redaktionen** — Multi-User-Webanwendung mit **KI-Assistenz für Spielemagazin-Redaktionen** — Multi-User-Webanwendung mit
modularer Plugin-Architektur. Lauffähiger Kern mit Plugin-System, modularer Plugin-Architektur. Lauffähiger Kern mit Plugin-System,
Authentifizierung/Rollen und Migrationen; als erstes Fachplugin ist das Authentifizierung/Rollen und Migrationen; vollständig implementiert sind
**Audit-Log** vollständig implementiert, weitere Plugins folgen. bisher **Audit-Log**, **Benachrichtigung** und **Neuheiten (BGG-Sync)**.
## Stack ## Stack
@@ -12,6 +12,7 @@ Authentifizierung/Rollen und Migrationen; als erstes Fachplugin ist das
- **Auth:** Session-Cookies (signiert), Passwort-Hashing mit Argon2id - **Auth:** Session-Cookies (signiert), Passwort-Hashing mit Argon2id
- **Plugins:** Entry-Points (`importlib.metadata`, Gruppe `spiele_redaktion.plugins`) - **Plugins:** Entry-Points (`importlib.metadata`, Gruppe `spiele_redaktion.plugins`)
plus lokales `plugins/`-Verzeichnis plus lokales `plugins/`-Verzeichnis
- **Hintergrund-Jobs:** APScheduler (BGG-Neuheiten-Sync)
- **Dependencies/Tests:** [uv](https://docs.astral.sh/uv/) + pytest - **Dependencies/Tests:** [uv](https://docs.astral.sh/uv/) + pytest
## Setup ## Setup
@@ -47,6 +48,10 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt:
| `SPIELE_SMTP_ABSENDER` | Benutzer bzw. `spiele-redaktion@localhost` | From-Adresse | | `SPIELE_SMTP_ABSENDER` | Benutzer bzw. `spiele-redaktion@localhost` | From-Adresse |
| `SPIELE_SMTP_TLS` | `starttls` | `starttls`, `ssl` oder `keine` | | `SPIELE_SMTP_TLS` | `starttls` | `starttls`, `ssl` oder `keine` |
| `SPIELE_TELEGRAM_BOT_TOKEN` | *(leer)* | Bot-Token für den Telegram-Kanal; leer = Dev-Fallback (nur Protokoll) | | `SPIELE_TELEGRAM_BOT_TOKEN` | *(leer)* | Bot-Token für den Telegram-Kanal; leer = Dev-Fallback (nur Protokoll) |
| `SPIELE_BGG_SYNC_AKTIV` | `1` | Hintergrund-Sync des Neuheiten-Plugins an (`1`) oder aus (`0`) |
| `SPIELE_BGG_SYNC_INTERVALL_STUNDEN` | `24` | Intervall des BGG-Syncs in Stunden (min. 1) |
| `SPIELE_BGG_SUCHBEGRIFFE` | `brettspiel` | Komma-getrennte Suchbegriffe für den regelmäßigen Sync |
| `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `25` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) |
## Tests ## Tests
@@ -59,7 +64,10 @@ Abgedeckt: Plugin-Loader lädt alle Plugins (inkl. Lifecycle-Hooks und
Entry-Point-Pfad), Migrations-Laufzeit inkl. Idempotenz, Login/Logout, Entry-Point-Pfad), Migrations-Laufzeit inkl. Idempotenz, Login/Logout,
Rollen-Zugriff (admin/redakteur vs. rezensent), Benutzerverwaltung sowie das Rollen-Zugriff (admin/redakteur vs. rezensent), Benutzerverwaltung sowie das
Audit-Log-Plugin (Logging-Funktion, Migration, Filter, Paginierung, Audit-Log-Plugin (Logging-Funktion, Migration, Filter, Paginierung,
Nur-Admin-Zugriff). Nur-Admin-Zugriff) sowie das Neuheiten-Plugin: BGG-Parsing, Erweiterungs-/
Prototyp-Filter, Rate-Limit & Retry-Backoff (gemockte HTTP-Antworten, keine
echten API-Calls), Update-statt-Duplikat-Logik, UI-Suche/Filter/Sortierung,
Rollen am Sync-Endpunkt und Scheduler-Lifecycle.
## Architektur ## Architektur
@@ -75,7 +83,8 @@ src/redaktionskern/ schlanker Kern — KEINE Fachlogik
plugins/ ein Ordner pro Funktion, ladbar über den Plugin-Loader plugins/ ein Ordner pro Funktion, ladbar über den Plugin-Loader
├── audit-log/ VOLL IMPLEMENTIERT: Model, Migration, API, Admin-Ansicht ├── audit-log/ VOLL IMPLEMENTIERT: Model, Migration, API, Admin-Ansicht
├── neuheiten/ benachrichtigung/ (in Arbeit) ├── neuheiten/ VOLL IMPLEMENTIERT: BGG-Client, Sync-Job, Migration, UI
├── benachrichtigung/ VOLL IMPLEMENTIERT: E-Mail/Telegram/In-App
├── dedup/ planung/ archiv/ erinnerung/ export/ (Stubs, ladbar) ├── dedup/ planung/ archiv/ erinnerung/ export/ (Stubs, ladbar)
└── … je __init__.py + templates/<name>/ └── … je __init__.py + templates/<name>/
``` ```
@@ -196,6 +205,70 @@ bericht = await plugin.send_notification(user, "Titel", "Nachrichtentext", "kate
einzelne Kanalausfälle werden protokolliert und im Bericht gemeldet. Die einzelne Kanalausfälle werden protokolliert und im Bericht gemeldet. Die
In-App-Nachricht landet im Posteingang des Benutzers. In-App-Nachricht landet im Posteingang des Benutzers.
## Plugin „neuheiten“ (implementiert)
Neuheitenliste auf Basis der **BoardGameGeek XML API2**
(`https://boardgamegeek.com/xmlapi2`). Regelmäßiger Sync als Hintergrund-Job,
manuelle Synchronisation für die Redaktion, deutsche sortier-/filterbare Liste.
### Datentabelle
Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten`
(SQLite ↔ Postgres portabel):
| Spalte | Inhalt |
|--------|--------|
| `titel` | Spieltitel |
| `verlag` / `autor` | Verlag bzw. Autor(en), kommagetrennt |
| `erscheinungsjahr` | Erscheinungsjahr — BGG liefert nur das Jahr, kein genaues Datum |
| `bgg_id` | BoardGameGeek-ID, eindeutig → Merge-Kriterium des Syncs |
| `status` | `neuheit` (spätere Plugins: Planung/Archiv) |
| `quelle` | Datenquelle (`boardgamegeek`) |
| `erstellt_am` / `aktualisiert_am` | Zeitstempel |
### BGG-Client & Sync-Ablauf
Pro Suchbegriff: `/search?type=boardgame&query=…` → IDs sammeln →
`/thing?id=…&type=boardgame` in Batches à 20 IDs.
- **Rate-Limit:** mindestens 1 Sekunde zwischen zwei Requests.
- **Retry mit Backoff:** exponentielles Zurückhalten bei 5xx, 429 und
Netzwerkfehlern; HTTP 202 (BGG-Warteschlange) wird gemäß `Retry-After`
erneut versucht; kaputtes XML erzeugt eine klare Fehlermeldung.
- **Filter:**
- Erweiterungen (`type=boardgameexpansion`) werden auf Request-Ebene
(`type=boardgame`) und zusätzlich pro Element ausgeschlossen.
- Prototypen: Die XML API2 liefert keinen Prototyp-Marker; als Heuristik
werden Titel mit Prototyp-Schlüsselwörtern („Prototyp“, „Prototype“, …)
gefiltert. Restrisiko verbleibt — redaktionelle Prüfung bleibt wichtig.
- **Update statt Duplikat:** bestehende Einträge werden über die eindeutige
`bgg_id` aktualisiert (Titel/Verlag/Autor/Jahr), der `status` bleibt
erhalten; neu angelegt wird nur bei unbekannter BGG-ID.
### Hintergrund-Job & manuelle Synchronisation
Beim Start registriert das Plugin einen APScheduler-Job (Standard: alle 24 h,
konfigurierbar über `SPIELE_BGG_SYNC_INTERVALL_STUNDEN`, abschaltbar über
`SPIELE_BGG_SYNC_AKTIV=0`). Die Suchbegriffe kommen aus
`SPIELE_BGG_SUCHBEGRIFFE` (komma-getrennt). Läuft ein Sync noch, wird ein
überlappender Durchlauf übersprungen.
Unter **Neuheiten → „Jetzt synchronisieren“** lösen Admins und Redakteure
einen sofortigen Sync aus — optional mit einem Sofort-Suchbegriff für eine
gezielte Recherche. Das Ergebnis (neu/aktualisiert/gefiltert) erscheint als
Banner; der Endpunkt ist rollengeschützt (Rezensenten erhalten 403).
### UI
Die Seite `/neuheiten` zeigt alle Einträge als Tabelle (Spieltitel, Verlag,
Autor, Erscheinungsjahr, Status, aktualisiert am):
- Sortierung per Spaltenkopf, Suche über Titel/Verlag/Autor und Statusfilter —
serverseitig umgesetzt, per HTMX ohne Seitenreload (ohne JavaScript als
normaler Formular-GET nutzbar).
- Jeder Titel verlinkt direkt auf den BGG-Eintrag.
- Sync-Steuerung nur für Rolle Admin/Redakteur.
## Deployment (später) ## Deployment (später)
Docker/Podman Compose ist vorgesehen (Henry-Lab, danach Kundenhardware). Docker/Podman Compose ist vorgesehen (Henry-Lab, danach Kundenhardware).
@@ -211,7 +284,7 @@ geschrieben.
| 1 | Kern: FastAPI, Plugin-System, DB/Migrationen, Auth/Rollen, HTMX-Layout | ✅ fertig (28 Tests grün) | | 1 | Kern: FastAPI, Plugin-System, DB/Migrationen, Auth/Rollen, HTMX-Layout | ✅ fertig (28 Tests grün) |
| 2 | Plugin `benachrichtigung` (E-Mail/Telegram/In-App, User-Präferenzen) | 🔄 in Arbeit | | 2 | Plugin `benachrichtigung` (E-Mail/Telegram/In-App, User-Präferenzen) | 🔄 in Arbeit |
| 3 | Plugin `audit-log` (Wer/Was/Wann, Admin-Ansicht, öffentliche API) | ✅ fertig | | 3 | Plugin `audit-log` (Wer/Was/Wann, Admin-Ansicht, öffentliche API) | ✅ fertig |
| 4 | Plugin `neuheiten` (BGG-Sync, APScheduler, Filter) | 🔄 in Arbeit | | 4 | Plugin `neuheiten` (BGG-Sync, APScheduler, Filter) | ✅ fertig |
| 5 | Plugin `dedup` (Verlags-Konflikt, deutsche Version, Vorgänger-/Planungs-Check) | ⏳ offen | | 5 | Plugin `dedup` (Verlags-Konflikt, deutsche Version, Vorgänger-/Planungs-Check) | ⏳ offen |
| 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ⏳ offen | | 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ⏳ offen |
| 7 | Plugin `archiv` (12-Monats-Autopilot) | ⏳ offen | | 7 | Plugin `archiv` (12-Monats-Autopilot) | ⏳ offen |
@@ -225,6 +298,6 @@ Legende: ✅ fertig · 🔄 in Arbeit · ⏳ offen · ⚠️ fertig mit offenen
## Offene Punkte (nicht Teil von Phase 1) ## Offene Punkte (nicht Teil von Phase 1)
- CSRF-Schutz für Formulare (aktuell SameSite=Lax-Cookie als Basisschutz) - CSRF-Schutz für Formulare (aktuell SameSite=Lax-Cookie als Basisschutz)
- Hintergrund-Jobs (APScheduler) für BGG-Sync und Erinnerungen - Hintergrund-Jobs (APScheduler) für Erinnerungen
- WeasyPrint für PDF-Export (Systemabhängigkeiten im Container) - WeasyPrint für PDF-Export (Systemabhängigkeiten im Container)
- Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets - Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets

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: Umfang:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle. - 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 __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 fastapi import Depends, Form, Request
from redaktionskern.auth.models import User from fastapi.responses import RedirectResponse
from redaktionskern.contracts import BasePlugin, NavEntry 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): class NeuheitenPlugin(BasePlugin):
name = "neuheiten" name = "neuheiten"
title = "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: def __init__(self) -> None:
super().__init__() 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") @self.router.get("/neuheiten")
def seite(request: Request, user: User = Depends(require_user)): def seite(
"""Platzhalterseite des Plugins.""" 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( return self.context.templates.TemplateResponse(
request=request, request=request,
name="neuheiten/index.html", name="neuheiten/index.html",
context={ context=kontext,
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
},
) )
@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]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/neuheiten")] 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() 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 titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1> <h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-2xl"> <p class="text-slate-600 mb-4">
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code> Neuheitenliste aus der <a href="https://boardgamegeek.com" class="text-emerald-700 underline">BoardGameGeek</a>-Datenbank.
in Version {{ version }} ist geladen. Erweiterungen und Prototypen werden beim Sync automatisch gefiltert.
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
</p> </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 %} {% endblock %}

View File

@@ -12,6 +12,8 @@ dependencies = [
"python-multipart>=0.0.9", "python-multipart>=0.0.9",
"itsdangerous>=2.1", "itsdangerous>=2.1",
"argon2-cffi>=23.1", "argon2-cffi>=23.1",
"apscheduler>=3.11,<4",
"httpx>=0.27",
] ]
[dependency-groups] [dependency-groups]

View File

@@ -61,6 +61,20 @@ def _aus_verzeichnis(verzeichnis: Path) -> list[BasePlugin]:
if not init.is_file(): if not init.is_file():
continue continue
modulname = f"{_MODUL_PRAEFIX}.{eintrag.name}" modulname = f"{_MODUL_PRAEFIX}.{eintrag.name}"
# Bereits geladenes Modul wiederverwenden (idempotentes Laden):
# Mehrere create_app()-Aufrufe im selben Prozess teilen sich sonst
# doppelte Klassen/Tabellen auf derselben SQLAlchemy-MetaData.
zwischengespeichert = sys.modules.get(modulname)
if (
zwischengespeichert is not None
and hasattr(zwischengespeichert, "plugin")
and getattr(zwischengespeichert.__spec__, "origin", None)
and Path(zwischengespeichert.__spec__.origin).resolve() == init.resolve()
):
plugins.append(_als_plugin(zwischengespeichert.plugin))
continue
spec = importlib.util.spec_from_file_location( spec = importlib.util.spec_from_file_location(
modulname, init, submodule_search_locations=[str(eintrag)] modulname, init, submodule_search_locations=[str(eintrag)]
) )

42
tests/_neuheiten.py Normal file
View File

@@ -0,0 +1,42 @@
"""Zugriff auf die neuheiten-Plugin-Module über den vom Plugin-Loader
verwendeten Modulnamen (`spiele_redaktion_plugins.neuheiten`).
Der Kern teilt sich eine SQLAlchemy-MetaData; deshalb darf es pro Prozess
nur eine Modul-Identität geben. Tests importieren das Plugin daher über
diesen Helper statt direkt über `plugins.neuheiten`.
"""
from __future__ import annotations
import sys
from pathlib import Path
from redaktionskern.plugin_loader import discover_plugins
_PROJEKT_WURZEL = Path(__file__).resolve().parents[1]
if "spiele_redaktion_plugins.neuheiten" not in sys.modules:
discover_plugins(
_PROJEKT_WURZEL / "plugins",
entry_point_group="spiele_redaktion.tests.keine_entry_points",
)
_neuheiten = sys.modules["spiele_redaktion_plugins.neuheiten"]
bgg = _neuheiten.bgg
sync_modul = _neuheiten.sync
models = _neuheiten.models
BggClient = bgg.BggClient
BggFehler = bgg.BggFehler
SuchTreffer = bgg.SuchTreffer
BggSpiel = bgg.BggSpiel
parse_suche = bgg.parse_suche
parse_things = bgg.parse_things
SyncService = sync_modul.SyncService
SyncErgebnis = sync_modul.SyncErgebnis
ist_moeglicher_prototyp = sync_modul.ist_moeglicher_prototyp
speichere_oder_aktualisiere = sync_modul.speichere_oder_aktualisiere
Neuheit = models.Neuheit
STATUS_NEUHEIT = models.STATUS_NEUHEIT

View File

@@ -14,6 +14,16 @@ PROJEKT_WURZEL = Path(__file__).resolve().parents[1]
ADMIN_PASSWORD = "test-admin-123" ADMIN_PASSWORD = "test-admin-123"
@pytest.fixture(autouse=True)
def _hintergrundjobs_deaktiviert(monkeypatch):
"""BGG-Hintergrund-Sync in Tests standardmäßig aus (keine Threads/Netzwerk).
Einzelne Tests aktivieren den Scheduler explizit wieder per
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "1").
"""
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "0")
@pytest.fixture @pytest.fixture
def settings(tmp_path) -> Settings: def settings(tmp_path) -> Settings:
return Settings( return Settings(

308
tests/test_neuheiten_bgg.py Normal file
View File

@@ -0,0 +1,308 @@
"""Tests für den BGG-XML-API2-Client — ausschließlich mit gemockten Antworten.
Kein echter Netzwerkverkehr: Der HTTP-Transport wird durch Fakes ersetzt,
Uhr und Schlaf-Funktion sind injiziert.
"""
from __future__ import annotations
import httpx
import pytest
from tests._neuheiten import BggClient, BggFehler, parse_suche, parse_things
SUCHE_XML = """<?xml version="1.0" encoding="utf-8"?>
<items total="3" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
<item type="boardgame" id="13">
<name value="Catan"/>
<yearpublished value="1995"/>
</item>
<item type="boardgameexpansion" id="55">
<name value="Catan: Erweiterung"/>
<yearpublished value="1996"/>
</item>
<item type="boardgame" id="9999">
<name value="Mysterium"/>
</item>
</items>
"""
THING_XML = """<?xml version="1.0" encoding="utf-8"?>
<items termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
<item type="boardgame" id="13">
<thumbnail>https://example.org/thumb.jpg</thumbnail>
<name type="primary" sortindex="1" value="Catan"/>
<yearpublished value="1995"/>
<link type="boardgamepublisher" id="4" value="KOSMOS"/>
<link type="boardgamepublisher" id="27" value="Catan Studio"/>
<link type="boardgamedesigner" id="2" value="Klaus Teuber"/>
</item>
<item type="boardgameexpansion" id="55">
<name type="primary" value="Catan: Erweiterung"/>
<yearpublished value="1996"/>
</item>
</items>
"""
class FakeUhr:
"""Steuerbare Monoton-Uhr."""
def __init__(self) -> None:
self.zeit = 0.0
def __call__(self) -> float:
return self.zeit
class AufzeichnenderTransport(httpx.BaseTransport):
"""Liefert eine feste Antwortkette; zeichnet URLs und Uhrzeiten auf."""
def __init__(self, antworten: list, uhr: FakeUhr):
self.antworten = list(antworten)
self.uhr = uhr
self.anfragen: list[tuple[str, float]] = []
def handle_request(self, request: httpx.Request) -> httpx.Response:
self.anfragen.append((str(request.url), self.uhr()))
element = self.antworten.pop(0)
if isinstance(element, Exception):
raise element
if not self.antworten:
self.antworten.append(element) # letzte Antwort zyklisch wiederholen
return element
def _antwort(
xml: str, status_code: int = 200, headers: dict | None = None
) -> httpx.Response:
return httpx.Response(
status_code,
content=xml.encode("utf-8"),
headers=headers or {},
request=httpx.Request("GET", "https://boardgamegeek.com/xmlapi2/test"),
)
def _client(
transport: httpx.BaseTransport, uhr: FakeUhr, schlaf
) -> BggClient:
return BggClient(
transport=transport,
mindestabstand_sekunden=1.0,
backoff_basis_sekunden=0.5,
backoff_maximum_sekunden=4.0,
schlaf=schlaf,
uhr=uhr,
)
def _fortschreitender_schlaf(uhr: FakeUhr, wartezeiten: list[float]):
def schlaf(sekunden: float) -> None:
wartezeiten.append(sekunden)
uhr.zeit += sekunden
return schlaf
# ---------- Parsing ----------
def test_parse_suche_nur_brettspiele():
treffer = parse_suche(SUCHE_XML)
assert [t.bgg_id for t in treffer] == [13, 9999] # Erweiterung raus
assert treffer[0].titel == "Catan"
assert treffer[0].erscheinungsjahr == 1995
assert treffer[1].erscheinungsjahr is None # fehlendes Jahr toleriert
def test_parse_suche_umlaute_und_kaputtes_xml():
xml = '<items><item type="boardgame" id="1"><name value="Schätze von Trullala"/></item></items>'
treffer = parse_suche(xml)
assert treffer[0].titel == "Schätze von Trullala"
with pytest.raises(BggFehler):
parse_suche("<items><item></items>")
def test_parse_things_felder_und_erweiterungsfilter():
spiele = parse_things(THING_XML)
assert len(spiele) == 1 # boardgameexpansion wird verworfen
spiel = spiele[0]
assert spiel.typ == "boardgame"
assert spiel.titel == "Catan"
assert spiel.verlag == "KOSMOS, Catan Studio"
assert spiel.autor == "Klaus Teuber"
assert spiel.erscheinungsjahr == 1995
def test_parse_things_leere_und_fehlerhafte_antwort():
assert parse_things('<items total="0"/>') == []
with pytest.raises(BggFehler):
parse_things("kein xml")
# ---------- HTTP-Verhalten ----------
def test_suche_sendet_type_boardgame():
uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort(SUCHE_XML)], uhr)
client = _client(transport, uhr, lambda s: None)
client.suche("catan")
url = transport.anfragen[0][0]
assert "/search" in url
assert "type=boardgame" in url
assert "query=catan" in url
def test_details_sendet_thing_mit_ids_und_filtert_erweiterungen():
uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort(THING_XML)], uhr)
client = _client(transport, uhr, lambda s: None)
spiele = client.details([13, 55])
url = transport.anfragen[0][0]
assert "/thing" in url
assert "id=13%2C55" in url or "id=13,55" in url
assert "type=boardgame" in url
assert [s.bgg_id for s in spiele] == [13]
def test_rate_limit_mindestens_eine_sekunde_zwischen_requests():
uhr = FakeUhr()
wartezeiten: list[float] = []
transport = AufzeichnenderTransport([_antwort(SUCHE_XML)], uhr)
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
client.suche("a")
client.suche("b")
zeitpunkt_1 = transport.anfragen[0][1]
zeitpunkt_2 = transport.anfragen[1][1]
assert zeitpunkt_2 - zeitpunkt_1 >= 1.0
assert wartezeiten # es wurde aktiv gewartet
def test_retry_bei_5xx_mit_backoff_dann_erfolg():
uhr = FakeUhr()
wartezeiten: list[float] = []
transport = AufzeichnenderTransport(
[_antwort("server error", status_code=500),
_antwort("server error", status_code=500),
_antwort(SUCHE_XML)], uhr
)
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
treffer = client.suche("catan")
assert len(treffer) == 2
assert len(transport.anfragen) == 3 # 2 × 500, dann Erfolg
# Backoff 0,5 s → Rate-Limit-Rest 0,5 s → Backoff 1,0 s:
assert wartezeiten == [0.5, 0.5, 1.0]
def test_202_wird_gemaess_retry_after_erneut_versucht():
uhr = FakeUhr()
wartezeiten: list[float] = []
transport = AufzeichnenderTransport(
[
_antwort("queued", status_code=202, headers={"Retry-After": "3"}),
_antwort(SUCHE_XML),
],
uhr,
)
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
treffer = client.suche("catan")
assert len(treffer) == 2
assert wartezeiten == [3.0] # Retry-After wird beachtet
assert len(transport.anfragen) == 2
def test_429_ohne_retry_after_nutzt_backoff():
uhr = FakeUhr()
wartezeiten: list[float] = []
transport = AufzeichnenderTransport(
[_antwort("zu schnell", status_code=429), _antwort(SUCHE_XML)], uhr
)
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
treffer = client.suche("catan")
assert len(treffer) == 2
# Backoff nach 429 plus Rate-Limit-Abstand vor dem zweiten Request:
assert wartezeiten == [0.5, 0.5]
def test_netzwerkfehler_wird_mit_backoff_wiederholt():
uhr = FakeUhr()
wartezeiten: list[float] = []
transport = AufzeichnenderTransport(
[httpx.ConnectError("Verbindung fehlgeschlagen"), _antwort(SUCHE_XML)], uhr
)
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
treffer = client.suche("catan")
assert len(treffer) == 2
assert wartezeiten == [0.5]
assert len(transport.anfragen) == 2
def test_4xx_fuehrt_zu_fehler_ohne_weitere_versuche():
uhr = FakeUhr()
wartezeiten: list[float] = []
transport = AufzeichnenderTransport([_antwort("nope", status_code=404)], uhr)
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
with pytest.raises(BggFehler):
client.suche("catan")
assert len(transport.anfragen) == 1
assert wartezeiten == []
def test_max_versuche_erschoepft_mit_fehler():
uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort("kaputt", status_code=500)], uhr)
client = BggClient(
transport=transport,
max_versuche=3,
backoff_basis_sekunden=0.1,
schlaf=lambda s: uhr.__setattr__("zeit", uhr.zeit + s),
uhr=uhr,
)
with pytest.raises(BggFehler):
client.suche("catan")
assert len(transport.anfragen) == 3
def test_details_batched_ueber_20_ids_pro_request():
uhr = FakeUhr()
aufgerufene_id_listen: list[list[str]] = []
def handler(request: httpx.Request) -> httpx.Response:
ids = request.url.params["id"].split(",")
aufgerufene_id_listen.append(ids)
items = "".join(
f'<item type="boardgame" id="{i}"><name value="Spiel {i}"/></item>'
for i in ids
)
return _antwort(f"<items>{items}</items>")
client = BggClient(
transport=httpx.MockTransport(handler),
schlaf=lambda s: None,
uhr=uhr,
)
spiele = client.details(list(range(1, 26))) # 25 IDs → 2 Requests
assert len(spiele) == 25
assert [len(batch) for batch in aufgerufene_id_listen] == [20, 5]

View File

@@ -0,0 +1,238 @@
"""Tests für die Sync-Logik: Upsert (Update statt Duplikat), Filter, Robustheit."""
from __future__ import annotations
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from redaktionskern.db import Base
from tests._neuheiten import (
BggFehler,
BggSpiel,
Neuheit,
SuchTreffer,
SyncService,
ist_moeglicher_prototyp,
speichere_oder_aktualisiere,
)
class FakeBggClient:
"""Duck-typed Ersatz für BggClient mit festen Ergebnissen."""
def __init__(
self,
suchergebnisse: dict[str, list[SuchTreffer]] | None = None,
details_pro_id: dict[int, BggSpiel] | None = None,
) -> None:
self.suchergebnisse = suchergebnisse or {}
self.details_pro_id = details_pro_id or {}
self.suche_aufrufe: list[str] = []
self.details_aufrufe: list[list[int]] = []
def suche(self, suchbegriff: str, *, max_treffer: int | None = None):
self.suche_aufrufe.append(suchbegriff)
treffer = list(self.suchergebnisse.get(suchbegriff, []))
return treffer[:max_treffer] if max_treffer is not None else treffer
def details(self, bgg_ids):
self.details_aufrufe.append(list(bgg_ids))
return [self.details_pro_id[i] for i in bgg_ids if i in self.details_pro_id]
@pytest.fixture
def session_factory():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
fabrik = sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
yield fabrik
engine.dispose()
def _spiel(bgg_id: int = 13, titel: str = "Catan", **aenderungen) -> BggSpiel:
basis = dict(
bgg_id=bgg_id,
titel=titel,
verlag="KOSMOS",
autor="Klaus Teuber",
erscheinungsjahr=1995,
typ="boardgame",
)
basis.update(aenderungen)
return BggSpiel(**basis)
def _alle_eintraege(session_factory) -> list[Neuheit]:
with session_factory() as db:
return list(db.scalars(select(Neuheit).order_by(Neuheit.bgg_id)).all())
# ---------- Update statt Duplikat ----------
def test_sync_reicht_trefferlimit_weiter(session_factory):
client = FakeBggClient(
suchergebnisse={
"viel": [SuchTreffer(i, f"Spiel {i}", None) for i in range(100)]
},
details_pro_id={},
)
service = SyncService(session_factory, client, max_treffer_pro_suchbegriff=7)
ergebnis = service.synchronisiere(["viel"])
# Das Limit begrenzt die Trefferliste vor der Detailabfrage:
assert len(client.details_aufrufe[0]) <= 20 # Batch-Größe bleibt intakt
assert sum(len(b) for b in client.details_aufrufe) == 7
def test_sync_legt_eintraege_an(session_factory):
client = FakeBggClient(
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()},
)
service = SyncService(session_factory, client)
ergebnis = service.synchronisiere(["catan"])
assert ergebnis.neu == 1
assert ergebnis.aktualisiert == 0
eintraege = _alle_eintraege(session_factory)
assert len(eintraege) == 1
eintrag = eintraege[0]
assert eintrag.titel == "Catan"
assert eintrag.verlag == "KOSMOS"
assert eintrag.autor == "Klaus Teuber"
assert eintrag.erscheinungsjahr == 1995
assert eintrag.bgg_id == 13
assert eintrag.status == "neuheit"
assert eintrag.quelle == "boardgamegeek"
def test_sync_aktualisiert_bestehende_eintraege_statt_duplikate(session_factory):
client = FakeBggClient(
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()},
)
service = SyncService(session_factory, client)
service.synchronisiere(["catan"])
# Zweiter Lauf mit geänderten Daten (z. B. korrigiertes Jahr, neuer Verlag)
client.details_pro_id[13] = _spiel(
verlag="Catan Studio", erscheinungsjahr=2020
)
ergebnis = service.synchronisiere(["catan"])
assert ergebnis.neu == 0
assert ergebnis.aktualisiert == 1
eintraege = _alle_eintraege(session_factory)
assert len(eintraege) == 1, "Update statt Duplikat verletzt"
eintrag = eintraege[0]
assert eintrag.verlag == "Catan Studio"
assert eintrag.erscheinungsjahr == 2020
assert eintrag.status == "neuheit", "Status darf beim Update nicht verloren gehen"
def test_sync_veraenderter_titel_gleiche_bgg_id_kein_duplikat(session_factory):
client = FakeBggClient(
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()},
)
service = SyncService(session_factory, client)
service.synchronisiere(["catan"])
client.details_pro_id[13] = _spiel(titel="Catan (Neuausgabe)")
service.synchronisiere(["catan"])
eintraege = _alle_eintraege(session_factory)
assert len(eintraege) == 1
assert eintraege[0].titel == "Catan (Neuausgabe)"
def test_speichere_oder_aktualisiere_rückgabe_werte(session_factory):
with session_factory() as db:
assert speichere_oder_aktualisiere(db, _spiel()) is True
db.flush()
assert speichere_oder_aktualisiere(db, _spiel(erscheinungsjahr=1996)) is False
# ---------- Filter: Erweiterungen & Prototypen ----------
def test_sync_filtert_erweiterungen(session_factory):
client = FakeBggClient(
suchergebnisse={
"catan": [
SuchTreffer(13, "Catan", 1995),
SuchTreffer(55, "Catan: Erweiterung", 1996),
]
},
details_pro_id={
13: _spiel(),
55: _spiel(55, "Catan: Erweiterung", typ="boardgameexpansion"),
},
)
service = SyncService(session_factory, client)
ergebnis = service.synchronisiere(["catan"])
assert ergebnis.gefiltert == 1
eintraege = _alle_eintraege(session_factory)
assert [e.bgg_id for e in eintraege] == [13]
def test_sync_filtert_prototypen(session_factory):
client = FakeBggClient(
suchergebnisse={
"essen": [
SuchTreffer(13, "Catan", 1995),
SuchTreffer(77, "Fabrik — Prototyp 2026", None),
SuchTreffer(78, "My Prototype Game", None),
]
},
details_pro_id={
13: _spiel(),
77: _spiel(77, "Fabrik — Prototyp 2026"),
78: _spiel(78, "My Prototype Game"),
},
)
service = SyncService(session_factory, client)
ergebnis = service.synchronisiere(["essen"])
assert ergebnis.gefiltert == 2
assert [e.bgg_id for e in _alle_eintraege(session_factory)] == [13]
def test_prototyp_heuristik():
assert ist_moeglicher_prototyp("Ein Prototyp von etwas")
assert ist_moeglicher_prototyp("Prototype X")
assert ist_moeglicher_prototyp("PROTOTYPISCHES Spiel")
assert not ist_moeglicher_prototyp("Catan")
assert not ist_moeglicher_prototyp("Die Burgen von Burgund")
# ---------- Robustheit ----------
def test_fehler_bei_einem_suchbegriff_bricht_lauf_nicht_ab(session_factory):
class HalbKaputterClient(FakeBggClient):
def suche(self, suchbegriff: str, *, max_treffer: int | None = None):
if suchbegriff == "kaputt":
raise BggFehler("BGG nicht erreichbar")
return super().suche(suchbegriff, max_treffer=max_treffer)
client = HalbKaputterClient(
suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()},
)
service = SyncService(session_factory, client)
ergebnis = service.synchronisiere(["kaputt", "gut"])
assert len(ergebnis.fehler) == 1
assert "kaputt" in ergebnis.fehler[0]
assert ergebnis.neu == 1
assert len(_alle_eintraege(session_factory)) == 1

241
tests/test_neuheiten_ui.py Normal file
View File

@@ -0,0 +1,241 @@
"""Tests für die Neuheiten-UI: Rollen, Liste, Suche/Filter/Sortierung, Sync-Button.
Alle BGG-Antworten sind gemockt (FakeBggClient), kein echter Netzwerkverkehr.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from redaktionskern.app import create_app
from tests._neuheiten import BggSpiel, Neuheit, SuchTreffer
from tests.conftest import ADMIN_PASSWORD, lege_benutzer_an, melde_an
class FakeBggClient:
"""Achtung: Attribut heißt bewusst NICHT `details`, sonst würde es die
gleichnamige Methode verdecken."""
def __init__(self, treffer=None, spiel_details=None):
self.treffer = treffer or []
self.spiel_details = spiel_details or []
self.suche_aufrufe: list[str] = []
def suche(self, suchbegriff: str, *, max_treffer: int | None = None):
self.suche_aufrufe.append(suchbegriff)
return list(self.treffer)
def details(self, bgg_ids):
return list(self.spiel_details)
def schliessen(self) -> None:
pass
def _client_mit_fakes(monkeypatch, client: TestClient, fake: FakeBggClient) -> None:
plugin = client.app.state.registry.get("neuheiten")
# Instanz-Methode ersetzen; monkeypatch stellt nach dem Test wieder her
monkeypatch.setattr(plugin, "_neuer_client", lambda: fake)
def test_neuheiten_erfordert_anmeldung(client):
antwort = client.get("/neuheiten", follow_redirects=False)
assert antwort.status_code == 303
assert "/login" in antwort.headers["location"]
def test_liste_zeigt_eintraege_und_deutsche_beschriftung(app, client):
with app.state.session_factory() as db:
db.add(
Neuheit(
titel="Catan",
verlag="KOSMOS",
autor="Klaus Teuber",
erscheinungsjahr=1995,
bgg_id=13,
)
)
db.commit()
melde_an(client)
antwort = client.get("/neuheiten")
assert antwort.status_code == 200
for text in (
"Spieltitel", "Verlag", "Autor", "Erscheinungsjahr", "Status",
"Catan", "KOSMOS", "Klaus Teuber", "1995", "1 Eintr&auml;ge",
):
assert text in antwort.text, f"Fehlt in der Seite: {text!r}"
def test_leere_liste_hat_hinweis(client):
melde_an(client)
antwort = client.get("/neuheiten")
assert antwort.status_code == 200
assert "Keine Eintr&auml;ge gefunden" in antwort.text
def test_suche_filtert_nach_titel_verlag_autor(app, client):
with app.state.session_factory() as db:
db.add_all(
[
Neuheit(titel="Catan", verlag="KOSMOS", autor="Klaus Teuber", bgg_id=13),
Neuheit(titel="Azul", verlag="Next Move Games", autor="Michael Kiesling", bgg_id=2),
Neuheit(titel="Ticket to Ride", verlag="Days of Wonder", autor="Alan R. Moon", bgg_id=9),
]
)
db.commit()
melde_an(client)
assert "Catan" in client.get("/neuheiten?q=catan").text
assert "Azul" not in client.get("/neuheiten?q=catan").text
assert "Ticket to Ride" in client.get("/neuheiten?q=wonder").text # Verlag-Treffer
def test_status_filter(app, client):
with app.state.session_factory() as db:
db.add(Neuheit(titel="Catan", bgg_id=13, status="neuheit"))
db.add(Neuheit(titel="Azul", bgg_id=2, status="planung"))
db.commit()
melde_an(client)
seite = client.get("/neuheiten?status=planung").text
assert "Azul" in seite
assert ">Catan<" not in seite
def test_sortierung_nach_jahr_abwaerts(app, client):
with app.state.session_factory() as db:
db.add_all(
[
Neuheit(titel="Alt", bgg_id=1, erscheinungsjahr=1990),
Neuheit(titel="Neu", bgg_id=2, erscheinungsjahr=2026),
Neuheit(titel="Mittel", bgg_id=3, erscheinungsjahr=2010),
]
)
db.commit()
melde_an(client)
antwort = client.get("/neuheiten?sort=jahr&richtung=ab")
reihenfolge = [zeile for zeile in ("Neu", "Mittel", "Alt") if zeile]
position = [antwort.text.index(titel) for titel in reihenfolge]
assert position == sorted(position)
def test_unbekannte_sortierung_faellt_auf_standard_zurueck(client):
melde_an(client)
antwort = client.get("/neuheiten?sort=hack&richtung=auf")
assert antwort.status_code == 200
def test_sync_button_nur_fuer_redaktion_sichtbar(app, client):
lege_benutzer_an(app, "rezensentin", role="rezensent")
melde_an(client)
admin_seite = client.get("/neuheiten").text
assert "Jetzt synchronisieren" in admin_seite
client.post("/logout")
melde_an(client, "rezensentin", "test-12345678")
rezensent_seite = client.get("/neuheiten").text
assert "Jetzt synchronisieren" not in rezensent_seite
def test_sync_endpunkt_rollen_pruefung(app, settings):
lege_benutzer_an(app, "rezensentin", role="rezensent")
from fastapi.testclient import TestClient
with TestClient(create_app(settings)) as client:
melde_an(client, "rezensentin", "test-12345678")
antwort = client.post("/neuheiten/sync", follow_redirects=False)
assert antwort.status_code == 403
def test_manueller_sync_legt_eintraege_an(monkeypatch, settings):
from fastapi.testclient import TestClient
fake = FakeBggClient(
treffer=[SuchTreffer(13, "Catan", 1995)],
spiel_details=[
BggSpiel(bgg_id=13, titel="Catan", verlag="KOSMOS", autor="Klaus Teuber",
erscheinungsjahr=1995, typ="boardgame"),
BggSpiel(bgg_id=55, titel="Catan: Erweiterung", verlag=None, autor=None,
erscheinungsjahr=1996, typ="boardgameexpansion"),
],
)
with TestClient(create_app(settings)) as client:
melde_an(client)
_client_mit_fakes(monkeypatch, client, fake)
antwort = client.post(
"/neuheiten/sync",
data={"suchbegriff": "catan"},
follow_redirects=False,
)
assert antwort.status_code == 303
assert "/neuheiten" in antwort.headers["location"]
# Meldung über Sync-Ergebnis:
folge = client.get(antwort.headers["location"])
assert "Sync abgeschlossen" in folge.text
assert "1 neu" in folge.text
with client.app.state.session_factory() as db:
eintraege = db.query(Neuheit).all()
assert [e.bgg_id for e in eintraege] == [13] # Erweiterung gefiltert
assert fake.suche_aufrufe == ["catan"] # expliziter Begriff statt Standard
def test_manueller_sync_ohne_suchbegriff_nutzt_konfigurierte_begriffe(monkeypatch, settings):
from fastapi.testclient import TestClient
monkeypatch.setenv("SPIELE_BGG_SUCHBEGRIFFE", "essen, familien")
fake = FakeBggClient()
with TestClient(create_app(settings)) as client:
melde_an(client)
_client_mit_fakes(monkeypatch, client, fake)
client.post("/neuheiten/sync", data={"suchbegriff": ""})
assert fake.suche_aufrufe == ["essen", "familien"]
def test_htmx_request_liefert_nur_teilfragment(app, client):
melde_an(client)
antwort = client.get("/neuheiten", headers={"HX-Request": "true"})
assert antwort.status_code == 200
assert "neuheiten-liste" in antwort.text # Fragment-Anker vorhanden
assert "<html" not in antwort.text.lower()
def test_scheduler_standardmaessig_deaktiviert_in_tests(settings):
app = create_app(settings)
plugin = app.state.registry.get("neuheiten")
assert plugin._scheduler is None
def test_scheduler_laesst_sich_aktivieren(monkeypatch, settings):
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "1")
monkeypatch.setenv("SPIELE_BGG_SYNC_INTERVALL_STUNDEN", "24")
app = create_app(settings)
plugin = app.state.registry.get("neuheiten")
with TestClient(app):
assert plugin._scheduler is not None
assert plugin._scheduler.running
job = plugin._scheduler.get_job("bgg-neuheiten-sync")
assert job is not None
assert plugin._scheduler is None # on_unload hat ihn gestoppt
def test_migration_legt_tabelle_an(app):
from sqlalchemy import inspect
inspektion = inspect(app.state.engine)
spalten = {c["name"] for c in inspektion.get_columns("neuheiten")}
assert {
"id", "titel", "verlag", "autor", "erscheinungsjahr",
"bgg_id", "status", "quelle", "erstellt_am", "aktualisiert_am",
} <= spalten

37
uv.lock generated
View File

@@ -37,6 +37,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
] ]
[[package]]
name = "apscheduler"
version = "3.11.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzlocal" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8c/6b/eeff360196bb20b312c9e762a820fd1b2c6d809466c755ef57863478e454/apscheduler-3.11.3.tar.gz", hash = "sha256:cd2fcc9330039a81a5893472ad49facf23a6d5604cbe1d918c835c6de7834d5a", size = 110312, upload-time = "2026-06-28T19:39:22.493Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/c9/8638db32514dbb9157b3d82680c6faea89283523edf9ed2415ea3884f2ae/apscheduler-3.11.3-py3-none-any.whl", hash = "sha256:bbeb2ec02d23d3c06a6c07ed7f0f3939ada6680eb121fae809a69bb42c537a30", size = 66024, upload-time = "2026-06-28T19:39:20.982Z" },
]
[[package]] [[package]]
name = "argon2-cffi" name = "argon2-cffi"
version = "25.1.0" version = "25.1.0"
@@ -743,8 +755,10 @@ name = "spiele-redaktion"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" },
{ name = "argon2-cffi" }, { name = "argon2-cffi" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" },
{ name = "itsdangerous" }, { name = "itsdangerous" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "python-multipart" }, { name = "python-multipart" },
@@ -760,8 +774,10 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "apscheduler", specifier = ">=3.11,<4" },
{ name = "argon2-cffi", specifier = ">=23.1" }, { name = "argon2-cffi", specifier = ">=23.1" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "itsdangerous", specifier = ">=2.1" }, { name = "itsdangerous", specifier = ">=2.1" },
{ name = "jinja2", specifier = ">=3.1" }, { name = "jinja2", specifier = ">=3.1" },
{ name = "python-multipart", specifier = ">=0.0.9" }, { name = "python-multipart", specifier = ">=0.0.9" },
@@ -851,6 +867,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
] ]
[[package]]
name = "tzdata"
version = "2026.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" },
]
[[package]]
name = "tzlocal"
version = "5.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/5b/879b2f932adfa7a053c360d50bc896c977fa6426109185f7c12ebdd0cb9d/tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4", size = 31170, upload-time = "2026-06-29T08:03:40.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" },
]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.52.4" version = "0.52.4"