Files
spiele-redaktion/plugins/audit-log/__init__.py
Flo Hartmann 9bd3b700c4 P1: Rollenbasiertes Dashboard mit Plugin-Karten
- Plugin-Vertrag erweitert: optionaler Hook dashboard_karten(user) +
  DashboardKarte-Dataclass (Kern bleibt schlank, Fachlogik im Plugin)
- Karten: Neuheiten+System-Status (neuheiten), Planungs-Stats bzw.
  eigene Einträge (planung), offene Dedup-Konflikte (dedup), nächste
  Redaktionsschlüsse (erinnerung), ungelesene Benachrichtigungen
  (benachrichtigung), letzte Audit-Einträge (audit-log, nur Admin),
  Benutzer-Anzahl (Kern, nur Admin)
- 'Geladene Plugins' als aufklappbarer Admin-Technikbereich
2026-08-26 08:48:30 +00:00

302 lines
11 KiB
Python

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