Plugins dedup + planung: Prüf-API, Verzahnung mit Benachrichtigung und Audit-Log

Plugin dedup:
- Öffentliche Prüf-API check_titel(titel, verlag, bgg_id, user) mit
  strukturiertem PruefErgebnis (Konflikte, Verlagsauswahl, Titel-Empfehlung)
- Vier Prüfungen: Verlags-Konflikt, Titel-Varianten (deutsche Version
  bevorzugt: Umlaut-/Wort-Heuristik + BGG Alternate-Names), Spiel oder
  Vorgänger bereits besprochen (BGG boardgameexpansion-Relation +
  rapidfuzz-Fuzzy-Match), Titel in fremder Planungsliste
- BGG-Hilfsclient mit Rate-Limit, netzwerkfrei testbar, degradiert defensiv
- Eigene Migration (Prüfprotokoll dedup_pruefungen) + Prüfseite /dedup
- Fremde Plugin-Tabellen werden nur lesend über die gemeinsame Metadata
  gelesen — keine Import-Abhängigkeiten zwischen Plugins

Plugin planung:
- Migration planungsliste: Titel, Verlag, Ausgabe, Rezensent, Status
  (offen/in_bearbeitung/abgeschlossen), Quelle, Prüf-Befund (JSON)
- Beide Eintragswege durch die dedup-Prüfung: Verschiebung aus den
  Neuheiten (Button „→ Zur Planung“, Neuheit wechselt auf Status planung)
  und händisches Nachtragen im Formular
- Bei Treffern: send_notification an den eintragenden Rezensenten
  (benachrichtigung-Plugin) + Audit-Log-Eintrag (audit-log-Plugin)
- Verlags-Konflikt → Auswahl-Dialog „Welcher Verlag wird geführt?“,
  Entscheidung wird auditiert
- Planungsliste mit Zuordnung, Statuswechsel, Bearbeiten/Löschen;
  Rezensenten nur eigene Einträge, Admin/Redakteur alle

Tests: 45 neue Tests (Heuristik, alle vier Prüfungen mit Fake-BGG-Client,
Protokoll, beide Eintragswege mit Stub-gemockten Abhängigkeiten, Rollen,
Integration mit echten Plugins) — 141 Tests grün.
This commit is contained in:
Flo Hartmann
2026-08-21 19:51:53 +00:00
parent 56cc5943b1
commit 0f12cd952b
21 changed files with 2838 additions and 53 deletions

View File

@@ -5,8 +5,8 @@ Multi-User-Webanwendung „KI-Assistenz für Spielemagazin-Redaktionen": Neuheit
## Status ## Status
Kern mit Plugin-System/Auth/Migrationen fertig; `audit-log`, Kern mit Plugin-System/Auth/Migrationen fertig; `audit-log`,
`benachrichtigung` und `neuheiten` (BGG-Sync) vollständig implementiert; `benachrichtigung`, `neuheiten` (BGG-Sync), `dedup` und `planung`
übrige Plugins Stubs. vollständig implementiert; übrige Plugins Stubs.
Live-Status: README.md → „Projekt-Fortschritt“. Live-Status: README.md → „Projekt-Fortschritt“.
## Struktur ## Struktur

106
README.md
View File

