Plugin erinnerung: Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung
- Ausgaben (Name + Redaktionsschluss-Datum) verwalten, mehrere parallel, Anlegen/Bearbeiten/Löschen nur für Admin, mit Audit-Log-Einträgen - Erinnerung 28 Tage vor dem Redaktionsschluss an alle aktiven Rezensenten mit offener (oder fehlender) Planung, Versand über das benachrichtigung- Plugin je Kanal-Präferenz, Audit als System/benachrichtigt - Täglicher APScheduler-Job (SPIELE_ERINNERUNG_JOB_UHRZEIT, abschaltbar) plus manuelle Sofort-Prüfung in der Admin-Ansicht - Doppelschutz: Protokolltabelle mit Unique (Ausgabe, Benutzer) - Admin-UI: Ausgabenliste mit Restzeit-Badges, Erinnerungsstatus (wer wurde wann über welche Kanäle erinnert), deutsche Templates - Tests mit eingefrorener Uhr (freezegun): Fenster-Grenzen, kein Versand vor Frist/nach Schluss, Empfänger-/Rollenregeln, keine Duplikate, Scheduler-Konfiguration; Integrationstest mit den echten Plugins (167 Tests grün)
This commit is contained in:
@@ -1,41 +1,538 @@
|
||||
"""Plugin „erinnerung“ — Platzhalter gemäß Plugin-Vertrag.
|
||||
"""Plugin „erinnerung“ — Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung.
|
||||
|
||||
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
|
||||
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
|
||||
Umfang:
|
||||
- **Ausgaben verwalten** (nur Rolle Admin): Name/Titel + Redaktionsschluss-Datum,
|
||||
mehrere Ausgaben parallel, anlegen/bearbeiten/löschen.
|
||||
- **Automatische Erinnerung:** vier Wochen vor dem Redaktionsschluss erhält
|
||||
jeder aktive Rezensent eine Benachrichtigung, der noch offene
|
||||
Planungseinträge hat (Status ≠ „abgeschlossen“) oder gar keine — über das
|
||||
Plugin „benachrichtigung“ (`send_notification`), Kanäle je Benutzer-Präferenz.
|
||||
- **Geplanter Job:** APScheduler, täglich einmal (Uhrzeit per Env
|
||||
`SPIELE_ERINNERUNG_JOB_UHRZEIT`, abschaltbar per `SPIELE_ERINNERUNG_JOB_AKTIV`).
|
||||
- **Keine Doppelerinnerung:** pro (Ausgabe, Benutzer) wird genau einmal
|
||||
erinnert; der Versand wird in `erinnerung_protokoll` protokolliert.
|
||||
- **Audit-Log:** Ausgaben-CRUD und jede Erinnerung werden über das
|
||||
Plugin „audit-log“ protokolliert (Erinnerung als „System“/`benachrichtigt`).
|
||||
|
||||
Geschäftsregeln liegen in `dienst.py` (einfrierbare Uhr: `heute()`),
|
||||
Partner-Plugins werden wie überall über die Registry geholt; fehlen sie,
|
||||
wird der Durchlauf ohne Versand/Protokoll übersprungen bzw. ohne Audit
|
||||
fortgesetzt — der Vorgang bricht nie.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, Request
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
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 delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from redaktionskern.auth.deps import get_db, require_roles, require_user
|
||||
from redaktionskern.auth.models import Role, User
|
||||
from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext
|
||||
|
||||
# Bewusst `from .dienst import …` (wie `from .models import …` überall sonst):
|
||||
# `from . import dienst` funktioniert unter dem dateibasierten Plugin-Loader
|
||||
# nicht zuverlässig (Python 3.14 versucht dort einen absoluten Import der
|
||||
# Namensraum-Oberpakete).
|
||||
from .dienst import (
|
||||
ERINNERUNGSFENSTER_TAGE,
|
||||
alle_ausgaben,
|
||||
bereits_erinnerte_ids,
|
||||
empfaenger,
|
||||
faellige_ausgaben,
|
||||
heute,
|
||||
im_erinnerungsfenster,
|
||||
nachricht_text,
|
||||
parse_datum,
|
||||
tage_bis,
|
||||
)
|
||||
from .models import Ausgabe, ErinnerungsProtokoll
|
||||
|
||||
_logger = logging.getLogger("plugins.erinnerung")
|
||||
|
||||
MODUL_PRAEFIX = "spiele_redaktion_plugins"
|
||||
JOB_ID = "erinnerungs-check"
|
||||
MAX_MELDUNGS_LAENGE = 300
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pruefbericht:
|
||||
"""Ergebnis eines `pruefe_und_erinnere`-Durchlaufs."""
|
||||
|
||||
gepruefte_ausgaben: int = 0
|
||||
versendet: list[dict] = field(default_factory=list)
|
||||
#: (Ausgabe, Benutzer)-Paare im Fenster, die wegen Protokolleintrag
|
||||
#: übersprungen wurden (Doppelschutz gegriffen).
|
||||
uebersprungen: int = 0
|
||||
|
||||
@property
|
||||
def anzahl_versendet(self) -> int:
|
||||
return len(self.versendet)
|
||||
|
||||
def als_text(self) -> str:
|
||||
if self.gepruefte_ausgaben == 0:
|
||||
return "Keine Ausgaben mit offenem Erinnerungsfenster."
|
||||
return (
|
||||
f"{self.gepruefte_ausgaben} Ausgabe(n) im Erinnerungsfenster geprüft, "
|
||||
f"{self.anzahl_versendet} Erinnerung(en) versendet, "
|
||||
f"{self.uebersprungen} bereits erinnert."
|
||||
)
|
||||
|
||||
|
||||
def _tabellen_erstellen(conn) -> None:
|
||||
"""Eigene Migration des Plugins: legt beide Tabellen an (idempotent)."""
|
||||
Ausgabe.__table__.create(conn, checkfirst=True)
|
||||
ErinnerungsProtokoll.__table__.create(conn, checkfirst=True)
|
||||
|
||||
|
||||
class ErinnerungPlugin(BasePlugin):
|
||||
name = "erinnerung"
|
||||
title = "Erinnerungen"
|
||||
description = "Erinnerungen vor Redaktionsschluss pro Ausgabe (Platzhalter)."
|
||||
description = (
|
||||
"Vier Wochen vor dem Redaktionsschluss pro Ausgabe Erinnerung an alle "
|
||||
"Rezensenten mit offener (oder fehlender) Planung."
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._scheduler = None # BackgroundScheduler, falls aktiviert
|
||||
self._lauf_sperre = threading.Lock()
|
||||
self._registry = None # wird bei Requests gemerkt (Partner-Auflösung)
|
||||
self._routen_registrieren()
|
||||
|
||||
# ---------- Plugin-Vertrag ----------
|
||||
|
||||
def migrations(self) -> list[Migration]:
|
||||
return [Migration(version="0001_ausgaben_und_protokoll", up=_tabellen_erstellen)]
|
||||
|
||||
def navigation(self) -> list[NavEntry]:
|
||||
return [NavEntry(label=self.title, url="/erinnerung")]
|
||||
|
||||
def on_load(self, context: PluginContext) -> None:
|
||||
super().on_load(context)
|
||||
import os
|
||||
|
||||
if os.environ.get("SPIELE_ERINNERUNG_JOB_AKTIV", "1").strip() == "1":
|
||||
self._scheduler_starten(os.environ.get("SPIELE_ERINNERUNG_JOB_UHRZEIT"))
|
||||
|
||||
def on_unload(self) -> None:
|
||||
if self._scheduler is not None:
|
||||
self._scheduler.shutdown(wait=False)
|
||||
self._scheduler = None
|
||||
super().on_unload()
|
||||
|
||||
# ---------- Partner-Plugins (benachrichtigung, audit-log) ----------
|
||||
|
||||
def _merke_registry(self, registry) -> None:
|
||||
"""Merk sich die Registry, damit auch der Hintergrund-Job Partner
|
||||
auflösen kann (auch ohne vorherigen Request via sys.modules-Fallback)."""
|
||||
self._registry = registry
|
||||
|
||||
def _partner(self, name: str):
|
||||
"""Löst ein Partner-Plugin auf: Registry zuerst, sonst Modul-Lookup.
|
||||
|
||||
Der Fallback über `sys.modules` deckt den Fall „Job läuft, bevor der
|
||||
erste Request die Registry gemerkt hat“ ab (Verzeichnis-Plugins).
|
||||
"""
|
||||
if self._registry is not None:
|
||||
partner = self._registry.get(name)
|
||||
if partner is not None:
|
||||
return partner
|
||||
modul = sys.modules.get(f"{MODUL_PRAEFIX}.{name}")
|
||||
return getattr(modul, "plugin", None) if modul else None
|
||||
|
||||
# ---------- Öffentliche API für andere Plugins/Tests ----------
|
||||
|
||||
async def pruefe_und_erinnere(
|
||||
self,
|
||||
jetzt: date | None = None,
|
||||
*,
|
||||
benachrichtigung=None,
|
||||
audit=None,
|
||||
) -> Pruefbericht:
|
||||
"""Prüft alle Ausgaben gegen die eingefrierbare Uhr und erinnert.
|
||||
|
||||
`jetzt` überschreibt die Uhr (Tests); ohne Angabe gilt `heute()`.
|
||||
Partner-Plugins können übergeben werden, sonst werden sie über die
|
||||
Registry aufgelöst. Der Durchlauf wirft nicht für einzelne Empfänger:
|
||||
Fehlschläge werden protokolliert und im Bericht gezählt.
|
||||
"""
|
||||
assert self.context is not None
|
||||
if benachrichtigung is None:
|
||||
benachrichtigung = self._partner("benachrichtigung")
|
||||
if audit is None:
|
||||
audit = self._partner("audit-log")
|
||||
|
||||
pruef_tag = jetzt if jetzt is not None else heute()
|
||||
bericht = Pruefbericht()
|
||||
|
||||
with self.context.session_factory() as db:
|
||||
for ausgabe in faellige_ausgaben(db, pruef_tag):
|
||||
bericht.gepruefte_ausgaben += 1
|
||||
tage_rest = tage_bis(ausgabe.redaktionsschluss, pruef_tag)
|
||||
schon_erinnert = bereits_erinnerte_ids(db, ausgabe.id)
|
||||
text = nachricht_text(ausgabe, tage_rest)
|
||||
|
||||
for empfaenger_user in empfaenger(db):
|
||||
if empfaenger_user.id in schon_erinnert:
|
||||
bericht.uebersprungen += 1
|
||||
continue
|
||||
kanaele: list[str] = []
|
||||
if benachrichtigung is not None:
|
||||
try:
|
||||
zustellbericht = await benachrichtigung.send_notification(
|
||||
empfaenger_user,
|
||||
f"Redaktionsschluss: {ausgabe.name}",
|
||||
text,
|
||||
"erinnerung",
|
||||
)
|
||||
kanaele = list(getattr(zustellbericht, "zugestellt", []))
|
||||
except Exception:
|
||||
# Ein Kanalfehler darf den Durchlauf nicht abbrechen;
|
||||
# ohne Protokolleintrag würde der nächste Lauf es
|
||||
# erneut (und hoffentlich erfolgreich) versuchen.
|
||||
_logger.exception(
|
||||
"Erinnerung an %s (Ausgabe %s) fehlgeschlagen.",
|
||||
empfaenger_user.username,
|
||||
ausgabe.name,
|
||||
)
|
||||
continue
|
||||
db.add(
|
||||
ErinnerungsProtokoll(
|
||||
ausgabe_id=ausgabe.id,
|
||||
user_id=empfaenger_user.id,
|
||||
kanaele=",".join(kanaele),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
bericht.versendet.append(
|
||||
{
|
||||
"ausgabe": ausgabe.name,
|
||||
"ausgabe_id": ausgabe.id,
|
||||
"benutzer": empfaenger_user.username,
|
||||
"user_id": empfaenger_user.id,
|
||||
"kanaele": kanaele,
|
||||
"tage_rest": tage_rest,
|
||||
}
|
||||
)
|
||||
if audit is not None:
|
||||
try:
|
||||
await audit.log(
|
||||
None,
|
||||
"benachrichtigt",
|
||||
"erinnerung",
|
||||
empfaenger_user.id,
|
||||
{
|
||||
"ausgabe": ausgabe.name,
|
||||
"ausgabe_id": ausgabe.id,
|
||||
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
|
||||
"tage_rest": tage_rest,
|
||||
"kanaele": kanaele,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"Audit-Log-Eintrag für Erinnerung fehlgeschlagen."
|
||||
)
|
||||
return bericht
|
||||
|
||||
def pruefe_und_erinnere_sync(
|
||||
self,
|
||||
jetzt: date | None = None,
|
||||
*,
|
||||
benachrichtigung=None,
|
||||
audit=None,
|
||||
) -> Pruefbericht:
|
||||
"""Synchrone Variante von `pruefe_und_erinnere` (wie audit.log_sync).
|
||||
|
||||
Für Kontexte ohne laufende Event-Loop (APScheduler-Thread, Tests).
|
||||
Darf nicht aus einer async-Funktion heraus aufgerufen werden.
|
||||
"""
|
||||
return asyncio.run(
|
||||
self.pruefe_und_erinnere(
|
||||
jetzt, benachrichtigung=benachrichtigung, audit=audit
|
||||
)
|
||||
)
|
||||
|
||||
# ---------- Hintergrund-Job ----------
|
||||
|
||||
def _scheduler_starten(self, uhrzeit_roh: str | None) -> None:
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
stunde, minute = 8, 0
|
||||
roh = (uhrzeit_roh or "08:00").strip()
|
||||
try:
|
||||
teile = roh.split(":")
|
||||
stunde = max(0, min(23, int(teile[0])))
|
||||
minute = max(0, min(59, int(teile[1]))) if len(teile) > 1 else 0
|
||||
except (ValueError, IndexError):
|
||||
stunde, minute = 8, 0
|
||||
|
||||
scheduler = BackgroundScheduler()
|
||||
scheduler.add_job(
|
||||
self._job,
|
||||
trigger="cron",
|
||||
hour=stunde,
|
||||
minute=minute,
|
||||
id=JOB_ID,
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
self._scheduler = scheduler
|
||||
_logger.info(
|
||||
"Erinnerungs-Check aktiv: täglich um %02d:%02d Uhr.", stunde, minute
|
||||
)
|
||||
|
||||
def _job(self) -> None:
|
||||
"""Hintergrund-Job: tägliche Prüfung (synchroner Kontext, eigener Thread)."""
|
||||
if not self._lauf_sperre.acquire(blocking=False):
|
||||
_logger.info("Erinnerungs-Check läuft bereits — Durchlauf übersprungen.")
|
||||
return
|
||||
try:
|
||||
bericht = self.pruefe_und_erinnere_sync()
|
||||
_logger.info("Erinnerungs-Check abgeschlossen: %s", bericht.als_text())
|
||||
except Exception:
|
||||
_logger.exception("Erinnerungs-Check fehlgeschlagen.")
|
||||
finally:
|
||||
self._lauf_sperre.release()
|
||||
|
||||
# ---------- Routen ----------
|
||||
|
||||
@staticmethod
|
||||
def _meldung(url: str, text: str, art: str = "meldung") -> RedirectResponse:
|
||||
return RedirectResponse(f"{url}?{art}={quote(text)}", status_code=303)
|
||||
|
||||
def _routen_registrieren(self) -> None:
|
||||
|
||||
@self.router.get("/erinnerung")
|
||||
def seite(request: Request, user: User = Depends(require_user)):
|
||||
"""Platzhalterseite des Plugins."""
|
||||
def seite(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
meldung: str = "",
|
||||
fehler: str = "",
|
||||
):
|
||||
"""Übersicht: Ausgaben mit Fristen; Admins verwalten und sehen
|
||||
den Erinnerungsstatus (wer wurde wann erinnert)."""
|
||||
self._merke_registry(request.app.state.registry)
|
||||
ist_admin = user.role == Role.ADMIN.value
|
||||
|
||||
pruef_tag = heute()
|
||||
ausgaben = alle_ausgaben(db)
|
||||
zeilen = []
|
||||
for ausgabe in ausgaben:
|
||||
tage_rest = tage_bis(ausgabe.redaktionsschluss, pruef_tag)
|
||||
zeilen.append(
|
||||
{
|
||||
"ausgabe": ausgabe,
|
||||
"tage_rest": tage_rest,
|
||||
"im_fenster": im_erinnerungsfenster(
|
||||
ausgabe.redaktionsschluss, pruef_tag
|
||||
),
|
||||
"erinnert_anzahl": db.scalar(
|
||||
select(func.count())
|
||||
.select_from(ErinnerungsProtokoll)
|
||||
.where(ErinnerungsProtokoll.ausgabe_id == ausgabe.id)
|
||||
)
|
||||
or 0,
|
||||
}
|
||||
)
|
||||
|
||||
protokoll = []
|
||||
if ist_admin:
|
||||
namen = {
|
||||
benutzer.id: (benutzer.display_name or benutzer.username)
|
||||
for benutzer in db.scalars(select(User)).all()
|
||||
}
|
||||
eintraege = list(
|
||||
db.scalars(
|
||||
select(ErinnerungsProtokoll).order_by(
|
||||
ErinnerungsProtokoll.erstellt_am.desc(),
|
||||
ErinnerungsProtokoll.id.desc(),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
ausgaben_namen = {a.id: a.name for a in ausgaben}
|
||||
for eintrag in eintraege:
|
||||
protokoll.append(
|
||||
{
|
||||
"eintrag": eintrag,
|
||||
"ausgabe_name": ausgaben_namen.get(eintrag.ausgabe_id, "—"),
|
||||
"benutzer": namen.get(eintrag.user_id, "—"),
|
||||
}
|
||||
)
|
||||
|
||||
return self.context.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="erinnerung/index.html",
|
||||
context={
|
||||
"user": user,
|
||||
"titel": self.title,
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"zeilen": zeilen,
|
||||
"ist_admin": ist_admin,
|
||||
"protokoll": protokoll,
|
||||
"fenster_tage": ERINNERUNGSFENSTER_TAGE,
|
||||
"meldung": meldung[:MAX_MELDUNGS_LAENGE],
|
||||
"fehler": fehler[:MAX_MELDUNGS_LAENGE],
|
||||
},
|
||||
)
|
||||
|
||||
def navigation(self) -> list[NavEntry]:
|
||||
return [NavEntry(label=self.title, url="/erinnerung")]
|
||||
@self.router.post("/erinnerung/ausgaben")
|
||||
def ausgabe_anlegen(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_roles(Role.ADMIN.value)),
|
||||
name: str = Form(""),
|
||||
redaktionsschluss: str = Form(""),
|
||||
):
|
||||
self._merke_registry(request.app.state.registry)
|
||||
roh_name = name.strip()
|
||||
if not roh_name:
|
||||
return self._meldung("/erinnerung", "Bitte einen Ausgaben-Namen angeben.", "fehler")
|
||||
try:
|
||||
schluss = parse_datum(redaktionsschluss)
|
||||
except ValueError:
|
||||
return self._meldung(
|
||||
"/erinnerung",
|
||||
"Ungültiges Datum — bitte als JJJJ-MM-TT angeben.",
|
||||
"fehler",
|
||||
)
|
||||
if schluss is None:
|
||||
return self._meldung("/erinnerung", "Bitte ein Redaktionsschluss-Datum angeben.", "fehler")
|
||||
|
||||
ausgabe = Ausgabe(name=roh_name, redaktionsschluss=schluss)
|
||||
db.add(ausgabe)
|
||||
db.commit()
|
||||
self._audit_sync(request, user, "erstellt", ausgabe.id, {
|
||||
"name": ausgabe.name,
|
||||
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
|
||||
})
|
||||
return self._meldung(
|
||||
"/erinnerung",
|
||||
f"Ausgabe „{ausgabe.name}“ angelegt (Redaktionsschluss "
|
||||
f"{ausgabe.redaktionsschluss.strftime('%d.%m.%Y')}).",
|
||||
)
|
||||
|
||||
@self.router.get("/erinnerung/ausgaben/{ausgabe_id}/bearbeiten")
|
||||
def bearbeiten_formular(
|
||||
request: Request,
|
||||
ausgabe_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_roles(Role.ADMIN.value)),
|
||||
):
|
||||
ausgabe = db.get(Ausgabe, ausgabe_id)
|
||||
if ausgabe is None:
|
||||
return self._meldung("/erinnerung", "Ausgabe nicht gefunden.", "fehler")
|
||||
return self.context.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="erinnerung/bearbeiten.html",
|
||||
context={
|
||||
"user": user,
|
||||
"titel_headline": f"Ausgabe bearbeiten — {ausgabe.name}",
|
||||
"ausgabe": ausgabe,
|
||||
},
|
||||
)
|
||||
|
||||
@self.router.post("/erinnerung/ausgaben/{ausgabe_id}/bearbeiten")
|
||||
def bearbeiten_speichern(
|
||||
request: Request,
|
||||
ausgabe_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_roles(Role.ADMIN.value)),
|
||||
name: str = Form(""),
|
||||
redaktionsschluss: str = Form(""),
|
||||
):
|
||||
self._merke_registry(request.app.state.registry)
|
||||
ausgabe = db.get(Ausgabe, ausgabe_id)
|
||||
if ausgabe is None:
|
||||
return self._meldung("/erinnerung", "Ausgabe nicht gefunden.", "fehler")
|
||||
roh_name = name.strip()
|
||||
if not roh_name:
|
||||
return self._meldung("/erinnerung", "Bitte einen Ausgaben-Namen angeben.", "fehler")
|
||||
try:
|
||||
schluss = parse_datum(redaktionsschluss)
|
||||
except ValueError:
|
||||
return self._meldung(
|
||||
"/erinnerung",
|
||||
"Ungültiges Datum — bitte als JJJJ-MM-TT angeben.",
|
||||
"fehler",
|
||||
)
|
||||
if schluss is None:
|
||||
return self._meldung("/erinnerung", "Bitte ein Redaktionsschluss-Datum angeben.", "fehler")
|
||||
|
||||
alt = {
|
||||
"name": ausgabe.name,
|
||||
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
|
||||
}
|
||||
ausgabe.name = roh_name
|
||||
ausgabe.redaktionsschluss = schluss
|
||||
db.commit()
|
||||
neu = {
|
||||
"name": ausgabe.name,
|
||||
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
|
||||
}
|
||||
geaendert = [k for k in alt if alt[k] != neu[k]]
|
||||
if geaendert:
|
||||
self._audit_sync(request, user, "geaendert", ausgabe.id, {
|
||||
"alt": {k: alt[k] for k in geaendert},
|
||||
"neu": {k: neu[k] for k in geaendert},
|
||||
})
|
||||
return self._meldung("/erinnerung", f"Ausgabe „{ausgabe.name}“ gespeichert.")
|
||||
|
||||
@self.router.post("/erinnerung/ausgaben/{ausgabe_id}/loeschen")
|
||||
def ausgabe_loeschen(
|
||||
request: Request,
|
||||
ausgabe_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_roles(Role.ADMIN.value)),
|
||||
):
|
||||
self._merke_registry(request.app.state.registry)
|
||||
ausgabe = db.get(Ausgabe, ausgabe_id)
|
||||
if ausgabe is None:
|
||||
return self._meldung("/erinnerung", "Ausgabe nicht gefunden.", "fehler")
|
||||
snapshot = {
|
||||
"name": ausgabe.name,
|
||||
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
|
||||
}
|
||||
# Protokollzeilen dieser Ausgabe mitlöschen (FK ohne Cascade).
|
||||
db.execute(
|
||||
delete(ErinnerungsProtokoll).where(
|
||||
ErinnerungsProtokoll.ausgabe_id == ausgabe_id
|
||||
)
|
||||
)
|
||||
db.delete(ausgabe)
|
||||
db.commit()
|
||||
self._audit_sync(request, user, "geloescht", ausgabe_id, snapshot)
|
||||
return self._meldung("/erinnerung", f"Ausgabe „{snapshot['name']}“ gelöscht.")
|
||||
|
||||
@self.router.post("/erinnerung/pruefen")
|
||||
async def jetzt_pruefen(
|
||||
request: Request,
|
||||
user: User = Depends(require_roles(Role.ADMIN.value)),
|
||||
):
|
||||
"""Manueller Durchlauf (gleiche Logik wie der tägliche Job)."""
|
||||
self._merke_registry(request.app.state.registry)
|
||||
bericht = await self.pruefe_und_erinnere()
|
||||
return self._meldung("/erinnerung", f"Prüfung abgeschlossen: {bericht.als_text()}")
|
||||
|
||||
# ---------- Audit (synchrone Routen → log_sync des audit-log-Plugins) ----------
|
||||
|
||||
def _audit_sync(self, request: Request, actor, action: str, objekt_id, details: dict) -> None:
|
||||
audit = request.app.state.registry.get("audit-log")
|
||||
if audit is None:
|
||||
return
|
||||
try:
|
||||
audit.log_sync(actor, action, "ausgabe", objekt_id, details)
|
||||
except Exception:
|
||||
_logger.exception("Audit-Log-Eintrag für Ausgabe fehlgeschlagen.")
|
||||
|
||||
|
||||
plugin = ErinnerungPlugin()
|
||||
|
||||
Reference in New Issue
Block a user