"""Audit log routes — admin-only, paginated, filterable, export, retention.""" from __future__ import annotations import csv import io import json import uuid from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from sqlalchemy import func, select, delete from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import require_permission from app.models.audit import AuditLog router = APIRouter(prefix="/api/v1/audit-log", tags=["audit"]) def _audit_to_dict(a: AuditLog) -> dict: """Serialize an AuditLog ORM object to dict.""" return { "id": str(a.id), "user_id": str(a.user_id) if a.user_id else None, "action": a.action, "entity_type": a.entity_type, "entity_id": str(a.entity_id) if a.entity_id else None, "changes": a.changes, "timestamp": a.timestamp.isoformat() if a.timestamp else None, "tenant_id": str(a.tenant_id), } @router.get("") async def list_audit_logs( entity_type: str | None = Query(None, description="Filter by entity type"), user_id: str | None = Query(None, description="Filter by user ID"), action: str | None = Query(None, description="Filter by action (create/update/delete/login)"), date_from: str | None = Query(None, description="ISO date range start (inclusive)"), date_to: str | None = Query(None, description="ISO date range end (inclusive)"), page: int = Query(1, ge=1, description="Page number"), page_size: int = Query(50, ge=1, le=200, description="Items per page"), db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("audit:read")), ): """List audit log entries with filtering and pagination. Admin only.""" tenant_id = uuid.UUID(current_user["tenant_id"]) q = select(AuditLog).where( AuditLog.tenant_id == tenant_id, AuditLog.deleted_at.is_(None), ) if entity_type: q = q.where(AuditLog.entity_type == entity_type) if user_id: try: uid = uuid.UUID(user_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"}) from None q = q.where(AuditLog.user_id == uid) if action: q = q.where(AuditLog.action == action) if date_from: try: dt_from = datetime.fromisoformat(date_from) except ValueError: raise HTTPException(400, detail={"detail": "Invalid date_from format", "code": "invalid_date"}) from None q = q.where(AuditLog.timestamp >= dt_from) if date_to: try: dt_to = datetime.fromisoformat(date_to) except ValueError: raise HTTPException(400, detail={"detail": "Invalid date_to format", "code": "invalid_date"}) from None q = q.where(AuditLog.timestamp <= dt_to) # Count total count_q = select(func.count()).select_from(q.subquery()) count_result = await db.execute(count_q) total = count_result.scalar() or 0 # Apply pagination offset = (page - 1) * page_size q = q.order_by(AuditLog.timestamp.desc()).offset(offset).limit(page_size) result = await db.execute(q) entries = result.scalars().all() return { "items": [_audit_to_dict(a) for a in entries], "total": total, "page": page, "page_size": page_size, "total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0, } @router.get("/export") async def export_audit_logs( format: str = Query("csv", description="Export format: csv or json"), entity_type: str | None = Query(None), action: str | None = Query(None), date_from: str | None = Query(None), date_to: str | None = Query(None), db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("audit:read")), ): """Export audit log entries as CSV or JSON. Admin only.""" tenant_id = uuid.UUID(current_user["tenant_id"]) q = select(AuditLog).where( AuditLog.tenant_id == tenant_id, AuditLog.deleted_at.is_(None), ) if entity_type: q = q.where(AuditLog.entity_type == entity_type) if action: q = q.where(AuditLog.action == action) if date_from: q = q.where(AuditLog.timestamp >= datetime.fromisoformat(date_from)) if date_to: q = q.where(AuditLog.timestamp <= datetime.fromisoformat(date_to)) q = q.order_by(AuditLog.timestamp.desc()).limit(10000) result = await db.execute(q) entries = result.scalars().all() if format == "json": content = json.dumps([_audit_to_dict(a) for a in entries], indent=2, default=str) return StreamingResponse( io.BytesIO(content.encode("utf-8")), media_type="application/json", headers={"Content-Disposition": "attachment; filename=audit_log_export.json"}, ) # CSV export output = io.StringIO() writer = csv.writer(output) writer.writerow(["id", "timestamp", "user_id", "action", "entity_type", "entity_id", "tenant_id"]) for a in entries: writer.writerow([ str(a.id), a.timestamp.isoformat() if a.timestamp else "", str(a.user_id) if a.user_id else "", a.action, a.entity_type, str(a.entity_id) if a.entity_id else "", str(a.tenant_id), ]) content = output.getvalue() return StreamingResponse( io.BytesIO(content.encode("utf-8")), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=audit_log_export.csv"}, ) @router.delete("/retention") async def audit_retention_cleanup( retention_days: int = Query(365, ge=1, le=3650, description="Delete entries older than N days"), db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("audit:read")), ): """Delete audit log entries older than retention_days. Admin only. Default retention: 365 days. """ tenant_id = uuid.UUID(current_user["tenant_id"]) cutoff = datetime.utcnow() - timedelta(days=retention_days) q = delete(AuditLog).where( AuditLog.tenant_id == tenant_id, AuditLog.timestamp < cutoff, ) result = await db.execute(q) await db.commit() return {"deleted": result.rowcount, "retention_days": retention_days, "cutoff": cutoff.isoformat()}