118 lines
4.9 KiB
Python
118 lines
4.9 KiB
Python
"""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
|