@@ -3,7 +3,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; vollständig implementiert sind Authentifizierung/Rollen und Migrationen; vollständig implementiert sind
bisher **Audit-Log**, **Benachrichtigung** und **Neuheiten (BGG-Sync)**. bisher **Audit-Log**, **Benachrichtigung**, **Neuheiten (BGG-Sync)**,
**Dedup-Prüfung** und **Planungsliste**.
## Stack ## Stack
@@ -52,6 +53,7 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt:
| `SPIELE_BGG_SYNC_INTERVALL_STUNDEN` | `24` | Intervall des BGG-Syncs in Stunden (min. 1) | | `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_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) | | `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `25` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) |
| `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG-Zusatzdaten für die Dedup-Prüfung an (`1`) oder aus (`0`): Alternate-Names und Erweiterungs-Relationen |
## Tests ## Tests
@@ -67,7 +69,12 @@ Audit-Log-Plugin (Logging-Funktion, Migration, Filter, Paginierung,
Nur-Admin-Zugriff) sowie das Neuheiten-Plugin: BGG-Parsing, Erweiterungs-/ Nur-Admin-Zugriff) sowie das Neuheiten-Plugin: BGG-Parsing, Erweiterungs-/
Prototyp-Filter, Rate-Limit & Retry-Backoff (gemockte HTTP-Antworten, keine Prototyp-Filter, Rate-Limit & Retry-Backoff (gemockte HTTP-Antworten, keine
echten API-Calls), Update-statt-Duplikat-Logik, UI-Suche/Filter/Sortierung, echten API-Calls), Update-statt-Duplikat-Logik, UI-Suche/Filter/Sortierung,
Rollen am Sync-Endpunkt und Scheduler-Lifecycle. Rollen am Sync-Endpunkt und Scheduler-Lifecycle. Dazu die beiden neuen
Plugins: **dedup** (Titel-Heuristik, Fuzzy-Match, alle vier Prüfungen mit
Fake-BGG-Client, Prüfprotokoll, Prüfseite) und **planung** (beide Eintragswege
mit Stub-gemockter dedup-/Benachrichtigungs-/Audit-API, Verlags-Auswahl-
Dialog, Statuswechsel, Bearbeiten/Löschen, Rechte pro Rolle) plus ein
Integrationstest mit den echten Plugins (In-App-Nachricht + Audit-Eintrag).
## Architektur ## Architektur
@@ -85,7 +92,9 @@ plugins/ ein Ordner pro Funktion, ladbar über den Plugin-Load
├── audit-log/ VOLL IMPLEMENTIERT: Model, Migration, API, Admin-Ansicht ├── audit-log/ VOLL IMPLEMENTIERT: Model, Migration, API, Admin-Ansicht
├── neuheiten/ VOLL IMPLEMENTIERT: BGG-Client, Sync-Job, Migration, UI ├── neuheiten/ VOLL IMPLEMENTIERT: BGG-Client, Sync-Job, Migration, UI
├── benachrichtigung/ VOLL IMPLEMENTIERT: E-Mail/Telegram/In-App ├── benachrichtigung/ VOLL IMPLEMENTIERT: E-Mail/Telegram/In-App
├── dedup/ planung/ archiv/ erinnerung/ export/ (Stubs, ladbar) ├── dedup/ VOLL IMPLEMENTIERT: check_titel-API, Heuristik, Protokoll
├── planung/ VOLL IMPLEMENTIERT: Planungsliste, Verschiebung, Dialoge
├── archiv/ erinnerung/ export/ (Stubs, ladbar)
└── … je __init__.py + templates/<name>/ └── … je __init__.py + templates/<name>/
``` ```
@@ -269,6 +278,91 @@ Autor, Erscheinungsjahr, Status, aktualisiert am):
- Jeder Titel verlinkt direkt auf den BGG-Eintrag. - Jeder Titel verlinkt direkt auf den BGG-Eintrag.
- Sync-Steuerung nur für Rolle Admin/Redakteur. - Sync-Steuerung nur für Rolle Admin/Redakteur.
## Plugin „dedup“ (implementiert)
Dedup-Prüfungen bei Import und händigem Eintrag. Die Prüf-Logik liegt
komplett im Plugin; der Kern bleibt unberührt.
### Öffentliche Prüf-API für andere Plugins
```python
dedup = request.app.state.registry.get("dedup")
ergebnis = await dedup.check_titel(titel, verlag, bgg_id, user=user)
# ergebnis.hat_konflikte -> bool
# ergebnis.konflikte -> Liste TitelKonflikt (art, beschreibung, details)
# ergebnis.verlags_optionen -> Verlage zur Auswahl bei Verlags-Konflikt
# ergebnis.titel_empfehlung -> deutschester bekannter Titel
# ergebnis.als_text() -> lesbare Zusammenfassung (deutsch)
```
### Die vier Prüfungen
| # | Prüfung | Mechanik |
|---|---------|----------|
| a | **Verlags-Konflikt** | Gleiches Spiel (BGG-ID oder Fuzzy-Match) bereits unter anderem Verlag/Vertrieb → Konflikt mit Verlagsauswahl |
| b | **Titel-Varianten** | Deutsche Version wird bevorzugt — Heuristik über deutsche Titel-Indikatoren (Umlaute/ß, Funktionswörter, typische Spielbegriffe) plus BGG Alternate-Names, falls verfügbar |
| c | **Bereits besprochen** | Abgeschlossene Planungseinträge bilden das Korpus; Vorgänger werden über die BGG `boardgameexpansion`-Relation erkannt und zusätzlich fuzzy auf Titel gematcht (rapidfuzz, Schwelle 85, `token_set_ratio`) |
| d | **Schon in Planung** | Titel steht bereits in der Planungsliste eines anderen Rezensenten (eigene Einträge zählen nicht) |
Fehlt eine Partner-Tabelle (Plugin nicht geladen), entfällt die jeweilige
Teilprüfung. BGG-Störungen blockieren nie: der Hilfsclient degradiert auf
die reine Heuristik und protokolliert nur. Abschaltbar über
`SPIELE_DEDUP_BGG_AKTIV=0` (Standard: an, Rate-Limit 1 s).
### Prüfprotokoll & Prüfseite
Jede `check_titel`-Prüfung landet in der eigenen Migration
`0001_pruefprotokoll` (Tabelle `dedup_pruefungen`: Titel, Verlag, BGG-ID,
Befund als JSON, geprüft von, Zeitpunkt). Unter **Dedup-Prüfung** (`/dedup`)
kann jeder angemeldete Benutzer einen Titel von Hand prüfen und sieht die
letzten 20 Prüfungen.
## Plugin „planung“ (implementiert)
Planungsliste mit Zuordnung an Rezensenten und Ausgaben. Beide Eintragswege
laufen automatisch durch die dedup-Prüfung.
### Datentabelle
Eigene Migration `0001_planungsliste`, Tabelle `planungsliste`:
| Spalte | Inhalt |
|--------|--------|
| `titel` / `verlag` / `autor` | Spieldaten |
| `bgg_id` | BoardGameGeek-ID (Match-Kriterium der dedup-Prüfung) |
| `ausgabe` | Magazin-Ausgabe, z. B. „3/2025“ (mehrere parallel möglich) |
| `rezensent_id` | Zuordnung (FK auf die Benutzer-Tabelle des Kerns) |
| `status` | `offen``in_bearbeitung``abgeschlossen` |
| `notizen` | Freitext |
| `quelle` | `neuheiten` (verschoben) oder `manuell` (nachgetragen) |
| `pruefung` | Befund der dedup-Prüfung beim Anlegen (JSON) |
| `erstellt_am` / `aktualisiert_am` | Zeitstempel |
### Die beiden Eintragswege
1. **Verschiebung aus der Neuheitenliste** — Button „→ Zur Planung“ in der
Neuheitenliste ruft `POST /planung/uebernehmen/<id>` auf; der Neuheiten-
Eintrag wechselt in den Status `planung`, der Planungseintrag wird als
Quelle `neuheiten` angelegt.
2. **Händisches Nachtragen** — Formular auf der Planungsseite
(`POST /planung/neu`, Quelle `manuell`).
Bei einem Prüftreffer erhält der eintragende Rezensent eine
**Benachrichtigung** (`send_notification` des benachrichtigung-Plugins,
Kategorie `dedup`), und es wird ein **Audit-Log-Eintrag** geschrieben
(`log` des audit-log-Plugins, Befund in `details.dedup_konflikte`). Bei
einem **Verlags-Konflikt** erscheint zuerst der Auswahl-Dialog „Welcher
Verlag soll geführt werden?“ — erst die bestätigte Wahl legt den Eintrag an;
die Entscheidung ist auditiert.
### UI & Rechte
Unter **Planung** (`/planung`) sieht jeder angemeldete Benutzer die komplette
Liste mit Zuordnung (Rezensent, Ausgabe, Status, Prüfhinweis-Badge).
Rezensenten können **nur ihre eigenen Einträge** bearbeiten (Statuswechsel,
Bearbeiten, Löschen); Admins und Redakteure alle. Jede Änderung wird im
Audit-Log protokolliert (`erstellt`/`verschoben`/`geaendert`/`geloescht`).
## Deployment (später) ## Deployment (später)
Docker/Podman Compose ist vorgesehen (Henry-Lab, danach Kundenhardware). Docker/Podman Compose ist vorgesehen (Henry-Lab, danach Kundenhardware).
@@ -282,11 +376,11 @@ geschrieben.
| # | Baustein | Status | | # | Baustein | Status |
|---|----------|--------| |---|----------|--------|
| 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) | ✅ fertig |
| 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) | ✅ fertig | | 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) | ✅ fertig (141 Tests grün) |
| 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ⏳ offen | | 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ✅ fertig (141 Tests grün) |
| 7 | Plugin `archiv` (12-Monats-Autopilot) | ⏳ offen | | 7 | Plugin `archiv` (12-Monats-Autopilot) | ⏳ offen |
| 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung) | ⏳ offen | | 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung) | ⏳ offen |
| 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ⏳ offen | | 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ⏳ offen |

View File

@@ -1,41 +1,221 @@
"""Plugin „dedup“ — Platzhalter gemäß Plugin-Vertrag. """Plugin „dedup“ — Prüfungen bei Import und händischem Eintrag.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag: Öffentliche API für andere Plugins (z. B. „planung“):
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
dedup = request.app.state.registry.get("dedup")
ergebnis = await dedup.check_titel(titel, verlag, bgg_id, user=user)
# ergebnis.hat_konflikte / ergebnis.konflikte / ergebnis.verlags_optionen
# ergebnis.titel_empfehlung / ergebnis.als_text()
Die vier Prüfungen (Verlags-Konflikt, deutsche Titel-Variante, bereits
besprochen inkl. Vorgänger-Erkennung, schon in fremder Planungsliste) sind
in `pruefung.py` beschrieben. Jede Prüfung wird in der eigenen Tabelle
`dedup_pruefungen` protokolliert.
Zusatzdaten von BoardGameGeek (Alternate-Names, Erweiterungs-Relationen)
können über SPIELE_DEDUP_BGG_AKTIV=0 abgeschaltet werden (Standard: an);
ohne Netzwerk degradiert die Prüfung automatisch auf die Heuristik.
""" """
from __future__ import annotations from __future__ import annotations
from fastapi import Depends, Request import logging
import os
from redaktionskern.auth.deps import require_user from fastapi import Depends, Form, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_user
from redaktionskern.auth.models import User from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, NavEntry from redaktionskern.contracts import BasePlugin, Migration, NavEntry
from .bgg import BggPruefClient
from .models import Pruefung
from .pruefung import KONFLIKT_ANZEIGE, PruefErgebnis, fuehre_pruefung_durch
_logger = logging.getLogger("plugins.dedup")
ANZAHL_PROTOKOLL = 20
def _protokoll_tabelle(conn) -> None:
"""Migration 0001: legt die Protokoll-Tabelle an (portabel, idempotent)."""
Pruefung.__table__.create(conn, checkfirst=True)
class DedupPlugin(BasePlugin): class DedupPlugin(BasePlugin):
name = "dedup" name = "dedup"
title = "Dedup-Prüfung" title = "Dedup-Prüfung"
description = "Prüfungen bei Import und händischem Eintrag (Platzhalter)." description = (
"Prüfungen bei Import und händigem Eintrag: Verlags-Konflikt, "
"Titel-Varianten (deutsche Version bevorzugt), besprochene Spiele "
"inkl. Vorgänger und fremde Planungseinträge."
)
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self._bgg_aktiv = True
self._bgg_client: BggPruefClient | None = None
#: Von Tests überschreibbare Fabrik für den BGG-Hilfsclient.
self.bgg_client_fabrik = None
@self.router.get("/dedup") self._routen_registrieren()
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins.""" # ---------- Plugin-Vertrag ----------
return self.context.templates.TemplateResponse(
request=request, def migrations(self) -> list[Migration]:
name="dedup/index.html", return [Migration(version="0001_pruefprotokoll", up=_protokoll_tabelle)]
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
},
)
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/dedup")] return [NavEntry(label=self.title, url="/dedup")]
def on_load(self, context) -> None:
super().on_load(context)
self._bgg_aktiv = os.environ.get("SPIELE_DEDUP_BGG_AKTIV", "1").strip() != "0"
def on_unload(self) -> None:
if self._bgg_client is not None:
self._bgg_client.schliessen()
self._bgg_client = None
super().on_unload()
# ---------- Öffentliche Prüf-API für andere Plugins ----------
async def check_titel(
self,
titel: str,
verlag: str | None = None,
bgg_id: int | str | None = None,
*,
user: User | None = None,
) -> PruefErgebnis:
"""Prüft einen Titel gegen Neuheiten- und Planungsliste.
Liefert ein strukturiertes `PruefErgebnis` mit Konflikt-Details;
wirft nicht (BGG-Probleme werden protokolliert und degradieren die
Prüfung auf die Titel-Heuristik). Jede Prüfung landet im Protokoll.
"""
id_wert: int | None
try:
id_wert = int(bgg_id) if bgg_id not in (None, "") else None
except (TypeError, ValueError):
id_wert = None
client = self._hilfsclient()
try:
ergebnis = fuehre_pruefung_durch(
self.context.session_factory,
client,
titel=titel,
verlag=verlag,
bgg_id=id_wert,
user_id=user.id if user is not None else None,
)
finally:
if client is not None:
client.schliessen()
self._protokolliere(ergebnis, user)
return ergebnis
# ---------- Internas ----------
def _hilfsclient(self) -> BggPruefClient | None:
if self.bgg_client_fabrik is not None:
return self.bgg_client_fabrik()
if not self._bgg_aktiv or self.context is None:
return None
if self._bgg_client is None:
self._bgg_client = BggPruefClient()
return self._bgg_client
def _protokolliere(self, ergebnis: PruefErgebnis, user: User | None) -> None:
"""Schreibt den Prüfbefund ins eigene Protokoll (best effort)."""
try:
with self.context.session_factory() as db:
db.add(
Pruefung(
titel=ergebnis.titel[:300],
verlag=ergebnis.verlag,
bgg_id=ergebnis.bgg_id,
konflikte=ergebnis.als_liste() or None,
geprueft_durch_id=user.id if user is not None else None,
geprueft_durch=user.username if user is not None else "System",
)
)
db.commit()
except Exception:
_logger.exception("dedup: Prüfung konnte nicht protokolliert werden.")
def _seite_rendern(
self,
request: Request,
user: User,
*,
ergebnis: PruefErgebnis | None = None,
meldung: str = "",
fehler: str = "",
):
with self.context.session_factory() as db:
protokoll = db.scalars(
select(Pruefung)
.order_by(Pruefung.erstellt_am.desc(), Pruefung.id.desc())
.limit(ANZAHL_PROTOKOLL)
).all()
return self.context.templates.TemplateResponse(
request=request,
name="dedup/index.html",
context={
"user": user,
"titel": self.title,
"ergebnis": ergebnis,
"konflikt_anzeige": KONFLIKT_ANZEIGE,
"protokoll": protokoll,
"meldung": meldung[:300],
"fehler": fehler[:300],
},
)
def _routen_registrieren(self) -> None:
@self.router.get("/dedup")
def seite(request: Request, user: User = Depends(require_user)):
"""Prüfseite: Manuelle Prüfung + Protokoll der letzten Prüfungen."""
return self._seite_rendern(request, user)
@self.router.post("/dedup/pruefen")
async def pruefen(
request: Request,
user: User = Depends(require_user),
titel: str = Form(""),
verlag: str = Form(""),
bgg_id: str = Form(""),
):
"""Führt eine manuelle Prüfung durch und zeigt das Ergebnis."""
roh_titel = titel.strip()
if not roh_titel:
return self._seite_rendern(
request, user, fehler="Bitte einen Spieltitel angeben."
)
try:
id_wert = int(bgg_id.strip()) if bgg_id.strip() else None
except ValueError:
return self._seite_rendern(
request, user,
fehler="Die BGG-ID muss eine Zahl sein.",
meldung="",
)
ergebnis = await self.check_titel(
roh_titel, verlag.strip() or None, id_wert, user=user
)
return self._seite_rendern(
request,
user,
ergebnis=ergebnis,
meldung="Prüfung abgeschlossen."
if not ergebnis.hat_konflikte
else "",
)
plugin = DedupPlugin() plugin = DedupPlugin()

104
plugins/dedup/bgg.py Normal file
View File

@@ -0,0 +1,104 @@
"""Hilfsclient des Plugins „dedup“ für die BoardGameGeek XML API2.
Besorgt zwei Zusatzinformationen für die Prüfung:
- Alternate-Names eines Spiels (für die deutsche Titel-Empfehlung),
- die `boardgameexpansion`-Relation — welches Basisspiel eine Erweiterung
ergänzt (für die Vorgänger-Erkennung).
Der Client ist bewusst defensiv: Fehlt das Netzwerk oder liefert BGG
Unsinn, geben die Methoden leere Listen zurück und die Prüfung fällt auf
die reine Titel-Heuristik zurück, statt den Eintrage-Vorgang zu blockieren.
Rate-Limit wie im Neuheiten-Plugin: mindestens 1 Sekunde zwischen Requests;
Uhr, Schlaf-Funktion und HTTP-Transport sind injizierbar, damit Tests
netzwerkfrei bleiben.
"""
from __future__ import annotations
import logging
import time
import xml.etree.ElementTree as ET
from collections.abc import Callable
import httpx
_logger = logging.getLogger("plugins.dedup")
BASIS_URL = "https://boardgamegeek.com/xmlapi2"
class BggPruefClient:
"""Liest Alternate-Names und Erweiterungs-Relationen von BGG."""
def __init__(
self,
mindestabstand_sekunden: float = 1.0,
*,
transport: httpx.BaseTransport | None = None,
uhr: Callable[[], float] = time.monotonic,
schlafen: Callable[[float], None] = time.sleep,
timeout: float = 10.0,
) -> None:
self._mindestabstand = max(0.0, mindestabstand_sekunden)
self._zuletzt: float | None = None
self._uhr = uhr
self._schlafen = schlafen
self._client = httpx.Client(
base_url=BASIS_URL, timeout=timeout, transport=transport
)
def schliessen(self) -> None:
self._client.close()
# ---------- Internas ----------
def _hole(self, pfad: str, parameter: dict[str, str]) -> ET.Element | None:
"""Ein GET mit Rate-Limit; bei jedem Fehler None (Prüfung degradiert)."""
if self._zuletzt is not None:
vergangen = self._uhr() - self._zuletzt
if vergangen < self._mindestabstand:
self._schlafen(self._mindestabstand - vergangen)
self._zuletzt = self._uhr()
try:
antwort = self._client.get(pfad, params=parameter)
antwort.raise_for_status()
return ET.fromstring(antwort.content)
except Exception as exc:
_logger.warning("BGG-Hilfsabruf fehlgeschlagen (%s %s): %s", pfad, parameter, exc)
return None
# ---------- Öffentliche API ----------
def alternativen(self, bgg_id: int) -> list[str]:
"""Alternate-Names des Spiels laut BGG (leer bei Problemen)."""
wurzel = self._hole("/thing", {"id": str(bgg_id), "type": "boardgame"})
namen: list[str] = []
if wurzel is None:
return namen
for element in wurzel.iter("name"):
if element.get("type") == "alternate":
wert = (element.get("value") or "").strip()
if wert:
namen.append(wert)
return namen
def vorgaenger(self, bgg_id: int) -> list[tuple[int, str]]:
"""Basisspiele, die dieses Spiel ergänzt — als (bgg_id, titel).
Die XML API2 listet auf der Seite einer Erweiterung die Links vom
Typ `boardgameexpansion` auf das jeweils ergänzte Basisspiel.
"""
wurzel = self._hole("/thing", {"id": str(bgg_id), "type": "boardgame"})
ergebnis: list[tuple[int, str]] = []
if wurzel is None:
return ergebnis
for element in wurzel.iter("link"):
if element.get("type") != "boardgameexpansion":
continue
try:
fremd_id = int(element.get("id") or "")
except ValueError:
continue
titel = (element.get("value") or "").strip()
if titel:
ergebnis.append((fremd_id, titel))
return ergebnis

38
plugins/dedup/models.py Normal file
View File

@@ -0,0 +1,38 @@
"""Datenmodell des Plugins „dedup“.
Das Plugin speichert ein Protokoll der durchgeführten Prüfungen: wer hat
wann welchen Titel prüfen lassen und was wurde befunden. Die Fachdaten der
geprüften Spiele liegen in den Plugin-Tabellen von „neuheiten“ und
„planung“ — dedup liest diese ausschließlich lesend über die gemeinsame
SQLAlchemy-Metadata und schreibt keine fremden Tabellen.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from redaktionskern.db import Base
class Pruefung(Base):
"""Protokolleintrag einer durchgeführten dedup-Prüfung."""
__tablename__ = "dedup_pruefungen"
__table_args__ = {"extend_existing": True}
id: Mapped[int] = mapped_column(primary_key=True)
titel: Mapped[str] = mapped_column(String(300))
verlag: Mapped[str | None] = mapped_column(String(300))
bgg_id: Mapped[int | None] = mapped_column(Integer)
#: Liste von Konflikt-Dicts ({art, beschreibung, details}) oder None.
konflikte: Mapped[list | None] = mapped_column(JSON)
geprueft_durch_id: Mapped[int | None] = mapped_column(Integer)
geprueft_durch: Mapped[str] = mapped_column(String(100), default="")
erstellt_am: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
return f"<Pruefung {self.titel!r}>"

413
plugins/dedup/pruefung.py Normal file
View File

@@ -0,0 +1,413 @@
"""Prüflogik des Plugins „dedup“ — die vier Prüfungen laut Fachanforderung:
a) **Verlags-Konflikt** — dasselbe Spiel ist bereits unter anderem
Verlag/Vertrieb geführt. Ergebnis enthält eine Verlagsauswahl.
b) **Titel-Varianten** — dasselbe Spiel mit unterschiedlichem Titel; die
deutsche Version wird bevorzugt (Heuristik über deutsche Titel-Indikatoren,
ergänzt um BGG Alternate-Names, falls ein Hilfsclient verfügbar ist).
c) **Spiel oder Vorgänger bereits besprochen** — abgeschlossene Einträge der
Planungsliste bilden das „besprochen“-Korpus; Vorgänger werden über die
BGG `boardgameexpansion`-Relation erkannt und zusätzlich fuzzy auf Titel
gematcht (rapidfuzz).
d) **Titel schon in Planungsliste eines anderen Rezensenten.**
Die Daten der anderen Plugins (neuheiten, planung) liest dieses Plugin
lesend über die gemeinsame SQLAlchemy-Metadata (`Base.metadata.tables`) —
so entstehen keine Import-Abhängigkeiten zwischen Plugin-Paketen. Fehlt eine
Tabelle (Plugin nicht geladen), entfällt die jeweilige Teilprüfung einfach.
"""
from __future__ import annotations
import logging
import re
import unicodedata
from collections.abc import Iterable
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from redaktionskern.db import Base
try:
from rapidfuzz import fuzz
except ImportError: # pragma: no cover - rapidfuzz ist eine harte Abhängigkeit
fuzz = None
_logger = logging.getLogger("plugins.dedup")
#: Fuzzy-Schwelle für „gemeintes Spiel“ (rapidfuzz token_sort_ratio 0100).
TITEL_SCHWELLE = 85
#: Statuswert des planung-Plugins für „bereits besprochen“ (bewusst als
#: Literal, damit keine Import-Abhängigkeit zwischen den Plugins entsteht).
STATUS_ABGESCHLOSSEN = "abgeschlossen"
#: Funktionswörter, die stark auf einen deutschen Spieltitel hindeuten.
DEUTSCHE_INDIKATOREN = frozenset({
"der", "die", "das", "den", "dem", "des",
"ein", "eine", "einen", "einem", "einer", "eines",
"und", "für", "von", "vom", "zu", "zum", "zur",
"im", "am", "beim", "auf", "mit", "aus",
})
#: Typische Begriffe deutscher Spieltitel (+2 Punkte je Treffer).
DEUTSCHE_SPIELWOERTER = frozenset({
"abenteuer", "berg", "blitz", "burg", "dorf", "drache", "drachen",
"erde", "feuer", "gärten", "haus", "himmel", "insel", "jagd",
"kaiser", "könig", "könige", "königreich", "land", "länder",
"meister", "mond", "norden", "osten", "pfad", "reich", "ritter",
"schiff", "schloss", "see", "spiel", "spiele", "spielen",
"stadt", "stern", "süden", "turm", "wald", "weg", "welt",
"westen", "wiese", "wilder", "wüste", "zauber", "zeit",
})
#: Wörter, die auf einen englischen Titeltitel hindeuten (1 Punkt je Treffer).
ENGLISCHE_INDIKATOREN = frozenset({
"the", "of", "and", "game", "games", "card", "cards", "dice", "board",
})
_UM_LAUTE = frozenset("äöüßÄÖÜ")
_WORT_MUSTER = re.compile(r"[^\W\d_]+", re.UNICODE)
# ---------------- Titel-Heuristik ----------------
def normalisiere(text: str) -> str:
"""Vereinheitlicht einen Titel für Vergleiche (Groß-/Satzzeichen egal)."""
text = unicodedata.normalize("NFC", text or "")
text = text.casefold()
text = re.sub(r"[^\w\s]+", " ", text, flags=re.UNICODE)
return re.sub(r"\s+", " ", text).strip()
def titel_aehnlich(a: str, b: str, schwelle: int = TITEL_SCHWELLE) -> bool:
"""True, wenn zwei Titel dasselbe Spiel meinen (exakt oder fuzzy).
Nach der Normalisierung zählt `fuzz.token_set_ratio`: Wortreihenfolge
und Füllwörter sind egal, damit „Die Siedler von Catan“ und
„Siedler von Catan, Die“ zusammenpassen, „Catan Erweiterung“ und
„Catan Grundspiel“ aber nicht.
"""
if not a or not b:
return False
na, nb = normalisiere(a), normalisiere(b)
if not na or not nb:
return False
if na == nb:
return True
if fuzz is None: # pragma: no cover
return False
return fuzz.token_set_ratio(na, nb) >= schwelle
def deutsch_score(titel: str) -> int:
"""Heuristik-Score: > 0 spricht für einen deutschen Titel.
Umlaute/ß zählen am stärksten (+3), deutsche Funktionswörter +1,
typische deutsche Spielbegriffe +2, englische Indikatoren 1.
"""
if not titel:
return 0
score = 0
if any(zeichen in _UM_LAUTE for zeichen in titel):
score += 3
woerter = {wort.casefold() for wort in _WORT_MUSTER.findall(titel)}
score += sum(1 for wort in woerter if wort in DEUTSCHE_INDIKATOREN)
score += sum(2 for wort in woerter if wort in DEUTSCHE_SPIELWOERTER)
score -= sum(1 for wort in woerter if wort in ENGLISCHE_INDIKATOREN)
return score
def ist_deutscher_titel(titel: str) -> bool:
return deutsch_score(titel) > 0
def bevorzuge_deutschen_titel(kandidaten: Iterable[str]) -> str | None:
"""Wählt aus Kandidaten den deutschesten Titel (bei Gleichstand den ersten)."""
bester: str | None = None
bester_score = 0
for kandidat in kandidaten:
kandidat = (kandidat or "").strip()
if not kandidat:
continue
score = deutsch_score(kandidat)
if bester is None or score > bester_score:
bester, bester_score = kandidat, score
return bester
def _verlags_schluessel(verlag: str) -> str:
"""Vergleichsschlüssel für Verlage („Kosmos“ == „kosmos “)."""
return normalisiere(verlag)
# ---------------- Prüfergebnis ----------------
#: Anzeigenamen der Konflikt-Arten (deutsche UI).
KONFLIKT_ANZEIGE: dict[str, str] = {
"verlag": "Verlags-Konflikt",
"titel": "Titel-Variante",
"besprochen": "Bereits besprochen",
"planung": "Schon in Planung",
}
@dataclass(frozen=True)
class TitelKonflikt:
"""Ein einzelner Befund der Prüfung."""
art: str # Schlüssel aus KONFLIKT_ANZEIGE
beschreibung: str
details: dict = field(default_factory=dict)
def als_dict(self) -> dict:
return {"art": self.art, "beschreibung": self.beschreibung, "details": self.details}
@property
def anzeige(self) -> str:
return KONFLIKT_ANZEIGE.get(self.art, self.art)
@dataclass
class PruefErgebnis:
"""Strukturiertes Ergebnis einer dedup-Prüfung."""
titel: str
verlag: str | None = None
bgg_id: int | None = None
konflikte: list[TitelKonflikt] = field(default_factory=list)
#: Verlage zur Auswahl bei einem Verlags-Konflikt (welcher wird geführt?).
verlags_optionen: list[str] = field(default_factory=list)
#: Empfohlener (deutschester) Titel, falls Titel-Varianten bekannt sind.
titel_empfehlung: str | None = None
@classmethod
def ohne_befund(cls, titel: str, verlag: str | None = None, bgg_id: int | None = None):
return cls(titel=titel, verlag=verlag, bgg_id=bgg_id)
@property
def hat_konflikte(self) -> bool:
return bool(self.konflikte)
@property
def hat_verlagskonflikt(self) -> bool:
return any(konflikt.art == "verlag" for konflikt in self.konflikte)
def als_liste(self) -> list[dict]:
return [konflikt.als_dict() for konflikt in self.konflikte]
def als_text(self) -> str:
if not self.konflikte:
return "Keine Treffer."
zeilen = [f"• [{k.anzeige}] {k.beschreibung}" for k in self.konflikte]
if (
self.titel_empfehlung
and normalisiere(self.titel_empfehlung) != normalisiere(self.titel)
):
zeilen.append(f"• Empfohlener Titel: „{self.titel_empfehlung}")
return "\n".join(zeilen)
# ---------------- Datenzugriff (fremde Plugin-Tabellen, nur lesend) ----------------
def _lade_zeilen(db, tabellenname: str, spalten: tuple[str, ...]) -> list[dict]:
"""Liest Zeilen einer Plugin-Tabelle über die gemeinsame Metadata.
Fehlt die Tabelle (Plugin nicht geladen bzw. Migration nicht gelaufen),
wird eine leere Liste zurückgegeben — die jeweilige Teilprüfung entfällt.
"""
tabelle = Base.metadata.tables.get(tabellenname)
if tabelle is None:
return []
try:
zeilen = db.execute(select(*(tabelle.c[name] for name in spalten))).all()
except SQLAlchemyError as exc:
_logger.warning("dedup: Tabelle '%s' nicht lesbar (%s) — Teilprüfung übersprungen.", tabellenname, exc)
return []
return [dict(zip(spalten, zeile._tuple())) for zeile in zeilen]
def _lade_benutzernamen(db) -> dict[int, str]:
benutzer = Base.metadata.tables.get("users")
namen: dict[int, str] = {}
if benutzer is None:
return namen
try:
for zeile in db.execute(
select(benutzer.c.id, benutzer.c.display_name, benutzer.c.username)
).all():
id_wert, anzeige, username = zeile._tuple()
namen[id_wert] = anzeige or username or f"Benutzer {id_wert}"
except SQLAlchemyError: # pragma: no cover - defensive
pass
return namen
def _rezensent_name(zeile: dict, namen: dict[int, str]) -> str:
id_wert = zeile.get("rezensent_id")
if id_wert is None:
return "unbekannt"
return namen.get(id_wert, f"Benutzer {id_wert}")
def _gleiches_spiel(zeile: dict, titel: str, bgg_id: int | None) -> bool:
"""Match über eindeutige BGG-ID, sonst fuzzy über den Titel."""
fremd_bgg = zeile.get("bgg_id")
if bgg_id is not None and fremd_bgg is not None and int(fremd_bgg) == int(bgg_id):
return True
return titel_aehnlich(titel, zeile.get("titel") or "")
# ---------------- Hauptprüfung ----------------
def fuehre_pruefung_durch(
session_factory,
bgg_hilfsclient,
*,
titel: str,
verlag: str | None = None,
bgg_id: int | None = None,
user_id: int | None = None,
) -> PruefErgebnis:
"""Führt alle vier Prüfungen durch und liefert ein PruefErgebnis.
`bgg_hilfsclient` darf None sein (BGG-Zusatzdaten deaktiviert) — dann
entfallen Alternate-Names und Vorgänger-Relationen, die Heuristik und
der Fuzzy-Match arbeiten weiter.
`user_id` ist der Eintragende: eigene Planungseinträge lösen keinen
„schon in Planung“-Konflikt aus.
"""
titel = (titel or "").strip()
verlag = (verlag or "").strip() or None
id_wert = int(bgg_id) if bgg_id else None
ergebnis = PruefErgebnis.ohne_befund(titel, verlag, id_wert)
if not titel:
return ergebnis
with session_factory() as db:
neuheiten = _lade_zeilen(db, "neuheiten", ("id", "titel", "verlag", "bgg_id"))
planungen = _lade_zeilen(
db, "planungsliste", ("id", "titel", "verlag", "bgg_id", "status", "rezensent_id")
)
namen = _lade_benutzernamen(db)
alternativen: list[str] = []
vorgaenger: list[tuple[int, str]] = []
if id_wert is not None and bgg_hilfsclient is not None:
try:
alternativen = list(bgg_hilfsclient.alternativen(id_wert))
except Exception as exc: # Zusatzdaten dürfen die Prüfung nie blockieren
_logger.warning("dedup: BGG-Alternativen nicht verfügbar (%s)", exc)
try:
vorgaenger = list(bgg_hilfsclient.vorgaenger(id_wert))
except Exception as exc:
_logger.warning("dedup: BGG-Vorgänger nicht verfügbar (%s)", exc)
# Kandidaten = Einträge zum selben Spiel (Neuheiten + Planungsliste).
kandidaten = [
zeile for zeile in (*neuheiten, *planungen)
if _gleiches_spiel(zeile, titel, id_wert)
]
# a) Verlags-Konflikt: gleiches Spiel unter anderem Verlag/Vertrieb.
bekannte_verlage: dict[str, str] = {}
for zeile in kandidaten:
kandidat_verlag = (zeile.get("verlag") or "").strip()
if kandidat_verlag:
bekannte_verlage.setdefault(_verlags_schluessel(kandidat_verlag), kandidat_verlag)
abweichend = [
name for schluessel, name in bekannte_verlage.items()
if verlag and schluessel != _verlags_schluessel(verlag)
]
if abweichend or len(bekannte_verlage) > 1:
optionen = list(bekannte_verlage.values())
if verlag and _verlags_schluessel(verlag) not in bekannte_verlage:
optionen.append(verlag)
ergebnis.verlags_optionen = optionen
ergebnis.konflikte.append(TitelKonflikt(
art="verlag",
beschreibung=(
"Gleiches Spiel ist bereits unter anderem Verlag geführt: "
+ ", ".join(sorted(bekannte_verlage.values()))
+ ". Bitte wählen, welcher Verlag geführt wird."
),
details={"eigener_verlag": verlag, "bekannte_verlage": list(bekannte_verlage.values())},
))
# b) Titel-Varianten → deutsche Version bevorzugen.
varianten = [
zeile["titel"] for zeile in kandidaten
if normalisiere(zeile.get("titel") or "") != normalisiere(titel)
]
ergebnis.titel_empfehlung = bevorzuge_deutschen_titel(
[titel, *varianten, *alternativen]
)
if varianten and ergebnis.titel_empfehlung \
and normalisiere(ergebnis.titel_empfehlung) != normalisiere(titel):
ergebnis.konflikte.append(TitelKonflikt(
art="titel",
beschreibung=(
f"Gleiches Spiel unter anderem Titel bekannt — empfohlen: "
f"{ergebnis.titel_empfehlung}“."
),
details={
"empfehlung": ergebnis.titel_empfehlung,
"varianten": sorted(set(varianten)),
"bgg_alternativen": alternativen[:10],
},
))
# c) Spiel oder Vorgänger bereits besprochen (Planungsstatus „abgeschlossen“).
besprochen = [z for z in planungen if z.get("status") == STATUS_ABGESCHLOSSEN]
for zeile in besprochen:
if _gleiches_spiel(zeile, titel, id_wert):
wer = _rezensent_name(zeile, namen)
ergebnis.konflikte.append(TitelKonflikt(
art="besprochen",
beschreibung=f"{zeile['titel']}“ wurde bereits besprochen ({wer}).",
details={"planungseintrag": zeile["id"], "titel": zeile["titel"], "rezensent": wer},
))
for fremd_id, fremd_titel in vorgaenger:
for zeile in besprochen:
zeilen_bgg = zeile.get("bgg_id")
passt = (
(fremd_id and zeilen_bgg is not None and int(zeilen_bgg) == fremd_id)
or titel_aehnlich(fremd_titel, zeile.get("titel") or "")
)
if passt:
wer = _rezensent_name(zeile, namen)
ergebnis.konflikte.append(TitelKonflikt(
art="besprochen",
beschreibung=(
f"Vorgänger „{fremd_titel}“ wurde bereits besprochen ({wer}) — "
"Erweiterung prüfen, ob sie noch Redaktionsstoff bietet."
),
details={
"vorgaenger_bgg_id": fremd_id,
"vorgaenger_titel": fremd_titel,
"planungseintrag": zeile["id"],
},
))
# d) Titel schon in der Planungsliste eines anderen Rezensenten.
for zeile in planungen:
if user_id is not None and zeile.get("rezensent_id") == user_id:
continue
if _gleiches_spiel(zeile, titel, id_wert):
wer = _rezensent_name(zeile, namen)
status = zeile.get("status") or ""
ergebnis.konflikte.append(TitelKonflikt(
art="planung",
beschreibung=(
f"{zeile['titel']}“ ist bereits in der Planungsliste "
f"von {wer} (Status: {status})."
),
details={
"planungseintrag": zeile["id"],
"rezensent": wer,
"status": status,
},
))
return ergebnis

View File

@@ -2,9 +2,121 @@
{% 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> Die Prüfung läuft automatisch bei jeder Eintragung in die Planungsliste.
in Version {{ version }} ist geladen. Hier kann sie auch von Hand für einen Titel ausgeführt werden.
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
</p> </p>
<div class="grid md:grid-cols-4 gap-3 mb-6 text-sm">
<div class="bg-white border border-slate-200 rounded-xl p-3">
<p class="font-medium text-slate-700 mb-1">&#127962; Verlags-Konflikt</p>
<p class="text-slate-500">Gleiches Spiel bei verschiedenen Verlagen/Vertrieb &rarr; Verlagsauswahl.</p>
</div>
<div class="bg-white border border-slate-200 rounded-xl p-3">
<p class="font-medium text-slate-700 mb-1">&#127463; Titel-Varianten</p>
<p class="text-slate-500">Deutsche Version wird bevorzugt (Heuristik + BGG Alternate-Names).</p>
</div>
<div class="bg-white border border-slate-200 rounded-xl p-3">
<p class="font-medium text-slate-700 mb-1">&#128214; Bereits besprochen</p>
<p class="text-slate-500">Spiel oder Vorg&auml;nger (BGG-Erweiterungs-Relation + Fuzzy-Match) schon in der Redaktion.</p>
</div>
<div class="bg-white border border-slate-200 rounded-xl p-3">
<p class="font-medium text-slate-700 mb-1">&#128203; Schon in Planung</p>
<p class="text-slate-500">Titel steht bereits in der Planungsliste eines anderen Rezensenten.</p>
</div>
</div>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<h2 class="font-semibold mb-3">Titel pr&uuml;fen</h2>
<form method="post" action="/dedup/pruefen" class="flex flex-wrap items-end gap-3">
<div>
<label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label>
<input type="text" id="titel" name="titel" required value="{{ ergebnis.titel if ergebnis else '' }}"
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>
<div>
<label for="verlag" class="block text-xs font-medium text-slate-500 mb-1">Verlag</label>
<input type="text" id="verlag" name="verlag" value="{{ ergebnis.verlag if ergebnis and ergebnis.verlag else '' }}"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-56 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label for="bgg_id" class="block text-xs font-medium text-slate-500 mb-1">BGG-ID (optional)</label>
<input type="text" id="bgg_id" name="bgg_id" value="{{ ergebnis.bgg_id if ergebnis and ergebnis.bgg_id else '' }}"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-32 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">
Pr&uuml;fen
</button>
</form>
{% if fehler %}
<div class="mt-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div>
{% endif %}
{% if meldung %}
<div class="mt-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div>
{% endif %}
{% if ergebnis %}
<div class="mt-4 rounded-lg border p-4 text-sm
{% if ergebnis.hat_konflikte %}bg-amber-50 border-amber-300{% else %}bg-emerald-50 border-emerald-200{% endif %}">
<p class="font-medium mb-2 {% if ergebnis.hat_konflikte %}text-amber-800{% else %}text-emerald-800{% endif %}">
{% if ergebnis.hat_konflikte %}&#9888; {{ ergebnis.konflikte|length }} Treffer f&uuml;r „{{ ergebnis.titel }}“{% else %}&#10003; Keine Treffer f&uuml;r „{{ ergebnis.titel }}“{% endif %}
</p>
<ul class="list-disc list-inside space-y-1 text-slate-700">
{% for konflikt in ergebnis.konflikte %}
<li>
<span class="inline-block px-2 py-0.5 rounded-full text-xs mr-1
{% if konflikt.art == 'verlag' %}bg-orange-200 text-orange-900
{% elif konflikt.art == 'titel' %}bg-sky-200 text-sky-900
{% elif konflikt.art == 'besprochen' %}bg-violet-200 text-violet-900
{% else %}bg-amber-200 text-amber-900{% endif %}">{{ konflikt.anzeige }}</span>
{{ konflikt.beschreibung }}
</li>
{% endfor %}
</ul>
{% if ergebnis.titel_empfehlung and ergebnis.titel_empfehlung != ergebnis.titel %}
<p class="mt-2 text-slate-600">Empfohlener Titel: <span class="font-medium">{{ ergebnis.titel_empfehlung }}</span></p>
{% endif %}
</div>
{% endif %}
</div>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-x-auto">
<h2 class="font-semibold px-4 pt-4 pb-2">Letzte Pr&uuml;fungen</h2>
<table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-2 font-medium">Zeitpunkt</th>
<th class="px-4 py-2 font-medium">Titel</th>
<th class="px-4 py-2 font-medium">Verlag</th>
<th class="px-4 py-2 font-medium">BGG-ID</th>
<th class="px-4 py-2 font-medium">Gepr&uuml;ft von</th>
<th class="px-4 py-2 font-medium">Befund</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for pruefung in protokoll %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-2 whitespace-nowrap text-slate-500">{{ pruefung.erstellt_am.strftime('%d.%m.%Y %H:%M') if pruefung.erstellt_am else '—' }}</td>
<td class="px-4 py-2 font-medium">{{ pruefung.titel }}</td>
<td class="px-4 py-2">{{ pruefung.verlag or '—' }}</td>
<td class="px-4 py-2">{{ pruefung.bgg_id or '—' }}</td>
<td class="px-4 py-2">{{ pruefung.geprueft_durch or '—' }}</td>
<td class="px-4 py-2">
{% if pruefung.konflikte %}
{% for konflikt in pruefung.konflikte %}
<span class="inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs mr-1">{{ konflikt_anzeige.get(konflikt.art, konflikt.art) }}</span>
{% endfor %}
{% else %}
<span class="text-emerald-700">Keine Treffer</span>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="6" class="px-4 py-6 text-center text-slate-400">Noch keine Pr&uuml;fungen durchgef&uuml;hrt.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %} {% endblock %}

View File

@@ -80,6 +80,7 @@ class NeuheitenPlugin(BasePlugin):
sort: str = STANDARD_SORTIERUNG[0], sort: str = STANDARD_SORTIERUNG[0],
richtung: str = STANDARD_SORTIERUNG[1], richtung: str = STANDARD_SORTIERUNG[1],
meldung: str = "", meldung: str = "",
fehler: str = "",
): ):
spalte = SORTIERBAR.get(sort, SORTIERBAR[STANDARD_SORTIERUNG[0]]) spalte = SORTIERBAR.get(sort, SORTIERBAR[STANDARD_SORTIERUNG[0]])
abwaerts = richtung == "ab" if sort in SORTIERBAR else False abwaerts = richtung == "ab" if sort in SORTIERBAR else False
@@ -117,6 +118,7 @@ class NeuheitenPlugin(BasePlugin):
"richtung": "ab" if abwaerts else "auf", "richtung": "ab" if abwaerts else "auf",
"status_optionen": status_optionen, "status_optionen": status_optionen,
"meldung": meldung[:MAX_MELDUNGS_LAENGE], "meldung": meldung[:MAX_MELDUNGS_LAENGE],
"fehler": fehler[:MAX_MELDUNGS_LAENGE],
"ist_redaktion": user.role in ( "ist_redaktion": user.role in (
Role.ADMIN.value, Role.ADMIN.value,
Role.REDAKTEUR.value, Role.REDAKTEUR.value,

View File

@@ -15,6 +15,7 @@
{% endif %} {% endif %}
</th> </th>
{% endfor %} {% endfor %}
<th class="px-4 py-3 font-medium">Aktion</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@@ -34,10 +35,18 @@
<td class="px-4 py-3 whitespace-nowrap text-slate-500"> <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 '—' }} {{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }}
</td> </td>
<td class="px-4 py-3">
{% if eintrag.status == 'neuheit' %}
<form method="post" action="/planung/uebernehmen/{{ eintrag.id }}">
<button type="submit" title="In die Planungsliste verschieben (läuft durch die dedup-Prüfung)"
class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2 py-1 rounded-lg whitespace-nowrap">&#8594; Zur Planung</button>
</form>
{% endif %}
</td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="6" class="px-4 py-8 text-center text-slate-400"> <td colspan="7" class="px-4 py-8 text-center text-slate-400">
Keine Eintr&auml;ge gefunden. Keine Eintr&auml;ge gefunden.
{% if ist_redaktion %}Nutze &bdquo;Jetzt synchronisieren&ldquo;, um die Liste aus BoardGameGeek zu befüllen.{% endif %} {% if ist_redaktion %}Nutze &bdquo;Jetzt synchronisieren&ldquo;, um die Liste aus BoardGameGeek zu befüllen.{% endif %}
</td> </td>

View File

@@ -14,6 +14,9 @@
{{ meldung }} {{ meldung }}
</div> </div>
{% endif %} {% endif %}
{% if fehler %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div>
{% endif %}
<div class="flex flex-wrap items-end gap-3 mb-4"> <div class="flex flex-wrap items-end gap-3 mb-4">
{% if ist_redaktion %} {% if ist_redaktion %}

View File

@@ -1,41 +1,529 @@
"""Plugin „planung“ — Platzhalter gemäß Plugin-Vertrag. """Plugin „planung“ — Planungsliste mit Zuordnung an Rezensenten.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag: Zwei Eintragswege, beide laufen automatisch durch die dedup-Prüfung des
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle. dedup-Plugins:
1. **Verschiebung aus der Neuheitenliste** — Button „Zur Planung“ in der
Neuheitenliste ruft `POST /planung/uebernehmen/<neuheit_id>` auf; der
Neuheiten-Eintrag wechselt dabei in den Status „planung“.
2. **Händisches Nachtragen** über das Formular auf `/planung`.
Bei einem Prüftreffer wird eine Benachrichtigung an den eintragenden
Rezensenten verschickt (Plugin „benachrichtigung“, `send_notification`)
und ein Audit-Log-Eintrag geschrieben (Plugin „audit-log“, `log`). Bei
einem Verlags-Konflikt erscheint zuerst ein Auswahl-Dialog, welcher Verlag
geführt wird; erst nach der Bestätigung wird der Eintrag angelegt.
Rezensenten sehen die komplette Liste (mit Zuordnung), dürfen aber nur
ihre eigenen Einträge bearbeiten; Admins und Redakteure alle.
Beide Partner-Plugins werden über die Registry geholt (`registry.get(...)`);
fehlen sie, entfallen Benachrichtigung/Audit bzw. die Prüfung, ohne dass
der Eintrage-Vorgang bricht.
""" """
from __future__ import annotations from __future__ import annotations
from fastapi import Depends, Request import logging
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 select, update
from sqlalchemy.orm import Session
from redaktionskern.auth.deps import AccessDenied, get_db, require_user
from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry
from redaktionskern.db import Base
from .models import (
GUELTIGE_STATUS,
STATUS_ANZEIGE,
Planungseintrag,
)
_logger = logging.getLogger("plugins.planung")
REDAKTION = (Role.ADMIN.value, Role.REDAKTEUR.value)
#: Konflikt-Anzeigenamen für UI/Benachrichtigung (bewusst als Kopie ohne
#: Import-Abhängigkeit zum dedup-Plugin).
KONFLIKT_ANZEIGE = {
"verlag": "Verlags-Konflikt",
"titel": "Titel-Variante",
"besprochen": "Bereits besprochen",
"planung": "Schon in Planung",
}
def _planungs_tabelle(conn) -> None:
"""Migration 0001: legt die Tabelle `planungsliste` an."""
Planungseintrag.__table__.create(conn, checkfirst=True)
def _neuheiten_tabelle():
"""Neuheiten-Tabelle über die gemeinsame Metadata (nur lesend/schreibend
auf den Status — kein Import des neuheiten-Pakets nötig)."""
return Base.metadata.tables.get("neuheiten")
class PlanungPlugin(BasePlugin): class PlanungPlugin(BasePlugin):
name = "planung" name = "planung"
title = "Planung" title = "Planung"
description = "Planungsliste mit Zuordnung an Rezensenten (Platzhalter)." description = (
"Planungsliste mit Zuordnung an Rezensenten — Verschiebung aus den "
"Neuheiten und händisches Nachtragen, automatisch durch die "
"dedup-Prüfung."
)
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self._routen_registrieren()
# ---------- Plugin-Vertrag ----------
def migrations(self) -> list[Migration]:
return [Migration(version="0001_planungsliste", up=_planungs_tabelle)]
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/planung")]
# ---------- Hilfsfunktionen ----------
@staticmethod
def _darf_bearbeiten(user: User, eintrag: Planungseintrag) -> bool:
return user.role in REDAKTION or eintrag.rezensent_id == user.id
@classmethod
def _pruefe_bearbeitbar(cls, user: User, eintrag: Planungseintrag) -> None:
if not cls._darf_bearbeiten(user, eintrag):
raise AccessDenied(
"Rezensenten dürfen nur ihre eigenen Planungseinträge ändern."
)
@staticmethod
def _hole_dedup(request: Request):
return request.app.state.registry.get("dedup")
@staticmethod
async def _benachrichtige(
request: Request, user: User, titel: str, ergebnis
) -> None:
"""Versendet bei Prüftreffern eine Nachricht an den Eintragenden."""
benachrichtigung = request.app.state.registry.get("benachrichtigung")
if benachrichtigung is None:
return
zeilen = [
f"Die Dedup-Prüfung für „{titel}“ hat {len(ergebnis.konflikte)} Treffer:"
]
for konflikt in ergebnis.konflikte:
anzeige = KONFLIKT_ANZEIGE.get(konflikt.art, konflikt.art)
zeilen.append(f"• [{anzeige}] {konflikt.beschreibung}")
try:
await benachrichtigung.send_notification(
user,
f"Dedup-Hinweis: {titel}",
"\n".join(zeilen),
"dedup",
)
except Exception:
# Eine fehlgeschlagene Benachrichtigung darf den Eintrag nicht blockieren.
_logger.exception("Benachrichtigung über Dedup-Treffer fehlgeschlagen.")
@staticmethod
async def _audit(
request: Request,
actor,
action: str,
objekt_id,
details: dict | None = None,
) -> None:
"""Schreibt einen Audit-Log-Eintrag (best effort, wenn Plugin geladen)."""
audit = request.app.state.registry.get("audit-log")
if audit is None:
return
try:
await audit.log(actor, action, "planungseintrag", objekt_id, details)
except Exception:
_logger.exception("Audit-Log-Eintrag für Planung fehlgeschlagen.")
@staticmethod
def _meldung(url: str, text: str, art: str = "meldung") -> RedirectResponse:
return RedirectResponse(f"{url}?{art}={quote(text)}", status_code=303)
def _dialog_antwort(
self,
request: Request,
user: User,
*,
titel: str,
ergebnis,
aktion: str,
versteckt: dict[str, str],
abbrechen_url: str = "/planung",
):
"""Auswahl-Dialog bei Verlags-Konflikt: Welcher Verlag wird geführt?"""
return self.context.templates.TemplateResponse(
request=request,
name="planung/pruefung.html",
context={
"user": user,
"titel_headline": f"Dedup-Prüfung — {titel}",
"spiel_titel": titel,
"konflikte": ergebnis.konflikte,
"konflikt_anzeige": KONFLIKT_ANZEIGE,
"titel_empfehlung": ergebnis.titel_empfehlung,
"verlags_optionen": ergebnis.verlags_optionen,
"aktion": aktion,
"versteckt": versteckt,
"abbrechen_url": abbrechen_url,
},
)
# ---------- Routen ----------
def _routen_registrieren(self) -> None:
@self.router.get("/planung") @self.router.get("/planung")
def seite(request: Request, user: User = Depends(require_user)): def liste(
"""Platzhalterseite des Plugins.""" request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
status: str = "",
meldung: str = "",
fehler: str = "",
):
"""Planungsliste: alle Einträge mit Zuordnung und Statuswechsel."""
abfrage = select(Planungseintrag)
if status in GUELTIGE_STATUS:
abfrage = abfrage.where(Planungseintrag.status == status)
eintraege = db.scalars(
abfrage.order_by(
Planungseintrag.aktualisiert_am.desc(), Planungseintrag.id.desc()
)
).all()
namen = {
benutzer.id: (benutzer.display_name or benutzer.username)
for benutzer in db.scalars(select(User)).all()
}
return self.context.templates.TemplateResponse( return self.context.templates.TemplateResponse(
request=request, request=request,
name="planung/index.html", name="planung/index.html",
context={ context={
"user": user, "user": user,
"titel": self.title, "titel": self.title,
"name": self.name, "eintraege": eintraege,
"version": self.version, "namen": namen,
"status_anzeige": STATUS_ANZEIGE,
"status_filter": status if status in GUELTIGE_STATUS else "",
"status_optionen": GUELTIGE_STATUS,
"kann_bearbeiten": lambda eintrag: self._darf_bearbeiten(user, eintrag),
"ist_redaktion": user.role in REDAKTION,
"meldung": meldung[:300],
"fehler": fehler[:300],
"anzahl": len(eintraege),
}, },
) )
def navigation(self) -> list[NavEntry]: @self.router.post("/planung/neu")
return [NavEntry(label=self.title, url="/planung")] async def neu_anlegen(
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
titel: str = Form(""),
verlag: str = Form(""),
ausgabe: str = Form(""),
bgg_id: str = Form(""),
verlag_wahl: str = Form(""),
verlag_bestaetigt: str = Form(""),
):
"""Händisches Nachtragen eines Titels (durchläuft die dedup-Prüfung)."""
roh_titel = titel.strip()
if not roh_titel:
return self._meldung("/planung", "Bitte einen Spieltitel angeben.", "fehler")
try:
id_wert = int(bgg_id.strip()) if bgg_id.strip() else None
except ValueError:
return self._meldung("/planung", "Die BGG-ID muss eine Zahl sein.", "fehler")
dedup = self._hole_dedup(request)
ergebnis = (
await dedup.check_titel(roh_titel, verlag.strip() or None, id_wert, user=user)
if dedup is not None
else None
)
hat_verlagskonflikt = bool(ergebnis and ergebnis.hat_verlagskonflikt)
# Verlags-Konflikt → Auswahl-Dialog statt sofortigem Anlegen.
if hat_verlagskonflikt and verlag_bestaetigt != "1":
return self._dialog_antwort(
request,
user,
titel=roh_titel,
ergebnis=ergebnis,
aktion="/planung/neu",
versteckt={
"titel": roh_titel,
"ausgabe": ausgabe.strip(),
"bgg_id": bgg_id.strip(),
"verlag_bestaetigt": "1",
},
)
gewaehlter_verlag = (verlag_wahl.strip() or verlag.strip()) or None
eintrag = Planungseintrag(
titel=roh_titel,
verlag=gewaehlter_verlag,
ausgabe=ausgabe.strip(),
bgg_id=id_wert,
rezensent_id=user.id,
status="offen",
quelle="manuell",
pruefung=(ergebnis.als_liste() or None) if ergebnis is not None else None,
)
db.add(eintrag)
db.commit()
details = {
"titel": eintrag.titel,
"verlag": eintrag.verlag,
"ausgabe": eintrag.ausgabe,
"quelle": eintrag.quelle,
"rezensent_id": eintrag.rezensent_id,
}
if ergebnis is not None and ergebnis.hat_konflikte:
details["dedup_konflikte"] = ergebnis.als_liste()
await self._audit(request, user, "erstellt", eintrag.id, details)
if ergebnis is not None and ergebnis.hat_konflikte:
await self._benachrichtige(request, user, eintrag.titel, ergebnis)
meldung = (
f"{eintrag.titel}“ wurde eingetragen. Die Dedup-Prüfung hatte "
"Treffer — du hast eine Benachrichtigung erhalten."
)
else:
meldung = f"{eintrag.titel}“ wurde eingetragen."
return self._meldung("/planung", meldung)
@self.router.post("/planung/uebernehmen/{neuheit_id}")
async def uebernehmen(
request: Request,
neuheit_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_user),
verlag_wahl: str = Form(""),
verlag_bestaetigt: str = Form(""),
):
"""Verschiebung Neuheiten → Planung (durchläuft die dedup-Prüfung)."""
tabelle = _neuheiten_tabelle()
if tabelle is None:
return self._meldung(
"/planung",
"Neuheitenliste nicht verfügbar — Verschiebung nicht möglich.",
"fehler",
)
zeile = db.execute(
select(tabelle).where(tabelle.c.id == neuheit_id)
).first()
if zeile is None:
return self._meldung(
"/neuheiten", "Dieser Neuheiten-Eintrag existiert nicht mehr.", "fehler"
)
if zeile.status != "neuheit":
return self._meldung(
"/neuheiten",
f"{zeile.titel}“ ist nicht mehr als Neuheit verfügbar.",
"fehler",
)
dedup = self._hole_dedup(request)
ergebnis = (
await dedup.check_titel(zeile.titel, zeile.verlag, zeile.bgg_id, user=user)
if dedup is not None
else None
)
hat_verlagskonflikt = bool(ergebnis and ergebnis.hat_verlagskonflikt)
# Verlags-Konflikt → Auswahl-Dialog statt sofortiger Übernahme.
if hat_verlagskonflikt and verlag_bestaetigt != "1":
return self._dialog_antwort(
request,
user,
titel=zeile.titel,
ergebnis=ergebnis,
aktion=f"/planung/uebernehmen/{neuheit_id}",
versteckt={"neuheit_id": str(neuheit_id), "verlag_bestaetigt": "1"},
abbrechen_url="/neuheiten",
)
gewaehlter_verlag = (verlag_wahl.strip() or (zeile.verlag or "")) or None
eintrag = Planungseintrag(
titel=zeile.titel,
verlag=gewaehlter_verlag,
autor=zeile.autor,
bgg_id=zeile.bgg_id,
ausgabe="",
rezensent_id=user.id,
status="offen",
quelle="neuheiten",
pruefung=(ergebnis.als_liste() or None) if ergebnis is not None else None,
)
db.add(eintrag)
db.execute(
update(tabelle)
.where(tabelle.c.id == neuheit_id)
.values(status="planung")
)
db.commit()
details = {
"titel": eintrag.titel,
"verlag": eintrag.verlag,
"ausgabe": eintrag.ausgabe,
"quelle": eintrag.quelle,
"rezensent_id": eintrag.rezensent_id,
"von": "neuheiten",
"neuheit_id": neuheit_id,
}
if ergebnis is not None and ergebnis.hat_konflikte:
details["dedup_konflikte"] = ergebnis.als_liste()
await self._audit(request, user, "verschoben", eintrag.id, details)
if ergebnis is not None and ergebnis.hat_konflikte:
await self._benachrichtige(request, user, eintrag.titel, ergebnis)
meldung = (
f"{eintrag.titel}“ wurde in deine Planungsliste übernommen. "
"Die Dedup-Prüfung hatte Treffer — du hast eine Benachrichtigung erhalten."
)
else:
meldung = f"{eintrag.titel}“ wurde in deine Planungsliste übernommen."
return self._meldung("/planung", meldung)
@self.router.post("/planung/{eintrag_id}/status")
async def status_wechseln(
request: Request,
eintrag_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_user),
status: str = Form(""),
):
"""Statuswechsel (offen → in Bearbeitung → abgeschlossen)."""
eintrag = db.get(Planungseintrag, eintrag_id)
if eintrag is None:
return self._meldung("/planung", "Eintrag nicht gefunden.", "fehler")
self._pruefe_bearbeitbar(user, eintrag)
if status not in GUELTIGE_STATUS:
return self._meldung("/planung", "Unbekannter Status.", "fehler")
alt = eintrag.status
eintrag.status = status
db.commit()
await self._audit(
request,
user,
"geaendert",
eintrag.id,
{"alt": {"status": alt}, "neu": {"status": status}},
)
return self._meldung(
"/planung",
f"Status von „{eintrag.titel}“: {STATUS_ANZEIGE.get(status, status)}.",
)
@self.router.get("/planung/{eintrag_id}/bearbeiten")
def bearbeiten_formular(
request: Request,
eintrag_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
eintrag = db.get(Planungseintrag, eintrag_id)
if eintrag is None:
return self._meldung("/planung", "Eintrag nicht gefunden.", "fehler")
self._pruefe_bearbeitbar(user, eintrag)
return self.context.templates.TemplateResponse(
request=request,
name="planung/bearbeiten.html",
context={
"user": user,
"titel_headline": f"Eintrag bearbeiten — {eintrag.titel}",
"eintrag": eintrag,
"abbrechen_url": "/planung",
},
)
@self.router.post("/planung/{eintrag_id}/bearbeiten")
async def bearbeiten_speichern(
request: Request,
eintrag_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_user),
titel: str = Form(""),
verlag: str = Form(""),
ausgabe: str = Form(""),
notizen: str = Form(""),
):
eintrag = db.get(Planungseintrag, eintrag_id)
if eintrag is None:
return self._meldung("/planung", "Eintrag nicht gefunden.", "fehler")
self._pruefe_bearbeitbar(user, eintrag)
roh_titel = titel.strip()
if not roh_titel:
return self._meldung("/planung", "Bitte einen Spieltitel angeben.", "fehler")
alt = {
"titel": eintrag.titel,
"verlag": eintrag.verlag,
"ausgabe": eintrag.ausgabe,
"notizen": eintrag.notizen,
}
eintrag.titel = roh_titel
eintrag.verlag = verlag.strip() or None
eintrag.ausgabe = ausgabe.strip()
eintrag.notizen = notizen.strip() or None
neu = {
"titel": eintrag.titel,
"verlag": eintrag.verlag,
"ausgabe": eintrag.ausgabe,
"notizen": eintrag.notizen,
}
geaendert = [k for k in alt if alt[k] != neu[k]]
db.commit()
if geaendert:
# Nur geänderte Felder, im alt/neu-Format des Audit-Log-Plugins.
await self._audit(
request,
user,
"geaendert",
eintrag.id,
{
"alt": {k: alt[k] for k in geaendert},
"neu": {k: neu[k] for k in geaendert},
},
)
return self._meldung("/planung", f"{eintrag.titel}“ wurde gespeichert.")
@self.router.post("/planung/{eintrag_id}/loeschen")
async def loeschen(
request: Request,
eintrag_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
eintrag = db.get(Planungseintrag, eintrag_id)
if eintrag is None:
return self._meldung("/planung", "Eintrag nicht gefunden.", "fehler")
self._pruefe_bearbeitbar(user, eintrag)
snapshot = {
"titel": eintrag.titel,
"verlag": eintrag.verlag,
"ausgabe": eintrag.ausgabe,
"status": eintrag.status,
"rezensent_id": eintrag.rezensent_id,
}
db.delete(eintrag)
db.commit()
await self._audit(request, user, "geloescht", eintrag_id, snapshot)
return self._meldung("/planung", f"{snapshot['titel']}“ wurde gelöscht.")
plugin = PlanungPlugin() plugin = PlanungPlugin()

66
plugins/planung/models.py Normal file
View File

@@ -0,0 +1,66 @@
"""Datenmodell des Plugins „planung“.
Ein Planungseintrag ordnet ein Spiel einem Rezensenten und einer Ausgabe
zu. Einträge entstehen auf zwei Wegen — beide laufen durch die dedup-
Prüfung des dedup-Plugins:
- Verschiebung aus der Neuheitenliste (`quelle = "neuheiten"`),
- händisches Nachtragen im Formular (`quelle = "manuell"`).
Bei Prüftreffern wird der Befund im JSON-Feld `pruefung` mitgespeichert.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from redaktionskern.db import Base
#: Gültige Statuswerte in Ablaufreihenfolge.
STATUS_OFFEN = "offen"
STATUS_IN_BEARBEITUNG = "in_bearbeitung"
STATUS_ABGESCHLOSSEN = "abgeschlossen"
GUELTIGE_STATUS: tuple[str, ...] = (
STATUS_OFFEN,
STATUS_IN_BEARBEITUNG,
STATUS_ABGESCHLOSSEN,
)
#: Deutsche Anzeigenamen der Statuswerte.
STATUS_ANZEIGE: dict[str, str] = {
STATUS_OFFEN: "Offen",
STATUS_IN_BEARBEITUNG: "In Bearbeitung",
STATUS_ABGESCHLOSSEN: "Abgeschlossen",
}
class Planungseintrag(Base):
"""Ein Spiel in der Planungsliste, zugeordnet zu Rezensent und Ausgabe."""
__tablename__ = "planungsliste"
__table_args__ = {"extend_existing": True}
id: Mapped[int] = mapped_column(primary_key=True)
titel: Mapped[str] = mapped_column(String(300))
verlag: Mapped[str | None] = mapped_column(String(300))
autor: Mapped[str | None] = mapped_column(String(300))
#: BoardGameGeek-ID, sofern bekannt (Match-Kriterium der dedup-Prüfung).
bgg_id: Mapped[int | None] = mapped_column(Integer)
#: Magazin-Ausgabe, z. B. „3/2025“ (mehrere Ausgaben parallel möglich).
ausgabe: Mapped[str] = mapped_column(String(100), default="")
rezensent_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
status: Mapped[str] = mapped_column(String(50), default=STATUS_OFFEN)
notizen: Mapped[str | None] = mapped_column(Text)
#: Woher stammt der Eintrag: "neuheiten" oder "manuell".
quelle: Mapped[str] = mapped_column(String(50), default="manuell")
#: Befund der dedup-Prüfung beim Anlegen (Liste von Konflikt-Dicts).
pruefung: Mapped[list | None] = mapped_column(JSON)
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"<Planungseintrag {self.titel!r} ({self.status})>"

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">Eintrag bearbeiten</h1>
<p class="text-slate-600 mb-4">„{{ eintrag.titel }}“ &middot; Rezensent:
{% if user.id == eintrag.rezensent_id %}du{% else %}{{ user.display_name }}{% endif %}
</p>
<form method="post" action="/planung/{{ eintrag.id }}/bearbeiten"
class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4">
<div>
<label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label>
<input type="text" id="titel" name="titel" required value="{{ eintrag.titel }}"
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label for="verlag" class="block text-xs font-medium text-slate-500 mb-1">Verlag</label>
<input type="text" id="verlag" name="verlag" value="{{ eintrag.verlag or '' }}"
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label for="ausgabe" class="block text-xs font-medium text-slate-500 mb-1">Ausgabe</label>
<input type="text" id="ausgabe" name="ausgabe" value="{{ eintrag.ausgabe }}" placeholder="z. B. 3/2025"
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
</div>
<div>
<label for="notizen" class="block text-xs font-medium text-slate-500 mb-1">Notizen</label>
<textarea id="notizen" name="notizen" rows="3"
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">{{ eintrag.notizen or '' }}</textarea>
</div>
<div class="flex items-center gap-3">
<button type="submit"
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-lg text-sm">
Speichern
</button>
<a href="{{ abbrechen_url }}" class="text-sm text-slate-500 hover:text-slate-800 underline">Abbrechen</a>
</div>
</form>
{% endblock %}

View File

@@ -2,9 +2,139 @@
{% 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> Planungsliste der Redaktion: wer reviewt welches Spiel f&uuml;r welche Ausgabe.
in Version {{ version }} ist geladen. Jede Eintragung &mdash; aus den Neuheiten verschoben oder h&auml;ndisch nachgetragen &mdash;
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert. wird automatisch durch die Dedup-Pr&uuml;fung gepr&uuml;ft.
</p> </p>
{% if meldung %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div>
{% endif %}
{% if fehler %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div>
{% endif %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<h2 class="font-semibold mb-3">Titel h&auml;ndisch nachtragen</h2>
<form method="post" action="/planung/neu" class="flex flex-wrap items-end gap-3">
<div>
<label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label>
<input type="text" id="titel" name="titel" required
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>
<div>
<label for="verlag" class="block text-xs font-medium text-slate-500 mb-1">Verlag</label>
<input type="text" id="verlag" name="verlag"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-52 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label for="ausgabe" class="block text-xs font-medium text-slate-500 mb-1">Ausgabe</label>
<input type="text" id="ausgabe" name="ausgabe" placeholder="z. B. 3/2025"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-32 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label for="bgg_id" class="block text-xs font-medium text-slate-500 mb-1">BGG-ID (optional)</label>
<input type="text" id="bgg_id" name="bgg_id"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-32 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">
Pr&uuml;fen &amp; eintragen
</button>
</form>
</div>
<div class="flex items-center mb-3 gap-3">
<form method="get" action="/planung" class="flex items-center gap-2">
<select name="status" class="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
onchange="this.form.submit()">
<option value="">Alle Status</option>
{% for wert in status_optionen %}
<option value="{{ wert }}" {% if wert == status_filter %}selected{% endif %}>{{ status_anzeige[wert] }}</option>
{% endfor %}
</select>
<noscript><button type="submit" class="bg-slate-200 hover:bg-slate-300 px-3 py-2 rounded-lg text-sm">Filtern</button></noscript>
</form>
<p class="text-xs text-slate-400 ml-auto">{{ anzahl }} Eintr&auml;ge</p>
</div>
<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>
<th class="px-4 py-3 font-medium">Spieltitel</th>
<th class="px-4 py-3 font-medium">Verlag</th>
<th class="px-4 py-3 font-medium">Ausgabe</th>
<th class="px-4 py-3 font-medium">Rezensent</th>
<th class="px-4 py-3 font-medium">Status</th>
<th class="px-4 py-3 font-medium">Aktualisiert</th>
<th class="px-4 py-3 font-medium">Aktionen</th>
</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 }}
{% if eintrag.bgg_id %}
<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>
{% endif %}
{% if eintrag.pruefung %}
<span class="ml-1 inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs"
title="Die dedup-Prüfung hatte beim Eintragen Treffer.">&#9888; Pr&uuml;fhinweis</span>
{% endif %}
</td>
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ eintrag.ausgabe or '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ namen.get(eintrag.rezensent_id, '—') }}</td>
<td class="px-4 py-3 whitespace-nowrap">
{% if eintrag.status == 'offen' %}
<span class="inline-block px-2 py-0.5 rounded-full bg-slate-200 text-slate-700 text-xs">{{ status_anzeige[eintrag.status] }}</span>
{% elif eintrag.status == 'in_bearbeitung' %}
<span class="inline-block px-2 py-0.5 rounded-full bg-sky-100 text-sky-800 text-xs">{{ status_anzeige[eintrag.status] }}</span>
{% else %}
<span class="inline-block px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 text-xs">{{ status_anzeige[eintrag.status] }}</span>
{% endif %}
</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>
<td class="px-4 py-3">
{% if kann_bearbeiten(eintrag) %}
<div class="flex flex-wrap items-center gap-1.5">
{% if eintrag.status == 'offen' %}
<form method="post" action="/planung/{{ eintrag.id }}/status">
<input type="hidden" name="status" value="in_bearbeitung">
<button type="submit" class="text-xs bg-sky-600 hover:bg-sky-700 text-white px-2 py-1 rounded-lg whitespace-nowrap">In Bearbeitung</button>
</form>
{% endif %}
{% if eintrag.status != 'abgeschlossen' %}
<form method="post" action="/planung/{{ eintrag.id }}/status">
<input type="hidden" name="status" value="abgeschlossen">
<button type="submit" class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2 py-1 rounded-lg whitespace-nowrap">Abgeschlossen</button>
</form>
{% endif %}
<a href="/planung/{{ eintrag.id }}/bearbeiten"
class="text-xs border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-2 py-1 rounded-lg whitespace-nowrap">Bearbeiten</a>
<form method="post" action="/planung/{{ eintrag.id }}/loeschen">
<button type="submit" class="text-xs border border-red-200 text-red-600 hover:bg-red-50 px-2 py-1 rounded-lg whitespace-nowrap">L&ouml;schen</button>
</form>
</div>
{% else %}
<span class="text-xs text-slate-400">nur {{ namen.get(eintrag.rezensent_id, '') }}</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="px-4 py-8 text-center text-slate-400">
Noch keine Eintr&auml;ge — schiebe einen Titel aus den Neuheiten hierher oder trage ihn oben h&auml;ndisch nach.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel_headline }}</h1>
<p class="text-slate-600 mb-4">
Die automatische Dedup-Pr&uuml;fung hat Treffer ergeben. Bitte pr&uuml;fen, bevor
der Eintrag angelegt wird.
</p>
<div class="bg-amber-50 border border-amber-300 rounded-xl p-4 mb-6 text-sm">
<p class="font-medium text-amber-800 mb-2">&#9888; {{ konflikte|length }} Treffer f&uuml;r „{{ spiel_titel }}“</p>
<ul class="list-disc list-inside space-y-1 text-slate-700">
{% for konflikt in konflikte %}
<li>
<span class="inline-block px-2 py-0.5 rounded-full text-xs mr-1
{% if konflikt.art == 'verlag' %}bg-orange-200 text-orange-900
{% elif konflikt.art == 'titel' %}bg-sky-200 text-sky-900
{% elif konflikt.art == 'besprochen' %}bg-violet-200 text-violet-900
{% else %}bg-amber-200 text-amber-900{% endif %}">
{{ konflikt_anzeige.get(konflikt.art, konflikt.art) }}
</span>
{{ konflikt.beschreibung }}
</li>
{% endfor %}
</ul>
{% if titel_empfehlung and titel_empfehlung != spiel_titel %}
<p class="mt-2 text-slate-600">Empfohlener Titel: <span class="font-medium">{{ titel_empfehlung }}</span></p>
{% endif %}
</div>
<form method="post" action="{{ aktion }}" class="bg-white border border-slate-200 rounded-xl shadow-sm p-4">
{% for name, wert in versteckt.items() %}
<input type="hidden" name="{{ name }}" value="{{ wert }}">
{% endfor %}
<h2 class="font-semibold mb-3">Welcher Verlag soll gef&uuml;hrt werden?</h2>
<div class="space-y-2 mb-4 text-sm">
{% for option in verlags_optionen %}
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="verlag_wahl" value="{{ option }}" required
class="accent-emerald-600">
<span>{{ option }}</span>
</label>
{% endfor %}
</div>
<div class="flex items-center gap-3">
<button type="submit"
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-lg text-sm">
Mit gew&auml;hltem Verlag &uuml;bernehmen
</button>
<a href="{{ abbrechen_url }}" class="text-sm text-slate-500 hover:text-slate-800 underline">Abbrechen</a>
</div>
</form>
{% endblock %}

View File

@@ -14,6 +14,7 @@ dependencies = [
"argon2-cffi>=23.1", "argon2-cffi>=23.1",
"apscheduler>=3.11,<4", "apscheduler>=3.11,<4",
"httpx>=0.27", "httpx>=0.27",
"rapidfuzz>=3.14.5",
] ]
[dependency-groups] [dependency-groups]

View File

@@ -22,6 +22,8 @@ def _hintergrundjobs_deaktiviert(monkeypatch):
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "1"). monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "1").
""" """
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "0") monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "0")
# Auch der BGG-Hilfsclient des dedup-Plugins bleibt in Tests offline.
monkeypatch.setenv("SPIELE_DEDUP_BGG_AKTIV", "0")
@pytest.fixture @pytest.fixture

391
tests/test_dedup.py Normal file
View File

@@ -0,0 +1,391 @@
"""Tests: Plugin „dedup“ — Heuristik, vier Prüfungen, Protokoll, Prüfseite.
Alle Prüfungen laufen gegen die echte SQLite-Testdatenbank; der optionale
BGG-Hilfsclient wird durch Fakes ersetzt (keine Netzwerk-Aufrufe).
"""
from __future__ import annotations
import asyncio
import sys
import time
import html
import httpx
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import func, select, text
from tests.conftest import lege_benutzer_an, melde_an
# ---------------- Hilfen ----------------
def plugin_modul(app, name: str):
plugin_instanz = app.state.registry.get(name)
return sys.modules[type(plugin_instanz).__module__]
def pruef_modul(app):
"""Das pruefung-Submodul (Heuristik-Funktionen) des dedup-Plugins."""
return sys.modules[plugin_modul(app, "dedup").__name__ + ".pruefung"]
def sichtbar(antwort) -> str:
"""Response-Text mit aufgelösten HTML-Entities (für Umlaut-Assertions)."""
return html.unescape(antwort.text)
def dedup_plugin(app):
return app.state.registry.get("dedup")
def pruefe(app, titel, verlag=None, bgg_id=None, *, user=None, hilfsclient=None):
"""Führt eine check_titel-Prüfung aus (optional mit Fake-BGG-Client)."""
if hilfsclient is not None:
dedup_plugin(app).bgg_client_fabrik = lambda: hilfsclient
try:
return asyncio.run(
dedup_plugin(app).check_titel(titel, verlag, bgg_id, user=user)
)
finally:
dedup_plugin(app).bgg_client_fabrik = None
def neuheit_anlegen(app, titel, verlag=None, bgg_id=1, status="neuheit"):
Neuheit = plugin_modul(app, "neuheiten").Neuheit
with app.state.session_factory() as db:
db.add(Neuheit(titel=titel, verlag=verlag, bgg_id=bgg_id, status=status))
db.commit()
def planung_anlegen(app, titel, rezensent_id, status="offen", verlag=None, bgg_id=None):
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
eintrag = Planungseintrag(
titel=titel, rezensent_id=rezensent_id, status=status,
verlag=verlag, bgg_id=bgg_id,
)
db.add(eintrag)
db.commit()
return eintrag.id
def benutzer_id(app, username: str) -> int:
from redaktionskern.auth.models import User
with app.state.session_factory() as db:
return db.scalar(select(User.id).where(User.username == username))
def benutzer_objekt(app, username: str):
from redaktionskern.auth.models import User
with app.state.session_factory() as db:
return db.scalar(select(User).where(User.username == username))
def konflikt_arten(ergebnis) -> list[str]:
return [k.art for k in ergebnis.konflikte]
class FakeBgg:
"""Ersetzt den BGG-Hilfsclient: feste Antworten, keine Netzwerk-Aufrufe."""
def __init__(self, alternativen_map=None, vorgaenger_map=None):
self._alternativen = alternativen_map or {}
self._vorgaenger = vorgaenger_map or {}
self.alternativen_aufrufe: list[int] = []
self.vorgaenger_aufrufe: list[int] = []
def alternativen(self, bgg_id):
self.alternativen_aufrufe.append(bgg_id)
return list(self._alternativen.get(bgg_id, []))
def vorgaenger(self, bgg_id):
self.vorgaenger_aufrufe.append(bgg_id)
return list(self._vorgaenger.get(bgg_id, []))
def schliessen(self):
pass
# ---------------- Titel-Heuristik ----------------
def test_deutsche_titel_heuristik(app):
m = pruef_modul(app)
assert m.ist_deutscher_titel("Die Siedler von Catan")
assert m.ist_deutscher_titel("Ein Fest für Odin")
assert m.ist_deutscher_titel("Bärenpark")
assert m.ist_deutscher_titel("Das verrückte Labyrinth")
assert not m.ist_deutscher_titel("The Crew")
assert not m.ist_deutscher_titel("Terra Mystica") # neutral → kein Deutsch-Beleg
def test_fuzzy_match_erkennt_titel_varianten(app):
m = pruef_modul(app)
assert m.titel_aehnlich("Die Siedler von Catan", "Siedler von Catan, Die")
assert m.titel_aehnlich("Cascadia", "cascadia")
assert not m.titel_aehnlich("Cascadia", "Everdell")
assert not m.titel_aehnlich("", "Cascadia")
def test_bevorzuge_deutschen_titel_waehlt_hoechsten_score(app):
m = pruef_modul(app)
kandidaten = ["The Settlers of Catan", "Catan", "Die Siedler von Catan"]
assert m.bevorzuge_deutschen_titel(kandidaten) == "Die Siedler von Catan"
# Ein deutscher Kandidat schlägt einen neutralen Eingangstitel:
assert m.bevorzuge_deutschen_titel(["Terra Mystica", "Bärenpark"]) == "Bärenpark"
# ---------------- Migration & Grundlagen ----------------
def test_migration_legt_protokolltabelle_an(app):
with app.state.engine.connect() as conn:
tabellen = {
zeile[0] for zeile in conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
)
}
protokoll = conn.execute(
text("SELECT version FROM schema_migrations WHERE plugin='dedup'")
).fetchall()
assert "dedup_pruefungen" in tabellen
assert [z[0] for z in protokoll] == ["0001_pruefprotokoll"]
def test_leere_tabellen_liefern_keine_konflikte(app):
ergebnis = pruefe(app, "Ganz neues Spiel", "Irgendein Verlag", 4711)
assert not ergebnis.hat_konflikte
assert ergebnis.titel == "Ganz neues Spiel"
assert ergebnis.bgg_id == 4711
# ---------------- Prüfung a) Verlags-Konflikt ----------------
def test_verlagskonflikt_gegen_neuheiten_eintrag(app):
neuheit_anlegen(app, "Stone Age", verlag="Hans im Glück", bgg_id=21)
ergebnis = pruefe(app, "Stone Age", "Kosmos", 21)
assert ergebnis.hat_verlagskonflikt
assert "Hans im Glück" in ergebnis.verlags_optionen
assert "Kosmos" in ergebnis.verlags_optionen
assert any(k.art == "verlag" for k in ergebnis.konflikte)
def test_kein_verlagskonflikt_bei_identischem_verlag(app):
neuheit_anlegen(app, "Stone Age", verlag="Kosmos", bgg_id=21)
ergebnis = pruefe(app, "Stone Age", "kosmos ", 21) # Groß-/Kleinschreibung egal
assert not ergebnis.hat_konflikte
def test_verlagskonflikt_ohne_bgg_id_ueber_fuzzy_match(app):
neuheit_anlegen(app, "Cascadia", verlag="Lookout Spiele")
ergebnis = pruefe(app, "Cascadia", "Feuerland Spiele", None)
assert ergebnis.hat_verlagskonflikt
assert "Lookout Spiele" in ergebnis.verlags_optionen
# ---------------- Prüfung b) Titel-Varianten / deutsche Version ----------------
def test_deutsche_version_wird_empfohlen(app):
neuheit_anlegen(app, "Die Siedler von Catan", verlag="Kosmos", bgg_id=13)
ergebnis = pruefe(app, "Settlers of Catan", "Kosmos", 13)
assert ergebnis.titel_empfehlung == "Die Siedler von Catan"
assert any(
k.art == "titel" and "empfohlen" in k.beschreibung.lower()
for k in ergebnis.konflikte
)
def test_eigener_deutscher_titel_erzeugt_keinen_titelkonflikt(app):
neuheit_anlegen(app, "Catan", verlag="Kosmos", bgg_id=13)
ergebnis = pruefe(app, "Die Siedler von Catan", "Kosmos", 13)
assert ergebnis.titel_empfehlung == "Die Siedler von Catan"
assert "titel" not in konflikt_arten(ergebnis)
def test_bgg_alternativen_fliessen_in_die_empfehlung_ein(app):
neuheit_anlegen(app, "Catan", verlag="Kosmos", bgg_id=13)
fake = FakeBgg(alternativen_map={
13: ["The Settlers of Catan", "Die Siedler von Catan"],
})
ergebnis = pruefe(app, "Settlers of Catan", "Kosmos", 13, hilfsclient=fake)
assert fake.alternativen_aufrufe == [13]
assert ergebnis.titel_empfehlung == "Die Siedler von Catan"
# ---------------- Prüfung c) Bereits besprochen / Vorgänger ----------------
def test_abgeschlossenes_spiel_gilt_als_besprochen(app):
lege_benutzer_an(app, "rez1", "rezensent")
lege_benutzer_an(app, "rez2", "rezensent")
rez1 = benutzer_id(app, "rez1")
planung_anlegen(app, "Cascadia", rez1, status="abgeschlossen")
ergebnis = pruefe(app, "Cascadia") # ohne Benutzerkontext (z. B. Import-Prüfung)
assert "besprochen" in konflikt_arten(ergebnis)
besprochen = next(k for k in ergebnis.konflikte if k.art == "besprochen")
assert "rez1" in besprochen.details["rezensent"] or "Rez1" in besprochen.details["rezensent"]
def test_vorgaenger_wird_ueber_bgg_relation_erkannt(app):
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_id(app, "rez1")
planung_anlegen(app, "Catan Grundspiel", rez1, status="abgeschlossen", bgg_id=13)
fake = FakeBgg(vorgaenger_map={99: [(13, "Catan Grundspiel")]})
ergebnis = pruefe(app, "Catan Erweiterung", None, 99, hilfsclient=fake)
besprochen = [k for k in ergebnis.konflikte if k.art == "besprochen"]
assert besprochen
assert any("Vorgänger" in k.beschreibung for k in besprochen)
def test_vorgaenger_wird_zusaetzlich_fuzzy_gematcht(app):
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_id(app, "rez1")
planung_anlegen(app, "Catan Grundspiel", rez1, status="abgeschlossen")
fake = FakeBgg(vorgaenger_map={99: [(555, "Katan Grundspiel")]})
ergebnis = pruefe(app, "Catan Erweiterung", None, 99, hilfsclient=fake)
assert any(
k.art == "besprochen" and "Vorgänger" in k.beschreibung
for k in ergebnis.konflikte
)
# ---------------- Prüfung d) Fremde Planungsliste ----------------
def test_fremder_planungseintrag_loest_konflikt_aus(app):
lege_benutzer_an(app, "rez1", "rezensent")
lege_benutzer_an(app, "rez2", "rezensent")
rez1 = benutzer_objekt(app, "rez1")
rez2 = benutzer_objekt(app, "rez2")
planung_anlegen(app, "Ark Nova", rez1.id)
ergebnis = pruefe(app, "Ark Nova", user=rez2)
assert "planung" in konflikt_arten(ergebnis)
planungs_konflikt = next(k for k in ergebnis.konflikte if k.art == "planung")
assert rez1.display_name in planungs_konflikt.details["rezensent"]
# Der eigene Eintrag des gleichen Rezensenten ist kein Konflikt:
ergebnis = pruefe(app, "Ark Nova", user=rez1)
assert "planung" not in konflikt_arten(ergebnis)
# ---------------- Protokoll ----------------
def test_pruefung_wird_im_eigenen_protokoll_gespeichert(app, client):
lege_benutzer_an(app, "anna", "redakteur")
with app.state.session_factory() as db:
from redaktionskern.auth.models import User
anna = db.scalar(select(User).where(User.username == "anna"))
neuheit_anlegen(app, "Cascadia", verlag="Lookout Spiele")
ergebnis = pruefe(app, "Cascadia", "Anderer Verlag", None, user=anna)
assert ergebnis.hat_konflikte
Pruefung = plugin_modul(app, "dedup").Pruefung
with app.state.session_factory() as db:
eintrag = db.scalar(select(Pruefung))
assert eintrag is not None
assert eintrag.titel == "Cascadia"
assert eintrag.geprueft_durch == "anna"
assert eintrag.konflikte and eintrag.konflikte[0]["art"] == "verlag"
# ---------------- Prüfseite ----------------
def test_dedup_seite_erreichbar_nach_login(client):
melde_an(client)
antwort = client.get("/dedup")
assert antwort.status_code == 200
assert "Titel prüfen" in sichtbar(antwort)
assert "Verlags-Konflikt" in antwort.text
def test_dedup_seite_anonym_wird_umgeleitet(client):
antwort = client.get("/dedup", follow_redirects=False)
assert antwort.status_code == 303
assert antwort.headers["location"].startswith("/login")
def test_manuelle_pruefung_ueber_formular(client):
melde_an(client)
antwort = client.post("/dedup/pruefen", data={
"titel": "Ganz neues Spiel", "verlag": "", "bgg_id": "",
})
assert antwort.status_code == 200
assert "Keine Treffer" in sichtbar(antwort)
def test_manuelle_pruefung_zeigt_treffer(client, app):
neuheit_anlegen(app, "Cascadia", verlag="Lookout Spiele")
melde_an(client)
antwort = client.post("/dedup/pruefen", data={
"titel": "Cascadia", "verlag": "Feuerland", "bgg_id": "",
})
assert antwort.status_code == 200
text_html = sichtbar(antwort)
assert "Verlags-Konflikt" in text_html
assert "Lookout Spiele" in text_html
def test_manuelle_pruefung_validiert_eingaben(client):
melde_an(client)
leer = client.post("/dedup/pruefen", data={"titel": "", "verlag": "", "bgg_id": ""})
assert "Bitte einen Spieltitel angeben" in leer.text
kaputt = client.post("/dedup/pruefen", data={"titel": "X", "verlag": "", "bgg_id": "abc"})
assert "Zahl sein" in kaputt.text
# ---------------- BGG-Hilfsclient (Parsing, netzwerkfrei) ----------------
THING_XML = """<?xml version="1.0" encoding="utf-8"?>
<items total="1" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
<item type="boardgame" id="99">
<name type="primary" sortindex="1" value="Catan Erweiterung"/>
<name type="alternate" sortindex="1" value="Catan Expansion"/>
<name type="alternate" sortindex="5" value="Siedler-Erweiterung"/>
<link type="boardgamecategory" id="1026" value="Settlement"/>
<link type="boardgameexpansion" id="13" value="Catan Grundspiel" inbound="false"/>
</item>
</items>
"""
def test_bgg_pruefclient_parst_alternativen_und_vorgaenger():
from plugins.dedup.bgg import BggPruefClient
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.params["id"] == "99"
return httpx.Response(200, content=THING_XML.encode())
schlaf_aufrufe: list[float] = []
client = BggPruefClient(
mindestabstand_sekunden=0.0,
transport=httpx.MockTransport(handler),
uhr=time.monotonic,
schlafen=lambda s: schlaf_aufrufe.append(s),
)
try:
alternativen = client.alternativen(99)
vorgaenger = client.vorgaenger(99)
finally:
client.schliessen()
assert alternativen == ["Catan Expansion", "Siedler-Erweiterung"]
assert vorgaenger == [(13, "Catan Grundspiel")]
assert schlaf_aufrufe == []
def test_bgg_pruefclient_toleroert_fehler():
from plugins.dedup.bgg import BggPruefClient
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("offline")
client = BggPruefClient(mindestabstand_sekunden=0.0, transport=httpx.MockTransport(handler))
try:
assert client.alternativen(99) == []
assert client.vorgaenger(99) == []
finally:
client.schliessen()

575
tests/test_planung.py Normal file
View File

@@ -0,0 +1,575 @@
"""Tests: Plugin „planung“ — Eintragswege, dedup-Verzahnung, Rollen, UI.
Abhängigkeiten werden gemockt: das dedup-Plugin durch einen Stub mit
steuerbaren Prüfergebnissen, benachrichtigung und audit-log durch
aufzeichnende Stubs. Ein Test beweist zusätzlich die Integration mit den
echten Plugins (In-App-Nachricht + Audit-Eintrag in der Datenbank).
"""
from __future__ import annotations
import html
import sys
from dataclasses import dataclass, field
from types import SimpleNamespace
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import func, select, text
from redaktionskern.app import create_app
from tests.conftest import ADMIN_PASSWORD, lege_benutzer_an, melde_an
# ---------------- Hilfen ----------------
def plugin_modul(app, name: str):
plugin_instanz = app.state.registry.get(name) or sys.modules.get(
f"spiele_redaktion_plugins.{name}"
)
return sys.modules[type(plugin_instanz).__module__]
def neuheit_anlegen(app, titel, verlag=None, bgg_id=None):
Neuheit = plugin_modul(app, "neuheiten").Neuheit
with app.state.session_factory() as db:
eintrag = Neuheit(titel=titel, verlag=verlag, bgg_id=bgg_id)
db.add(eintrag)
db.commit()
return eintrag.id
def planung_anlegen(app, titel, rezensent_id, **kwargs):
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
eintrag = Planungseintrag(titel=titel, rezensent_id=rezensent_id, **kwargs)
db.add(eintrag)
db.commit()
return eintrag.id
def planung_zaehlen(app) -> int:
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
return db.scalar(select(func.count()).select_from(Planungseintrag))
def sichtbar(antwort) -> str:
"""Response-Text mit aufgelösten HTML-Entities (für Umlaut-Assertions)."""
return html.unescape(antwort.text)
def benutzer_objekt(app, username: str):
from redaktionskern.auth.models import User
with app.state.session_factory() as db:
return db.scalar(select(User).where(User.username == username))
# ---------------- Stubs (gemockte Abhängigkeiten) ----------------
@dataclass
class Aufgezeichnet:
"""Ein aufgezeichneter Aufruf von audit.log / send_notification."""
daten: dict = field(default_factory=dict)
class StubDedup:
"""Ersetzt das dedup-Plugin: liefert gesteuerte Prüfergebnisse."""
name = "dedup"
def __init__(self, modul):
self._modul = modul # echte Datenklassen für realistische Ergebnisse
self.aufrufe: list[dict] = []
self.warteschlange: list = []
self.standard_treffer = None
def treffer_einreihen(self, ergebnis) -> None:
self.warteschlange.append(ergebnis)
def konflikt(self, art, beschreibung, details=None, optionen=None, empfehlung=None,
titel="", verlag=None, bgg_id=None):
P, K = self._modul.PruefErgebnis, self._modul.TitelKonflikt
return P(
titel=titel, verlag=verlag, bgg_id=bgg_id,
konflikte=[K(art=art, beschreibung=beschreibung, details=details or {})],
verlags_optionen=list(optionen or []),
titel_empfehlung=empfehlung,
)
async def check_titel(self, titel, verlag=None, bgg_id=None, *, user=None):
self.aufrufe.append(
{"titel": titel, "verlag": verlag, "bgg_id": bgg_id,
"user_id": user.id if user is not None else None}
)
if self.warteschlange:
ergebnis = self.warteschlange.pop(0)
ergebnis.titel = titel
ergebnis.verlag = verlag
ergebnis.bgg_id = bgg_id
return ergebnis
P = self._modul.PruefErgebnis
return P(titel=titel, verlag=verlag, bgg_id=bgg_id)
class StubBenachrichtigung:
name = "benachrichtigung"
def __init__(self):
self.gesendet: list[Aufgezeichnet] = []
async def send_notification(self, user, titel, text, kategorie="allgemein"):
self.gesendet.append(Aufgezeichnet({
"user_id": user.id, "titel": titel, "text": text, "kategorie": kategorie,
}))
return SimpleNamespace(zugestellt=["inapp"], fehlgeschlagen=[])
class StubAudit:
name = "audit-log"
def __init__(self):
self.eintraege: list[Aufgezeichnet] = []
async def log(self, actor, action, objekt_typ="", objekt_id=None,
details=None, **kwargs):
self.eintraege.append(Aufgezeichnet({
"actor": actor, "action": action, "objekt_typ": objekt_typ,
"objekt_id": objekt_id, "details": details,
}))
def log_sync(self, *args, **kwargs): # Kompatibilität zur echten API
import asyncio
asyncio.run(self.log(*args, **kwargs))
@pytest.fixture
def stubs(app):
"""Ersetzt dedup/benachrichtigung/audit-log im Registry-Zustand der App."""
modul = sys.modules[plugin_modul(app, "dedup").__name__ + ".pruefung"]
dedup_stub = StubDedup(modul)
benachrichtigung = StubBenachrichtigung()
audit = StubAudit()
registry_inhalt = app.state.registry._plugins
registry_inhalt["dedup"] = dedup_stub
registry_inhalt["benachrichtigung"] = benachrichtigung
registry_inhalt["audit-log"] = audit
return SimpleNamespace(
dedup=dedup_stub, benachrichtigung=benachrichtigung, audit=audit,
)
# ---------------- Migration & Zugriff ----------------
def test_migration_legt_planungsliste_an(app):
with app.state.engine.connect() as conn:
tabellen = {
zeile[0] for zeile in conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
)
}
protokoll = conn.execute(
text("SELECT version FROM schema_migrations WHERE plugin='planung'")
).fetchall()
assert "planungsliste" in tabellen
assert [z[0] for z in protokoll] == ["0001_planungsliste"]
def test_anonym_wird_zum_login_umgeleitet(client):
antwort = client.get("/planung", follow_redirects=False)
assert antwort.status_code == 303
assert antwort.headers["location"].startswith("/login")
def test_rezensent_sieht_planungsliste(app, client):
lege_benutzer_an(app, "rez1", "rezensent")
melde_an(client, "rez1", "test-12345678")
antwort = client.get("/planung")
assert antwort.status_code == 200
text_html = sichtbar(antwort)
assert "Planungsliste" in text_html
assert "Titel händisch nachtragen" in text_html
# ---------------- Weg 1: händisches Nachtragen ----------------
def test_manuell_ohne_treffer_legt_eintrag_an(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent")
melde_an(client, "rez1", "test-12345678")
antwort = client.post("/planung/neu", data={
"titel": "Ein ganz neues Spiel", "verlag": "Neu Verlag",
"ausgabe": "3/2025", "bgg_id": "",
}, follow_redirects=True)
assert antwort.status_code == 200
assert "wurde eingetragen" in antwort.text
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
eintrag = db.scalar(select(Planungseintrag))
assert eintrag.titel == "Ein ganz neues Spiel"
assert eintrag.verlag == "Neu Verlag"
assert eintrag.ausgabe == "3/2025"
assert eintrag.quelle == "manuell"
assert eintrag.status == "offen"
assert eintrag.pruefung is None
# Dedup wurde befragt:
assert len(stubs.dedup.aufrufe) == 1
assert stubs.dedup.aufrufe[0]["titel"] == "Ein ganz neues Spiel"
# Kein Treffer → keine Benachrichtigung, aber Audit-Eintrag:
assert stubs.benachrichtigung.gesendet == []
assert [e.daten["action"] for e in stubs.audit.eintraege] == ["erstellt"]
assert stubs.audit.eintraege[0].daten["objekt_typ"] == "planungseintrag"
def test_leerer_titel_wird_abgewiesen(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent")
melde_an(client, "rez1", "test-12345678")
antwort = client.post("/planung/neu", data={"titel": ""}, follow_redirects=True)
assert "Bitte einen Spieltitel angeben" in antwort.text
assert planung_zaehlen(app) == 0
assert stubs.dedup.aufrufe == []
def test_manuell_mit_treffer_benachrichtigt_und_auditiert(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_objekt(app, "rez1")
stubs.dedup.standard_treffer = True
stubs.dedup.treffer_einreihen(stubs.dedup.konflikt(
"planung", "„Spiel X“ ist bereits in der Planungsliste von Anna.",
details={"rezensent": "Anna"},
))
melde_an(client, "rez1", "test-12345678")
antwort = client.post("/planung/neu", data={
"titel": "Spiel X", "verlag": "", "ausgabe": "", "bgg_id": "",
}, follow_redirects=True)
assert antwort.status_code == 200
assert "Benachrichtigung erhalten" in antwort.text
# Benachrichtigung an den eintragenden Rezensenten:
assert len(stubs.benachrichtigung.gesendet) == 1
nachricht = stubs.benachrichtigung.gesendet[0].daten
assert nachricht["user_id"] == rez1.id
assert nachricht["kategorie"] == "dedup"
assert "Dedup-Prüfung" in nachricht["text"]
# Audit-Eintrag enthält den Befund:
erstellt = next(e for e in stubs.audit.eintraege if e.daten["action"] == "erstellt")
assert erstellt.daten["details"]["dedup_konflikte"][0]["art"] == "planung"
# Der Befund wird am Eintrag gespeichert:
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
eintrag = db.scalar(select(Planungseintrag))
assert eintrag.pruefung[0]["art"] == "planung"
def test_verlagskonflikt_oeffnet_auswahl_dialog(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent")
stubs.dedup.treffer_einreihen(stubs.dedup.konflikt(
"verlag", "Gleiches Spiel ist bereits unter anderem Verlag geführt.",
optionen=["Hans im Glück", "Kosmos"],
))
melde_an(client, "rez1", "test-12345678")
antwort = client.post("/planung/neu", data={
"titel": "Umstrittenes Spiel", "verlag": "Kosmos", "ausgabe": "", "bgg_id": "",
})
# Kein Redirect: der Auswahl-Dialog wird gerendert.
assert antwort.status_code == 200
assert "Welcher Verlag" in antwort.text
assert "Hans im Glück" in antwort.text and "Kosmos" in antwort.text
assert 'action="/planung/neu"' in antwort.text
assert planung_zaehlen(app) == 0 # erst nach der Wahl wird angelegt
def test_verlagswahl_legt_eintrag_mit_gewaehltem_verlag_an(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent")
stubs.dedup.treffer_einreihen(stubs.dedup.konflikt(
"verlag", "Gleiches Spiel ist bereits unter anderem Verlag geführt.",
optionen=["Hans im Glück", "Kosmos"],
))
stubs.dedup.treffer_einreihen(stubs.dedup.konflikt(
"verlag", "Gleiches Spiel ist bereits unter anderem Verlag geführt.",
optionen=["Hans im Glück", "Kosmos"],
))
melde_an(client, "rez1", "test-12345678")
# Schritt 1: Dialog erscheint.
dialog = client.post("/planung/neu", data={
"titel": "Umstrittenes Spiel", "verlag": "Kosmos", "ausgabe": "", "bgg_id": "",
})
assert "Welcher Verlag" in dialog.text
# Schritt 2: Bestätigte Verlagswahl legt den Eintrag an.
antwort = client.post("/planung/neu", data={
"titel": "Umstrittenes Spiel", "verlag": "Kosmos",
"ausgabe": "", "bgg_id": "",
"verlag_wahl": "Hans im Glück", "verlag_bestaetigt": "1",
}, follow_redirects=True)
assert antwort.status_code == 200
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
eintrag = db.scalar(select(Planungseintrag))
assert eintrag is not None
assert eintrag.verlag == "Hans im Glück"
# Entscheidung und Befund sind auditiert:
erstellt = next(e for e in stubs.audit.eintraege if e.daten["action"] == "erstellt")
assert erstellt.daten["details"]["verlag"] == "Hans im Glück"
assert erstellt.daten["details"]["dedup_konflikte"][0]["art"] == "verlag"
assert len(stubs.benachrichtigung.gesendet) == 1
# ---------------- Weg 2: Verschiebung aus den Neuheiten ----------------
def test_uebernehmen_verschiebt_neuheit_in_planung(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent")
neuheit_id = neuheit_anlegen(app, "Azul Next Move", verlag="Next Move", bgg_id=500)
melde_an(client, "rez1", "test-12345678")
antwort = client.post(f"/planung/uebernehmen/{neuheit_id}", follow_redirects=True)
assert antwort.status_code == 200
assert "Azul Next Move" in antwort.text
assert "übernommen" in antwort.text
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
eintrag = db.scalar(select(Planungseintrag))
status = db.execute(
text("SELECT status FROM neuheiten WHERE id=:id"), {"id": neuheit_id}
).scalar()
assert eintrag.quelle == "neuheiten"
assert eintrag.bgg_id == 500
assert eintrag.rezensent_id == benutzer_objekt(app, "rez1").id
assert status == "planung" # Neuheit gilt als verplant
# dedup wurde mit den Neuheiten-Daten befragt; Audit als „verschoben“:
assert stubs.dedup.aufrufe == [
{"titel": "Azul Next Move", "verlag": "Next Move", "bgg_id": 500,
"user_id": benutzer_objekt(app, "rez1").id}
]
assert [e.daten["action"] for e in stubs.audit.eintraege] == ["verschoben"]
assert stubs.benachrichtigung.gesendet == []
def test_uebernehmen_mit_verlagskonflikt_zeigt_dialog(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent")
neuheit_id = neuheit_anlegen(app, "Umstritten", verlag="Verlag A", bgg_id=600)
stubs.dedup.treffer_einreihen(stubs.dedup.konflikt(
"verlag", "Gleiches Spiel ist bereits unter anderem Verlag geführt.",
optionen=["Verlag A", "Verlag B"],
))
melde_an(client, "rez1", "test-12345678")
dialog = client.post(f"/planung/uebernehmen/{neuheit_id}")
assert dialog.status_code == 200
assert "Welcher Verlag" in dialog.text
assert 'action="/planung/uebernehmen/' in dialog.text
assert planung_zaehlen(app) == 0
antwort = client.post(f"/planung/uebernehmen/{neuheit_id}", data={
"verlag_wahl": "Verlag B", "verlag_bestaetigt": "1",
}, follow_redirects=True)
assert antwort.status_code == 200
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
eintrag = db.scalar(select(Planungseintrag))
status = db.execute(
text("SELECT status FROM neuheiten WHERE id=:id"), {"id": neuheit_id}
).scalar()
assert eintrag.verlag == "Verlag B"
assert status == "planung"
def test_doppelte_uebernahme_wird_abgeblockt(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent")
neuheit_id = neuheit_anlegen(app, "Azul", verlag="Next Move", bgg_id=500)
melde_an(client, "rez1", "test-12345678")
erste = client.post(f"/planung/uebernehmen/{neuheit_id}", follow_redirects=False)
assert erste.status_code == 303
zweite = client.post(f"/planung/uebernehmen/{neuheit_id}", follow_redirects=True)
assert "nicht mehr als Neuheit verfügbar" in zweite.text
assert planung_zaehlen(app) == 1
def test_unbekannte_neuheit_fuehrt_zur_fehlermeldung(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent")
melde_an(client, "rez1", "test-12345678")
antwort = client.post("/planung/uebernehmen/9999", follow_redirects=True)
assert "existiert nicht mehr" in antwort.text
assert planung_zaehlen(app) == 0
# ---------------- Statuswechsel ----------------
def test_statuswechsel_eigener_eintrag_wird_auditiert(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_objekt(app, "rez1")
eintrag_id = planung_anlegen(app, "Ark Nova", rez1.id)
melde_an(client, "rez1", "test-12345678")
antwort = client.post(f"/planung/{eintrag_id}/status",
data={"status": "in_bearbeitung"}, follow_redirects=True)
assert antwort.status_code == 200
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
eintrag = db.get(Planungseintrag, eintrag_id)
assert eintrag.status == "in_bearbeitung"
geaendert = stubs.audit.eintraege[-1].daten
assert geaendert["action"] == "geaendert"
assert geaendert["details"]["alt"]["status"] == "offen"
assert geaendert["details"]["neu"]["status"] == "in_bearbeitung"
def test_statuswechsel_fremder_eintrag_403(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent")
lege_benutzer_an(app, "rez2", "rezensent")
eintrag_id = planung_anlegen(app, "Ark Nova", benutzer_objekt(app, "rez1").id)
melde_an(client, "rez2", "test-12345678")
antwort = client.post(f"/planung/{eintrag_id}/status", data={"status": "abgeschlossen"})
assert antwort.status_code == 403
def test_redakteur_darf_status_fremder_eintraege_wechseln(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent")
lege_benutzer_an(app, "red1", "redakteur")
eintrag_id = planung_anlegen(app, "Ark Nova", benutzer_objekt(app, "rez1").id)
melde_an(client, "red1", "test-12345678")
antwort = client.post(f"/planung/{eintrag_id}/status",
data={"status": "abgeschlossen"}, follow_redirects=True)
assert antwort.status_code == 200
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
assert db.get(Planungseintrag, eintrag_id).status == "abgeschlossen"
def test_unbekannter_status_wird_abgelehnt(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent")
eintrag_id = planung_anlegen(app, "Ark Nova", benutzer_objekt(app, "rez1").id)
melde_an(client, "rez1", "test-12345678")
antwort = client.post(f"/planung/{eintrag_id}/status",
data={"status": "quatsch"}, follow_redirects=True)
assert "Unbekannter Status" in antwort.text
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
assert db.get(Planungseintrag, eintrag_id).status == "offen"
# ---------------- Bearbeiten & Löschen ----------------
def test_eigener_eintrag_kann_bearbeitet_werden(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent")
eintrag_id = planung_anlegen(app, "Ark Nova", benutzer_objekt(app, "rez1").id,
ausgabe="")
melde_an(client, "rez1", "test-12345678")
formular = client.get(f"/planung/{eintrag_id}/bearbeiten")
assert formular.status_code == 200
assert "Ark Nova" in formular.text
antwort = client.post(f"/planung/{eintrag_id}/bearbeiten", data={
"titel": "Ark Nova", "verlag": "Feuerland Spiele",
"ausgabe": "4/2025", "notizen": "Schwerpunkt-Thema",
}, follow_redirects=True)
assert antwort.status_code == 200
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
eintrag = db.get(Planungseintrag, eintrag_id)
assert eintrag.ausgabe == "4/2025"
assert eintrag.notizen == "Schwerpunkt-Thema"
geaendert = next(
e for e in stubs.audit.eintraege if e.daten["action"] == "geaendert"
).daten
assert "alt" in geaendert["details"] and "neu" in geaendert["details"]
def test_fremder_eintrag_nicht_bearbeitbar_fuer_rezensenten(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent")
lege_benutzer_an(app, "rez2", "rezensent")
eintrag_id = planung_anlegen(app, "Ark Nova", benutzer_objekt(app, "rez1").id)
melde_an(client, "rez2", "test-12345678")
assert client.get(f"/planung/{eintrag_id}/bearbeiten").status_code == 403
assert client.post(f"/planung/{eintrag_id}/bearbeiten", data={
"titel": "Hack", "verlag": "", "ausgabe": "", "notizen": "",
}).status_code == 403
def test_eigener_eintrag_kann_geloescht_werden(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent")
eintrag_id = planung_anlegen(app, "Weg von hier", benutzer_objekt(app, "rez1").id)
melde_an(client, "rez1", "test-12345678")
antwort = client.post(f"/planung/{eintrag_id}/loeschen", follow_redirects=True)
assert antwort.status_code == 200
assert "wurde gelöscht" in antwort.text
assert planung_zaehlen(app) == 0
geloescht = next(
e for e in stubs.audit.eintraege if e.daten["action"] == "geloescht"
).daten
assert geloescht["details"]["titel"] == "Weg von hier"
def test_fremder_eintrag_nicht_loeschbar_fuer_rezensenten(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent")
lege_benutzer_an(app, "rez2", "rezensent")
eintrag_id = planung_anlegen(app, "Ark Nova", benutzer_objekt(app, "rez1").id)
melde_an(client, "rez2", "test-12345678")
antwort = client.post(f"/planung/{eintrag_id}/loeschen")
assert antwort.status_code == 403
assert planung_zaehlen(app) == 1
# ---------------- Liste / Zuordnung / Rechte im UI ----------------
def test_liste_zeigt_zuordnung_und_rechte(client, app, stubs):
lege_benutzer_an(app, "rez1", "rezensent", password="pw-rez1-123456")
lege_benutzer_an(app, "rez2", "rezensent", password="pw-rez2-123456")
own_id = planung_anlegen(app, "Mein Spiel", benutzer_objekt(app, "rez1").id)
fremd_id = planung_anlegen(app, "Fremdes Spiel", benutzer_objekt(app, "rez2").id)
melde_an(client, "rez1", "pw-rez1-123456")
antwort = client.get("/planung")
text_html = antwort.text
assert "Mein Spiel" in text_html and "Fremdes Spiel" in text_html
# Eigene Aktionen sichtbar, fremde nicht:
assert f"/planung/{own_id}/bearbeiten" in text_html
assert f"/planung/{fremd_id}/bearbeiten" not in text_html
# ---------------- Integration mit den echten Plugins ----------------
def test_integration_echte_plugins_benachrichtigung_und_audit(settings):
"""Ohne Stubs: Treffer löst echte In-App-Nachricht + Audit-Eintrag aus."""
app = create_app(settings)
lege_benutzer_an(app, "rez1", "rezensent")
lege_benutzer_an(app, "rez2", "rezensent")
planung_anlegen(app, "Azul", benutzer_objekt(app, "rez2").id) # fremd → Treffer
neuheit_id = neuheit_anlegen(app, "Azul", verlag="Next Move", bgg_id=777)
client = TestClient(app)
melde_an(client, "rez1", "test-12345678")
antwort = client.post(f"/planung/uebernehmen/{neuheit_id}", follow_redirects=True)
assert antwort.status_code == 200
# Echte In-App-Benachrichtigung für rez1:
benachrichtigungen = plugin_modul(app, "benachrichtigung").Benachrichtigung
with app.state.session_factory() as db:
nachrichten = db.scalars(select(benachrichtigungen)).all()
audit_zeilen = db.scalars(
select(plugin_modul(app, "audit-log").AuditEintrag)
).all()
assert any(n.user_id == benutzer_objekt(app, "rez1").id and "Azul" in n.titel
for n in nachrichten)
assert any(a.action == "verschoben" and a.objekt_typ == "planungseintrag"
for a in audit_zeilen)
# Und die echte dedup-Prüfung hat ihren Protokolleintrag bekommen:
pruefungen = plugin_modul(app, "dedup").Pruefung
with app.state.session_factory() as db:
protokoll = db.scalars(select(pruefungen)).all()
assert protokoll and protokoll[0].konflikte

View File

@@ -18,7 +18,7 @@ ERWARTETE_STUBS = {
} }
# Voll implementierte Plugins (keine Stubs mehr): # Voll implementierte Plugins (keine Stubs mehr):
ERWARTETE_PLUGINS = ERWARTETE_STUBS | {"neuheiten"} ERWARTETE_PLUGINS = ERWARTETE_STUBS | {"neuheiten", "dedup", "planung"}
def test_alle_plugins_werden_geladen(app): def test_alle_plugins_werden_geladen(app):
@@ -41,7 +41,7 @@ def test_plugins_haben_navigation(app):
# Voll implementierte Plugins tragen keinen Platzhalter-Text mehr auf ihrer # Voll implementierte Plugins tragen keinen Platzhalter-Text mehr auf ihrer
# Seite; die Prüfung läuft weiter für alle übrigen Platzhalter. # Seite; die Prüfung läuft weiter für alle übrigen Platzhalter.
VOLL_IMPLEMENTIERT = {"audit-log", "neuheiten", "benachrichtigung"} VOLL_IMPLEMENTIERT = {"audit-log", "neuheiten", "benachrichtigung", "dedup", "planung"}
PLATZHALTER_STUBS = ERWARTETE_STUBS - VOLL_IMPLEMENTIERT PLATZHALTER_STUBS = ERWARTETE_STUBS - VOLL_IMPLEMENTIERT

81
uv.lock generated
View File

@@ -750,6 +750,85 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
] ]
[[package]]
name = "rapidfuzz"
version = "3.14.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/f9/3c41a7be8855803f4f6c713b472226a98d31d41869d98f64f4ca790510d6/rapidfuzz-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e251126d48615e1f02b4a178f2cd0cd4f0332b8a019c01a2e10480f7552554b4", size = 1952372, upload-time = "2026-04-07T11:13:58.32Z" },
{ url = "https://files.pythonhosted.org/packages/9e/89/c2557e37531d03465193bff0ab9de70b468420a807d71a26a65100635459/rapidfuzz-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ab449c9abd0d4e1f8145dce0798a4c822a1a1933d613c764a641bea88b8bdab", size = 1159782, upload-time = "2026-04-07T11:14:00.127Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b2/ffeeb7eca1a897d51b998f4c0ef0281696c3b06abcca4f88f9def708ffe1/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2829fedd672dd7107267189dabe2bbe07972801d636014417c6861eb89e358", size = 1383677, upload-time = "2026-04-07T11:14:01.696Z" },
{ url = "https://files.pythonhosted.org/packages/6b/d0/4539e42a2d596e068f7738f279638a4a74edd1fbb6f8594e2458058979c6/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d50e5861872935fece391351cbb5ba21d1bced277cf5e1143d207a0a35f1925", size = 3168906, upload-time = "2026-04-07T11:14:03.29Z" },
{ url = "https://files.pythonhosted.org/packages/5e/1c/3ec897eb9d8b05308aa8ef6ae4ed64b088ad521a3f9d8ff469e7e97bc2b0/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:7092a216728f80c960bd6b3807275d1ee318b168986bd5dc523349581d4890b8", size = 1478176, upload-time = "2026-04-07T11:14:04.94Z" },
{ url = "https://files.pythonhosted.org/packages/ab/ba/970c03a12ce20a5399e22afe9f8932fd4cd1265b8a8461d0e63b00eb4eae/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9669753caef7fdc6529f6adcc5883ed98d65976445d9322e7dbdb6b697feee13", size = 2402441, upload-time = "2026-04-07T11:14:07.228Z" },
{ url = "https://files.pythonhosted.org/packages/81/93/61d351cae60c1d0e21ba5ff1a1015ad045539ed215da9d6e302204ed887a/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:823b1b9d9230809d8edcc18872770764bfe8ef4357995e16744047c8ccf0e489", size = 2511628, upload-time = "2026-04-07T11:14:09.234Z" },
{ url = "https://files.pythonhosted.org/packages/87/52/374d2d4f60fd98155142a869323aa221e30868cfa1f15171a0f64070c247/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f0b2af76b7e7060c09e1a0dfa9410eb19369cbe6164509bff2ef94094b54d2b6", size = 4275480, upload-time = "2026-04-07T11:14:11.332Z" },
{ url = "https://files.pythonhosted.org/packages/d8/04/82e7989bc9ec20a15b720a335c5cb6b0724bf6582013898f90a3280cfccd/rapidfuzz-3.14.5-cp311-cp311-win32.whl", hash = "sha256:c5801a89604c65ab4cc9e91b23bc4076d0ca80efd8c976fb63843d7879a85d7f", size = 1725627, upload-time = "2026-04-07T11:14:13.217Z" },
{ url = "https://files.pythonhosted.org/packages/b9/b5/eca8ac5609bc9bcb02bb6ff87fa5983cc92b8772d66a431556ab8a8c178f/rapidfuzz-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:d7ca16637c0ede8243f84074044bd0b2335a0341421f8227c85756de2d18c819", size = 1545977, upload-time = "2026-04-07T11:14:14.766Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e1/dbf318de28f65fa2cdd0a9dfbdee380f8199eb83b19259bc4f8592551b4e/rapidfuzz-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:8c90cdf8516d9057e502aa6003cea71cf5ec27cc44699ca52412b502a04761bb", size = 816827, upload-time = "2026-04-07T11:14:16.788Z" },
{ url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" },
{ url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" },
{ url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" },
{ url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" },
{ url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" },
{ url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" },
{ url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" },
{ url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" },
{ url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" },
{ url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" },
{ url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" },
{ url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" },
{ url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" },
{ url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" },
{ url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" },
{ url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" },
{ url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" },
{ url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" },
{ url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" },
{ url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" },
{ url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" },
{ url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" },
{ url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" },
{ url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" },
{ url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" },
{ url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" },
{ url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" },
{ url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" },
{ url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" },
{ url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" },
{ url = "https://files.pythonhosted.org/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327, upload-time = "2026-04-07T11:15:26.266Z" },
{ url = "https://files.pythonhosted.org/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755, upload-time = "2026-04-07T11:15:28.659Z" },
{ url = "https://files.pythonhosted.org/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571, upload-time = "2026-04-07T11:15:31.225Z" },
{ url = "https://files.pythonhosted.org/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468, upload-time = "2026-04-07T11:15:33.428Z" },
{ url = "https://files.pythonhosted.org/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311, upload-time = "2026-04-07T11:15:35.557Z" },
{ url = "https://files.pythonhosted.org/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228, upload-time = "2026-04-07T11:15:37.669Z" },
{ url = "https://files.pythonhosted.org/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226, upload-time = "2026-04-07T11:15:39.857Z" },
{ url = "https://files.pythonhosted.org/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283, upload-time = "2026-04-07T11:15:42.279Z" },
{ url = "https://files.pythonhosted.org/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614, upload-time = "2026-04-07T11:15:44.498Z" },
{ url = "https://files.pythonhosted.org/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971, upload-time = "2026-04-07T11:15:46.86Z" },
{ url = "https://files.pythonhosted.org/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985, upload-time = "2026-04-07T11:15:49.411Z" },
{ url = "https://files.pythonhosted.org/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517, upload-time = "2026-04-07T11:15:51.819Z" },
{ url = "https://files.pythonhosted.org/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056, upload-time = "2026-04-07T11:15:54.292Z" },
{ url = "https://files.pythonhosted.org/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732, upload-time = "2026-04-07T11:15:56.584Z" },
{ url = "https://files.pythonhosted.org/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902, upload-time = "2026-04-07T11:15:58.768Z" },
{ url = "https://files.pythonhosted.org/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130, upload-time = "2026-04-07T11:16:01.453Z" },
{ url = "https://files.pythonhosted.org/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308, upload-time = "2026-04-07T11:16:03.588Z" },
{ url = "https://files.pythonhosted.org/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210, upload-time = "2026-04-07T11:16:05.871Z" },
{ url = "https://files.pythonhosted.org/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621, upload-time = "2026-04-07T11:16:08.483Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950, upload-time = "2026-04-07T11:16:10.873Z" },
{ url = "https://files.pythonhosted.org/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357, upload-time = "2026-04-07T11:16:13.1Z" },
{ url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" },
{ url = "https://files.pythonhosted.org/packages/d9/ee/e71853bf82846c5c2174b924b71d8e8099fb05ff87c958a720380b434ba3/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:578e6051f6d5e6200c259b47a103cf06bb875ab5814d17333fc0b5c290b22f4c", size = 1888603, upload-time = "2026-04-07T11:16:18.223Z" },
{ url = "https://files.pythonhosted.org/packages/36/82/40f67b730f32be2ebad9f62add1571c754f52249254b2e88af094b907eee/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbf1b8bb2695415b347f3727da1addca2acb82c9b97ac86bebf8b1bead1eb12d", size = 1120599, upload-time = "2026-04-07T11:16:20.682Z" },
{ url = "https://files.pythonhosted.org/packages/ef/9f/a3635cc4ec8fc6e14b46e7db1f7f8763d8c4bef33dcc124eea2e6cb2c8f3/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4a8f5cc84c7ad6bffa0e9947b33eb343ad66e6b53e94fe54378a5508c5ed53", size = 1348524, upload-time = "2026-04-07T11:16:23.451Z" },
{ url = "https://files.pythonhosted.org/packages/cc/1b/2b229520f0b48464cfcd7aa758f74551d12c9bc4ab544022a60210aab064/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c6d85283629646fa87acc22c66b30ea9d4de7f6fdf887daa2e30fa041829b5", size = 3099302, upload-time = "2026-04-07T11:16:25.858Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" },
]
[[package]] [[package]]
name = "spiele-redaktion" name = "spiele-redaktion"
version = "0.1.0" version = "0.1.0"
@@ -762,6 +841,7 @@ dependencies = [
{ name = "itsdangerous" }, { name = "itsdangerous" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "rapidfuzz" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
@@ -781,6 +861,7 @@ requires-dist = [
{ 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" },
{ name = "rapidfuzz", specifier = ">=3.14.5" },
{ name = "sqlalchemy", specifier = ">=2.0" }, { name = "sqlalchemy", specifier = ">=2.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
] ]