"""Admin audit log API: read the append-only who/what/when trail, and clear it. Admin-only. The client renders these rows in a DataTable (client-side sort/filter/paginate), so this returns the most recent window of rows (capped) plus the true total, rather than paging server-side — the log stays small in practice (state-changing runs + admin actions only).""" from fastapi import APIRouter, Depends from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from app import audit from app.audit import AuditAction from app.db import get_db from app.models import AuditLog, User from app.routes.admin import admin_user router = APIRouter(prefix="/api/admin/audit", tags=["admin"]) # Cap the client-side window. The audit log is small by design; if it ever outgrows this we'd # switch the DataTable to server-side paging. MAX_ROWS = 2000 def _serialize(row: AuditLog, actors: dict[int, str]) -> dict: return { "id": row.id, "created_at": row.created_at.isoformat() if row.created_at else None, # actor_id NULL = the scheduler/background did it (client shows "System"); a set id that # can't be resolved would mean a deleted user, but SET NULL makes that NULL — so present. "actor": actors.get(row.actor_id) if row.actor_id is not None else None, "action": row.action, "target_type": row.target_type, "target_id": row.target_id, "summary": row.summary, "before": row.before, "after": row.after, } @router.get("") def list_audit( limit: int = 500, _: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: limit = max(1, min(limit, MAX_ROWS)) total = db.scalar(select(func.count()).select_from(AuditLog)) or 0 rows = ( db.execute(select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit)) .scalars() .all() ) actor_ids = {r.actor_id for r in rows if r.actor_id is not None} actors: dict[int, str] = {} if actor_ids: actors = dict( db.execute(select(User.id, User.email).where(User.id.in_(actor_ids))).all() ) return { "items": [_serialize(r, actors) for r in rows], "total": total, "returned": len(rows), } @router.delete("") def clear_audit(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: """Clear the audit log. Leaves ONE row recording who cleared it + how many (tamper-evidence).""" n = db.execute(delete(AuditLog)).rowcount or 0 audit.record(db, admin.id, AuditAction.AUDIT_CLEAR, summary=f"Cleared {n} audit entries") db.commit() return {"cleared": n}