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)
This commit is contained in:
Flo Hartmann
2026-08-21 19:02:50 +00:00
parent f51732bff1
commit 83d574d95c
6 changed files with 708 additions and 29 deletions

View File

@@ -1,41 +1,278 @@
"""Plugin „audit-log“ — Platzhalter gemäß Plugin-Vertrag.
"""Plugin „audit-log“ — Wer hat was wann verschoben, eingetragen oder geändert.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
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
from fastapi import Depends, Request
import json
from datetime import datetime, time
from math import ceil
from urllib.parse import urlencode
from redaktionskern.auth.deps import require_user
from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, NavEntry
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 (Platzhalter)."
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 seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
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,
"name": self.name,
"version": self.version,
"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,
},
)
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/audit-log")]
plugin = AuditLogPlugin()