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:
@@ -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()
|
||||
|
||||
40
plugins/audit-log/models.py
Normal file
40
plugins/audit-log/models.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Datenmodell des Plugins „audit-log“.
|
||||
|
||||
Ein AuditEintrag protokolliert ein Ereignis: wer hat was wann mit welchem
|
||||
Objekt getan (Alt-/Neustand als JSON, IP optional). Die Tabelle gehört
|
||||
ausschließlich zu diesem Plugin; der Kern kennt sie nicht.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from redaktionskern.db import Base
|
||||
|
||||
|
||||
class AuditEintrag(Base):
|
||||
"""Ein protokolliertes Ereignis im Audit-Log."""
|
||||
|
||||
__tablename__ = "audit_eintraege"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
# Wer: Benutzer-ID und -Name (NULL/„System“ bei automatischen Ereignissen)
|
||||
actor_id: Mapped[int | None] = mapped_column(Integer, index=True)
|
||||
actor_name: Mapped[str] = mapped_column(String(100), default="", index=True)
|
||||
# Was: Aktionstyp (z. B. „erstellt“, „geaendert“, „geloescht“)
|
||||
action: Mapped[str] = mapped_column(String(100), index=True)
|
||||
# Auf welches Objekt: Typ + ID (ID als Text, damit alle ID-Arten passen)
|
||||
objekt_typ: Mapped[str] = mapped_column(String(100), default="")
|
||||
objekt_id: Mapped[str] = mapped_column(String(100), default="")
|
||||
# Alt-/Neustand und freie Zusatzinformationen als JSON
|
||||
alt: Mapped[dict | None] = mapped_column(JSON)
|
||||
neu: Mapped[dict | None] = mapped_column(JSON)
|
||||
details: Mapped[dict | None] = mapped_column(JSON)
|
||||
# Optionale Herkunftsangabe
|
||||
ip_adresse: Mapped[str | None] = mapped_column(String(64))
|
||||
# Wann
|
||||
erstellt_am: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), index=True
|
||||
)
|
||||
@@ -1,10 +1,132 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
|
||||
{% block titel %}{{ titel }} — {{ app_name }}{% endblock %}
|
||||
{% block inhalt %}
|
||||
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
||||
<p class="text-slate-600 max-w-2xl">
|
||||
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
|
||||
in Version {{ version }} ist geladen.
|
||||
Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert.
|
||||
<h1 class="text-2xl font-bold mb-1">{{ titel }}</h1>
|
||||
<p class="text-slate-600 mb-4 text-sm">
|
||||
Protokoll aller relevanten Ereignisse — nur für Administratoren sichtbar.
|
||||
</p>
|
||||
|
||||
{% if fehler %}
|
||||
{% for meldung in fehler %}
|
||||
<div class="mb-3 rounded-lg border border-amber-300 bg-amber-50 text-amber-800 px-4 py-2 text-sm">
|
||||
{{ meldung }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form method="get" action="/audit-log" class="bg-white rounded-xl border border-slate-200 p-4 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 items-end">
|
||||
<label class="block text-sm">
|
||||
<span class="text-slate-500 block mb-1">Benutzer</span>
|
||||
<select name="benutzer"
|
||||
class="w-full border border-slate-300 rounded-lg px-2 py-2 bg-white">
|
||||
<option value="">Alle</option>
|
||||
{% for name in benutzer_auswahl %}
|
||||
<option value="{{ name }}" {% if name == filter_benutzer %}selected{% endif %}>{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="text-slate-500 block mb-1">Aktionstyp</span>
|
||||
<select name="aktion"
|
||||
class="w-full border border-slate-300 rounded-lg px-2 py-2 bg-white">
|
||||
<option value="">Alle</option>
|
||||
{% for a in aktionen_auswahl %}
|
||||
<option value="{{ a }}" {% if a == filter_aktion %}selected{% endif %}>
|
||||
{{ aktionen_anzeige[a] or a }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="text-slate-500 block mb-1">Von</span>
|
||||
<input type="date" name="von" value="{{ filter_von }}"
|
||||
class="w-full border border-slate-300 rounded-lg px-2 py-2">
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="text-slate-500 block mb-1">Bis</span>
|
||||
<input type="date" name="bis" value="{{ filter_bis }}"
|
||||
class="w-full border border-slate-300 rounded-lg px-2 py-2">
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit"
|
||||
class="bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||
Filtern
|
||||
</button>
|
||||
<a href="/audit-log"
|
||||
class="border border-slate-300 hover:bg-slate-50 rounded-lg px-4 py-2 text-sm text-slate-600">
|
||||
Zurücksetzen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="bg-white rounded-xl border border-slate-200 overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-2 font-medium whitespace-nowrap">Zeitpunkt</th>
|
||||
<th class="px-4 py-2 font-medium">Benutzer</th>
|
||||
<th class="px-4 py-2 font-medium">Aktion</th>
|
||||
<th class="px-4 py-2 font-medium">Objekt</th>
|
||||
<th class="px-4 py-2 font-medium">Änderung / Details</th>
|
||||
<th class="px-4 py-2 font-medium">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for e in eintraege %}
|
||||
<tr class="align-top">
|
||||
<td class="px-4 py-2 text-slate-500 whitespace-nowrap">
|
||||
{{ e.erstellt_am.strftime('%d.%m.%Y %H:%M:%S') if e.erstellt_am else '–' }}
|
||||
</td>
|
||||
<td class="px-4 py-2 font-medium">{{ e.actor_name }}</td>
|
||||
<td class="px-4 py-2">{{ aktionen_anzeige[e.action] or e.action }}</td>
|
||||
<td class="px-4 py-2">
|
||||
{{ e.objekt_typ }}{% if e.objekt_id %} #{{ e.objekt_id }}{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 max-w-md">
|
||||
{% if e.alt or e.neu %}
|
||||
{% if e.alt %}<div class="mb-1"><span class="text-slate-400 text-xs">Alt:</span>
|
||||
<pre class="text-xs bg-slate-50 rounded p-1 overflow-x-auto whitespace-pre-wrap">{{ e.alt | json_schoen }}</pre></div>{% endif %}
|
||||
{% if e.neu %}<div><span class="text-slate-400 text-xs">Neu:</span>
|
||||
<pre class="text-xs bg-emerald-50 rounded p-1 overflow-x-auto whitespace-pre-wrap">{{ e.neu | json_schoen }}</pre></div>{% endif %}
|
||||
{% elif e.details %}
|
||||
<pre class="text-xs bg-slate-50 rounded p-1 overflow-x-auto whitespace-pre-wrap">{{ e.details | json_schoen }}</pre>
|
||||
{% else %}
|
||||
<span class="text-slate-400">–</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-slate-500">{{ e.ip_adresse or '–' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-slate-500">
|
||||
Keine Einträge gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-3 text-sm text-slate-600">
|
||||
<p>
|
||||
{% if gesamt %}
|
||||
Zeige {{ start }}–{{ ende }} von {{ gesamt }} Einträgen
|
||||
{% else %}
|
||||
0 Einträge
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if gesamt_seiten > 1 %}
|
||||
<div class="flex items-center gap-2">
|
||||
{% if seite > 1 %}
|
||||
<a href="{{ url_zurueck }}" class="border border-slate-300 hover:bg-slate-50 rounded-lg px-3 py-1">← Zurück</a>
|
||||
{% endif %}
|
||||
<span>Seite {{ seite }} von {{ gesamt_seiten }}</span>
|
||||
{% if seite < gesamt_seiten %}
|
||||
<a href="{{ url_weiter }}" class="border border-slate-300 hover:bg-slate-50 rounded-lg px-3 py-1">Weiter →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user