Files
spiele-redaktion/plugins/planung/__init__.py

590 lines
23 KiB
Python

"""Plugin „planung“ — Planungsliste mit Zuordnung an Rezensenten.
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
import logging
from urllib.parse import quote
from fastapi import Depends, Form, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import func, 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 — 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]:
def bild_url_spalte(conn) -> None:
"""Migration 0002: Spalte bild_url für Coverbilder (nullable).
Portabel (SQLite/Postgres): `ALTER TABLE … ADD COLUMN` mit
nullable VARCHAR ohne Default; bestehende Zeilen bleiben erhalten.
"""
if conn.dialect.name == "sqlite":
info = conn.exec_driver_sql(
"PRAGMA table_info(planungsliste)"
).fetchall()
if any(zeile[1] == "bild_url" for zeile in info):
return
else:
existiert = conn.exec_driver_sql(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'planungsliste' AND column_name = 'bild_url'"
).scalar()
if existiert is not None:
return
conn.exec_driver_sql(
"ALTER TABLE planungsliste ADD COLUMN bild_url VARCHAR(500)"
)
def quellen_url_spalte(conn) -> None:
"""Migration 0003: Spalte quellen_url (Link zur Herkunftsquelle)."""
if conn.dialect.name == "sqlite":
info = conn.exec_driver_sql(
"PRAGMA table_info(planungsliste)"
).fetchall()
if any(zeile[1] == "quellen_url" for zeile in info):
return
else:
existiert = conn.exec_driver_sql(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'planungsliste' AND column_name = 'quellen_url'"
).scalar()
if existiert is not None:
return
conn.exec_driver_sql(
"ALTER TABLE planungsliste ADD COLUMN quellen_url VARCHAR(500)"
)
return [
Migration(version="0001_planungsliste", up=_planungs_tabelle),
Migration(version="0002_bild_url", up=bild_url_spalte),
Migration(version="0003_quellen_url", up=quellen_url_spalte),
]
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",
bild_url: str | None = None,
):
"""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,
"bild_url": bild_url,
"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 liste(
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
status: str = "",
meldung: str = "",
fehler: str = "",
seite_nr: int = 1,
):
"""Planungsliste: Einträge paginiert (50/Seite) mit Zuordnung."""
pro_seite = 50
abfrage = select(Planungseintrag)
if status in GUELTIGE_STATUS:
abfrage = abfrage.where(Planungseintrag.status == status)
gesamt = db.scalar(
select(func.count()).select_from(abfrage.subquery())
) or 0
eintraege = db.scalars(
abfrage.order_by(
Planungseintrag.aktualisiert_am.desc(), Planungseintrag.id.desc()
)
.offset((max(seite_nr, 1) - 1) * pro_seite)
.limit(pro_seite)
).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,
"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": gesamt,
"hat_weitere": max(seite_nr, 1) * pro_seite < gesamt,
"naechste_seite": max(seite_nr, 1) + 1,
},
)
@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",
bild_url=zeile.bild_url,
)
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,
bild_url=zeile.bild_url,
quellen_url=zeile.quellen_url,
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()