Files
spiele-redaktion/plugins/audit-log/__init__.py
Flo Hartmann 83d574d95c Plugin audit-log: Protokolltabelle, öffentliche API und Admin-Ansicht
- Eigene Migration 0001_audit_eintraege (Tabelle audit_eintraege: Akteur,
  Aktion, Objekttyp/-ID, Alt/Neu + Details als JSON, optionale IP, Zeitstempel)
- Öffentliche Plugin-API: await log(actor, action, objekt_typ, objekt_id,
  details, ip_adresse) bzw. log_sync() für synchrone Routen; andere Plugins
  holen die API über request.app.state.registry.get("audit-log")
- Admin-Ansicht /audit-log (nur Admin): Filter nach Benutzer, Aktionstyp und
  Zeitraum, Paginierung (25/Seite), deutsche UI, Alt/Neu-JSON-Darstellung
- Tests: Logging-Funktion (Actor-Varianten, alt/neu-Extraktion), Migration,
  Filter-Querys, Paginierung, Nur-Admin-Zugriff (13 neue Tests)
- Loader-Test angepasst: voll implementierte Plugins tragen keinen
  Platzhalter-Text mehr
- AGENTS.md: Status aktualisiert

Verifiziert: HEAD + diese Änderungen = 58 Tests grün (uv run pytest)
2026-08-21 19:02:50 +00:00

279 lines
9.7 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, 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 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()