bifroest/app/audit/views.py
2026-10-27 08:34:17 +01:00

118 lines
4.9 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Audit-Log ansehen und exportieren (→ Konzept 13)."""
import csv
import io
import json
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.audit.models import AuditEvent
from app.core.errors import Invalid
from app.identity.models import User
from app.workspaces.models import Workspace
PAGE = 100
EXPORT_LIMIT = 50_000
@dataclass
class Filters:
action: str = "" # Präfix, z. B. "approval." oder "source.tested"
user_id: int | None = None
workspace_id: int | None = None
start: date | None = None
end: date | None = None # einschließlich
before: int | None = None # Blättern: nur Ereignisse mit kleinerer ID
query: dict[str, str] = field(default_factory=dict) # für Links (ohne before)
def parse(params: dict[str, str], *, workspace_id: int | None = None) -> Filters:
def day(name: str) -> date | None:
value = params.get(name, "").strip()
if not value:
return None
try:
return date.fromisoformat(value)
except ValueError:
raise Invalid(f"Ungültiges Datum: {value}") from None
def number(name: str) -> int | None:
value = params.get(name, "").strip()
return int(value) if value.isdigit() else None
filters = Filters(action=params.get("action", "").strip()[:100], user_id=number("user"),
workspace_id=workspace_id if workspace_id is not None else number("workspace"),
start=day("start"), end=day("end"), before=number("before"))
filters.query = {k: v for k, v in params.items() if k in ("action", "user", "workspace", "start", "end") and v}
return filters
def statement(filters: Filters):
query = select(AuditEvent)
if filters.action:
query = query.where(AuditEvent.action.startswith(filters.action, autoescape=True))
if filters.user_id is not None:
query = query.where(AuditEvent.user_id == filters.user_id)
if filters.workspace_id is not None:
query = query.where(AuditEvent.workspace_id == filters.workspace_id)
if filters.start:
query = query.where(AuditEvent.time >= datetime.combine(filters.start, time.min).astimezone())
if filters.end:
query = query.where(AuditEvent.time < datetime.combine(filters.end + timedelta(days=1), time.min).astimezone())
return query
def page(session: Session, filters: Filters) -> tuple[list[AuditEvent], int | None]:
"""Eine Seite, neueste zuerst; dazu die ID für die nächste Seite (oder None)."""
query = statement(filters)
if filters.before is not None:
query = query.where(AuditEvent.id < filters.before)
events = list(session.scalars(query.order_by(AuditEvent.id.desc()).limit(PAGE + 1)))
more = len(events) > PAGE
events = events[:PAGE]
return events, (events[-1].id if more else None)
def actions(session: Session, workspace_id: int | None = None) -> list[str]:
"""Vorkommende Aktionen für die Filterauswahl."""
query = select(AuditEvent.action).distinct().order_by(AuditEvent.action)
if workspace_id is not None:
query = query.where(AuditEvent.workspace_id == workspace_id)
return list(session.scalars(query))
def labels(session: Session, events: list[AuditEvent]) -> tuple[dict[int, str], dict[int, str]]:
user_ids = {e.user_id for e in events if e.user_id}
workspace_ids = {e.workspace_id for e in events if e.workspace_id}
users = {u.id: u.display_name for u in session.scalars(select(User).where(User.id.in_(user_ids)))}
workspaces = {w.id: w.name for w in session.scalars(select(Workspace).where(Workspace.id.in_(workspace_ids)))}
return users, workspaces
def all_users(session: Session) -> list[User]:
return list(session.scalars(select(User).order_by(User.display_name)))
def all_workspaces(session: Session) -> list[Workspace]:
return list(session.scalars(select(Workspace).order_by(Workspace.name)))
def count(session: Session, filters: Filters) -> int:
return session.scalar(select(func.count()).select_from(statement(filters).subquery())) or 0
def export(session: Session, filters: Filters) -> str:
"""CSV (Semikolon, für Excel), höchstens EXPORT_LIMIT Zeilen, neueste zuerst."""
events = list(session.scalars(statement(filters).order_by(AuditEvent.id.desc()).limit(EXPORT_LIMIT)))
users, workspaces = labels(session, events)
out = io.StringIO()
writer = csv.writer(out, delimiter=";")
writer.writerow(["id", "zeit", "aktion", "nutzer", "workspace", "ziel", "ziel_id", "daten"])
for e in events:
writer.writerow([e.id, e.time.isoformat(timespec="seconds"), e.action, users.get(e.user_id, e.user_id or ""),
workspaces.get(e.workspace_id, e.workspace_id or ""), e.target_kind, e.target_id,
json.dumps(e.data, ensure_ascii=False, default=str)])
return "" + out.getvalue() # BOM, damit Excel UTF-8 erkennt