Files
spiele-redaktion/plugins/erinnerung/__init__.py
Flo Hartmann 469cf8826e 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)
2026-08-21 20:31:13 +00:00

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()