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:
@@ -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:
|
||||
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
|
||||
Öffentliche API für andere Plugins (z. B. „planung“):
|
||||
|
||||
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 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.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):
|
||||
name = "dedup"
|
||||
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:
|
||||
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")
|
||||
def seite(request: Request, user: User = Depends(require_user)):
|
||||
"""Platzhalterseite des Plugins."""
|
||||
return self.context.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="dedup/index.html",
|
||||
context={
|
||||
"user": user,
|
||||
"titel": self.title,
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
},
|
||||
)
|
||||
self._routen_registrieren()
|
||||
|
||||
# ---------- Plugin-Vertrag ----------
|
||||
|
||||
def migrations(self) -> list[Migration]:
|
||||
return [Migration(version="0001_pruefprotokoll", up=_protokoll_tabelle)]
|
||||
|
||||
def navigation(self) -> list[NavEntry]:
|
||||
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()
|
||||
|
||||
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 inhalt %}
|
||||
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
||||
<p class="text-slate-600 max-w-2xl">
|
||||
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
|
||||
in Version {{ version }} ist geladen.
|
||||
Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert.
|
||||
<p class="text-slate-600 mb-4">
|
||||
Die Prüfung läuft automatisch bei jeder Eintragung in die Planungsliste.
|
||||
Hier kann sie auch von Hand für einen Titel ausgeführt werden.
|
||||
</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 %}
|
||||
|
||||
@@ -80,6 +80,7 @@ class NeuheitenPlugin(BasePlugin):
|
||||
sort: str = STANDARD_SORTIERUNG[0],
|
||||
richtung: str = STANDARD_SORTIERUNG[1],
|
||||
meldung: str = "",
|
||||
fehler: str = "",
|
||||
):
|
||||
spalte = SORTIERBAR.get(sort, SORTIERBAR[STANDARD_SORTIERUNG[0]])
|
||||
abwaerts = richtung == "ab" if sort in SORTIERBAR else False
|
||||
@@ -117,6 +118,7 @@ class NeuheitenPlugin(BasePlugin):
|
||||
"richtung": "ab" if abwaerts else "auf",
|
||||
"status_optionen": status_optionen,
|
||||
"meldung": meldung[:MAX_MELDUNGS_LAENGE],
|
||||
"fehler": fehler[:MAX_MELDUNGS_LAENGE],
|
||||
"ist_redaktion": user.role in (
|
||||
Role.ADMIN.value,
|
||||
Role.REDAKTEUR.value,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
<th class="px-4 py-3 font-medium">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
@@ -34,10 +35,18 @@
|
||||
<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 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>
|
||||
{% else %}
|
||||
<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.
|
||||
{% if ist_redaktion %}Nutze „Jetzt synchronisieren“, um die Liste aus BoardGameGeek zu befüllen.{% endif %}
|
||||
</td>
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{{ 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="flex flex-wrap items-end gap-3 mb-4">
|
||||
{% 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:
|
||||
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
|
||||
Zwei Eintragswege, beide laufen automatisch durch die dedup-Prüfung des
|
||||
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 fastapi import Depends, Request
|
||||
import logging
|
||||
from urllib.parse import quote
|
||||
|
||||
from redaktionskern.auth.deps import require_user
|
||||
from redaktionskern.auth.models import User
|
||||
from redaktionskern.contracts import BasePlugin, NavEntry
|
||||
from fastapi import Depends, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import 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):
|
||||
name = "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:
|
||||
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")
|
||||
def seite(request: Request, user: User = Depends(require_user)):
|
||||
"""Platzhalterseite des Plugins."""
|
||||
def liste(
|
||||
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(
|
||||
request=request,
|
||||
name="planung/index.html",
|
||||
context={
|
||||
"user": user,
|
||||
"titel": self.title,
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"eintraege": eintraege,
|
||||
"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]:
|
||||
return [NavEntry(label=self.title, url="/planung")]
|
||||
@self.router.post("/planung/neu")
|
||||
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()
|
||||
|
||||
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 inhalt %}
|
||||
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
||||
<p class="text-slate-600 max-w-2xl">
|
||||
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
|
||||
in Version {{ version }} ist geladen.
|
||||
Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert.
|
||||
<p class="text-slate-600 mb-4">
|
||||
Planungsliste der Redaktion: wer reviewt welches Spiel für welche Ausgabe.
|
||||
Jede Eintragung — aus den Neuheiten verschoben oder händisch nachgetragen —
|
||||
wird automatisch durch die Dedup-Prüfung geprüft.
|
||||
</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 %}
|
||||
|
||||
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 %}
|
||||
Reference in New Issue
Block a user