"""Plugin „audit-log“ — Wer hat was wann verschoben, eingetragen oder geändert. Voller Plugin-Vertrag: eigene Migration (Tabelle audit_eintraege), eigene Routen/Templates (Admin-Ansicht mit Filtern und Paginierung) und eine öffentliche API, die andere Plugins bei relevanten Ereignissen aufrufen. Andere Plugins benutzen die API so: audit = request.app.state.registry.get("audit-log") if audit is not None: await audit.log(user, "geaendert", "planungseintrag", eintrag.id, details={"alt": alt, "neu": neu}, ip_adresse=request.client.host) In synchronen Kontexten (FastAPI führt `def`-Routen im Threadpool aus) steht `audit.log_sync(...)` mit denselben Argumenten bereit. """ from __future__ import annotations import json from datetime import datetime, time from math import ceil from urllib.parse import urlencode from fastapi import Depends, Request from sqlalchemy import 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, DashboardKarte, Migration, NavEntry from .models import AuditEintrag SEITEN_GROESSE = 25 SYSTEM_NAME = "System" # Kanonische Aktionstypen mit deutschen Anzeigenamen. Andere Plugins sollen # diese Konstanten verwenden, damit die Filter-Auswahl konsistent bleibt. AKTIONEN_ANZEIGE: dict[str, str] = { "erstellt": "Eintrag erstellt", "verschoben": "Eintrag verschoben", "geaendert": "Eintrag geändert", "geloescht": "Eintrag gelöscht", "benachrichtigt": "Benachrichtigung versendet", } def _json_schoen(wert) -> str: """Jinja-Filter: Dict lesbar als JSON formatieren (leer → Leerstring).""" if not wert: return "" return json.dumps(wert, ensure_ascii=False, indent=2, sort_keys=True) def _aus_actor(actor: User | str | None) -> tuple[int | None, str]: """Normalisiert den Akteur auf (Benutzer-ID, Name im Log).""" if actor is None: return None, SYSTEM_NAME if isinstance(actor, User): return actor.id, actor.username return None, str(actor) def _tabelle_anlegen(conn) -> None: """Migration 0001: legt die Plugin-Tabelle an (portabel, idempotent).""" AuditEintrag.__table__.create(conn, checkfirst=True) def _parse_datum(roh: str) -> datetime | None: """Parst ein ISO-Datum (JJJJ-MM-TT); None bei leer, ValueError bei Unsinn.""" roh = (roh or "").strip() if not roh: return None return datetime.strptime(roh, "%Y-%m-%d") class AuditLogPlugin(BasePlugin): name = "audit-log" title = "Audit-Log" description = "Wer hat was wann verschoben, eingetragen oder geändert." # ---------- Plugin-Vertrag ---------- def migrations(self) -> list[Migration]: return [Migration(version="0001_audit_eintraege", up=_tabelle_anlegen)] def navigation(self) -> list[NavEntry]: return [NavEntry(label=self.title, url=f"/{self.name}")] def dashboard_karten(self, user: User) -> list[DashboardKarte]: """Dashboard: die letzten fünf Audit-Einträge (nur Admins).""" if user.role != Role.ADMIN.value: return [] with self.context.session_factory() as db: letzte = db.scalars( select(AuditEintrag).order_by(AuditEintrag.id.desc()).limit(5) ).all() zeilen = tuple( f"{e.erstellt_am.strftime('%d.%m. %H:%M') if e.erstellt_am else '—'}" f" · {e.actor_name or 'System'}: {e.action}" for e in letzte ) return [ DashboardKarte( titel="Letzte Audit-Log-Einträge", zeilen=zeilen, beschreibung="" if zeilen else "Noch keine Ereignisse protokolliert.", link_url="/audit-log", link_label="Zum Audit-Log", ) ] def on_load(self, context) -> None: super().on_load(context) # Jinja-Filter für die JSON-Anzeige in der Admin-Ansicht. context.templates.env.filters["json_schoen"] = _json_schoen # ---------- Öffentliche API für andere Plugins ---------- async def log( self, actor: User | str | None, action: str, objekt_typ: str = "", objekt_id: int | str | None = None, details: dict | None = None, *, ip_adresse: str | None = None, ) -> None: """Protokolliert ein Ereignis (asynchrone Variante). `details` ist ein freies Dict; die Schlüssel „alt“ und „neu“ werden in die gleichnamigen JSON-Spalten übernommen, der Rest landet in `details`. """ self._speichere(actor, action, objekt_typ, objekt_id, details, ip_adresse) def log_sync( self, actor: User | str | None, action: str, objekt_typ: str = "", objekt_id: int | str | None = None, details: dict | None = None, *, ip_adresse: str | None = None, ) -> None: """Synchrone Variante von log() für `def`-Routen (Threadpool).""" self._speichere(actor, action, objekt_typ, objekt_id, details, ip_adresse) def _speichere( self, actor: User | str | None, action: str, objekt_typ: str, objekt_id: int | str | None, details: dict | None, ip_adresse: str | None, ) -> None: actor_id, actor_name = _aus_actor(actor) alt = neu = rest = None if details: rest = dict(details) alt = rest.pop("alt", None) neu = rest.pop("neu", None) if not rest: rest = None with self.context.session_factory() as db: db.add( AuditEintrag( actor_id=actor_id, actor_name=actor_name, action=action, objekt_typ=objekt_typ or "", objekt_id="" if objekt_id is None else str(objekt_id), alt=alt, neu=neu, details=rest, ip_adresse=ip_adresse, ) ) db.commit() # ---------- Admin-Ansicht ---------- def __init__(self) -> None: super().__init__() @self.router.get("/audit-log") def ansicht( request: Request, db: Session = Depends(get_db), benutzer: str = "", aktion: str = "", von: str = "", bis: str = "", seite: int = 1, user: User = Depends(require_roles(Role.ADMIN.value)), ): """Admin-Ansicht: filterbar nach Benutzer, Aktion und Zeitraum.""" fehler: list[str] = [] filter_liste = [] if benutzer.strip(): filter_liste.append(AuditEintrag.actor_name == benutzer.strip()) if aktion.strip(): filter_liste.append(AuditEintrag.action == aktion.strip()) try: von_dt = _parse_datum(von) except ValueError: von_dt = None fehler.append("„Von“ ist kein gültiges Datum (JJJJ-MM-TT) — Filter ignoriert.") try: bis_dt = _parse_datum(bis) except ValueError: bis_dt = None fehler.append("„Bis“ ist kein gültiges Datum (JJJJ-MM-TT) — Filter ignoriert.") if von_dt is not None: filter_liste.append(AuditEintrag.erstellt_am >= von_dt) if bis_dt is not None: filter_liste.append( AuditEintrag.erstellt_am <= datetime.combine(bis_dt.date(), time.max) ) gesamt = db.scalar( select(func.count()).select_from(AuditEintrag).where(*filter_liste) ) or 0 gesamt_seiten = max(1, ceil(gesamt / SEITEN_GROESSE)) seite = min(max(1, seite), gesamt_seiten) offset = (seite - 1) * SEITEN_GROESSE eintraege = ( db.scalars( select(AuditEintrag) .where(*filter_liste) .order_by(AuditEintrag.erstellt_am.desc(), AuditEintrag.id.desc()) .offset(offset) .limit(SEITEN_GROESSE) ) .all() ) # Auswahl für die Filter-Dropdowns (aus vorhandenen Einträgen). benutzer_auswahl = [ name for name in db.scalars( select(AuditEintrag.actor_name).distinct().order_by(AuditEintrag.actor_name) ) if name ] aktionen_auswahl = db.scalars( select(AuditEintrag.action).distinct().order_by(AuditEintrag.action) ).all() # Paginierungs-Links mit erhaltenen Filtern. parameter = { schluessel: wert for schluessel, wert in ( ("benutzer", benutzer.strip()), ("aktion", aktion.strip()), ("von", von.strip()), ("bis", bis.strip()), ) if wert } basis = urlencode(parameter) def _seiten_url(nr: int) -> str: return f"/audit-log?{basis}&seite={nr}" if basis else f"/audit-log?seite={nr}" return self.context.templates.TemplateResponse( request=request, name="audit-log/index.html", context={ "user": user, "titel": self.title, "eintraege": eintraege, "gesamt": gesamt, "seite": seite, "gesamt_seiten":gesamt_seiten, "seiten_groesse": SEITEN_GROESSE, "start": offset + 1 if eintraege else 0, "ende": offset + len(eintraege), "url_zurueck": _seiten_url(seite - 1), "url_weiter": _seiten_url(seite + 1), "url_ohne_seite": f"/audit-log?{basis}" if basis else "/audit-log", "benutzer_auswahl": benutzer_auswahl, "aktionen_auswahl": aktionen_auswahl, "aktionen_anzeige": AKTIONEN_ANZEIGE, "filter_benutzer": benutzer.strip(), "filter_aktion": aktion.strip(), "filter_von": von.strip(), "filter_bis": bis.strip(), "fehler": fehler, }, ) plugin = AuditLogPlugin()