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

@@ -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
)