- 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)
539 lines
21 KiB
Python
539 lines
21 KiB
Python
"""Plugin „erinnerung“ — Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung.
|
|
|
|
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
|
|
|
|
import asyncio
|
|
import logging
|
|
import sys
|
|
import threading
|
|
from dataclasses import dataclass, field
|
|
from datetime import date
|
|
from urllib.parse import quote
|
|
|
|
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 = (
|
|
"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,
|
|
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,
|
|
"zeilen": zeilen,
|
|
"ist_admin": ist_admin,
|
|
"protokoll": protokoll,
|
|
"fenster_tage": ERINNERUNGSFENSTER_TAGE,
|
|
"meldung": meldung[:MAX_MELDUNGS_LAENGE],
|
|
"fehler": fehler[:MAX_MELDUNGS_LAENGE],
|
|
},
|
|
)
|
|
|
|
@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()
|