diff --git a/AGENTS.md b/AGENTS.md index d986771..721484f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,9 @@ Multi-User-Webanwendung „KI-Assistenz für Spielemagazin-Redaktionen": Neuheitenliste (BGG), Dedup-Prüfungen, Planungsliste, Archivierung, Erinnerungen, Benachrichtigungen (E-Mail/Telegram/In-App), Audit-Log, Export. ## Status -Setup — Prompt freigegeben 2026-08-21, Implementierung noch nicht begonnen. +Kern mit Plugin-System/Auth/Migrationen fertig; `audit-log` vollständig +implementiert; `neuheiten`/`benachrichtigung` in Arbeit; übrige Plugins Stubs. +Live-Status: README.md → „Projekt-Fortschritt“. ## Struktur | Pfad | Inhalt | diff --git a/plugins/audit-log/__init__.py b/plugins/audit-log/__init__.py index 9c7af3f..0c0143c 100644 --- a/plugins/audit-log/__init__.py +++ b/plugins/audit-log/__init__.py @@ -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() diff --git a/plugins/audit-log/models.py b/plugins/audit-log/models.py new file mode 100644 index 0000000..3509702 --- /dev/null +++ b/plugins/audit-log/models.py @@ -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 + ) diff --git a/plugins/audit-log/templates/audit-log/index.html b/plugins/audit-log/templates/audit-log/index.html index cc09663..20a8fa1 100644 --- a/plugins/audit-log/templates/audit-log/index.html +++ b/plugins/audit-log/templates/audit-log/index.html @@ -1,10 +1,132 @@ {% extends "base.html" %} -{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %} +{% block titel %}{{ titel }} — {{ app_name }}{% endblock %} {% block inhalt %} -
- Plugin {{ name }}
- in Version {{ version }} ist geladen.
- Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert.
+
+ Protokoll aller relevanten Ereignisse — nur für Administratoren sichtbar.
+ +{% if fehler %} + {% for meldung in fehler %} +| Zeitpunkt | +Benutzer | +Aktion | +Objekt | +Änderung / Details | +IP | +
|---|---|---|---|---|---|
| + {{ e.erstellt_am.strftime('%d.%m.%Y %H:%M:%S') if e.erstellt_am else '–' }} + | +{{ e.actor_name }} | +{{ aktionen_anzeige[e.action] or e.action }} | ++ {{ e.objekt_typ }}{% if e.objekt_id %} #{{ e.objekt_id }}{% endif %} + | +
+ {% if e.alt or e.neu %}
+ {% if e.alt %} Alt:
+ {% endif %}
+ {% if e.neu %}{{ e.alt | json_schoen }}Neu:
+ {% endif %}
+ {% elif e.details %}
+ {{ e.neu | json_schoen }}{{ e.details | json_schoen }}
+ {% else %}
+ –
+ {% endif %}
+ |
+ {{ e.ip_adresse or '–' }} | +
| + Keine Einträge gefunden. + | +|||||
+ {% if gesamt %} + Zeige {{ start }}–{{ ende }} von {{ gesamt }} Einträgen + {% else %} + 0 Einträge + {% endif %} +
+ {% if gesamt_seiten > 1 %} + + {% endif %} +