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