"""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