Teil 1 — Coverbilder: - Migration 0004_bild_url (neuheiten), 0002_bild_url (planung, archiv mit beiden Spiegel-Tabellen); nullable VARCHAR(500), SQLite↔Postgres portabel, bestehende Zeilen bleiben erhalten - BGG-Client extrahiert Cover aus der Thing-Antwort (bevorzugt <image>, sonst <thumbnail>; protokoll-relative URLs → https) - Sync-Upsert pflegt bild_url beim Update; fehlendes Bild löscht keines - Web-Quellen übernehmen img-URLs aus den Listenelementen (src/data-src, relative Pfade gegen die Seiten-URL aufgelöst); SPIEL-Essen nutzt das BILDER-Feld des JSON-Endpunkts - Verschiebung Neuheiten→Planung und Archivierung/Wiederherstellen schreiben bild_url mit - Anzeige: kleine Vorschaubilder (40x56) in den Listen, groß (max 200px) in Planung-Bearbeiten/Prüf-Dialog; Platzhalter-Div ohne Bild; Hotlinking mit loading=lazy + decoding=async Teil 2 — Responsives Layout: - Basislayout: Burger-Menü (Alpine.js) auf Mobil, Desktop-Leiste ab md: - Listen kollabieren <768px zu Karten-Ansichten (Cover links, Titel + Metadaten rechts, Status-Badge oben); Desktop behält Tabellenform - Touch-Ziele >= 44px auf Mobil, Formulare vollbreit mit Labels oben - Sync-Übersicht/Audit-Log bleiben horizontal scrollbare Tabellen Tests: 15 neue Fälle (tests/test_covers.py) — Migration inkl. Daten-Erhalt, BGG-Parsing gemockt, Quellen-Übernahme, Template-Render; 316 Tests grün. README aktualisiert.
560 lines
21 KiB
Python
560 lines
21 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 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)"
|
|
)
|
|
|
|
return [
|
|
Migration(version="0001_planungsliste", up=_planungs_tabelle),
|
|
Migration(version="0002_bild_url", up=bild_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 = "",
|
|
):
|
|
"""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,
|
|
"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),
|
|
},
|
|
)
|
|
|
|
@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,
|
|
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()
|