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:
@@ -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
106
README.md
@@ -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 |
|
||||||
|
|||||||
@@ -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
104
plugins/dedup/bgg.py
Normal 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
38
plugins/dedup/models.py
Normal 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
413
plugins/dedup/pruefung.py
Normal 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 0–100).
|
||||||
|
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
|
||||||
@@ -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 — die Funktion wird in einer spä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">🏚 Verlags-Konflikt</p>
|
||||||
|
<p class="text-slate-500">Gleiches Spiel bei verschiedenen Verlagen/Vertrieb → Verlagsauswahl.</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white border border-slate-200 rounded-xl p-3">
|
||||||
|
<p class="font-medium text-slate-700 mb-1">🇧 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">📖 Bereits besprochen</p>
|
||||||
|
<p class="text-slate-500">Spiel oder Vorgä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">📋 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ü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ü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 %}⚠ {{ ergebnis.konflikte|length }} Treffer für „{{ ergebnis.titel }}“{% else %}✓ Keine Treffer fü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ü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ü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üfungen durchgeführt.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">→ 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äge gefunden.
|
Keine Einträge gefunden.
|
||||||
{% if ist_redaktion %}Nutze „Jetzt synchronisieren“, um die Liste aus BoardGameGeek zu befüllen.{% endif %}
|
{% if ist_redaktion %}Nutze „Jetzt synchronisieren“, um die Liste aus BoardGameGeek zu befüllen.{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
66
plugins/planung/models.py
Normal 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})>"
|
||||||
41
plugins/planung/templates/planung/bearbeiten.html
Normal file
41
plugins/planung/templates/planung/bearbeiten.html
Normal 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 }}“ · 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 %}
|
||||||
@@ -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ür welche Ausgabe.
|
||||||
in Version {{ version }} ist geladen.
|
Jede Eintragung — aus den Neuheiten verschoben oder händisch nachgetragen —
|
||||||
Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert.
|
wird automatisch durch die Dedup-Prüfung geprü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ä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üfen & 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ä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 {{ 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.">⚠ Prü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ö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äge — schiebe einen Titel aus den Neuheiten hierher oder trage ihn oben händisch nach.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
55
plugins/planung/templates/planung/pruefung.html
Normal file
55
plugins/planung/templates/planung/pruefung.html
Normal 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üfung hat Treffer ergeben. Bitte prü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">⚠ {{ konflikte|length }} Treffer fü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ü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ähltem Verlag übernehmen
|
||||||
|
</button>
|
||||||
|
<a href="{{ abbrechen_url }}" class="text-sm text-slate-500 hover:text-slate-800 underline">Abbrechen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
391
tests/test_dedup.py
Normal 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
575
tests/test_planung.py
Normal 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
|
||||||
@@ -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
81
uv.lock
generated
@@ -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" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user