feat: punkt 7 (audit log) — export route (CSV/JSON), retention cleanup ARQ cron job (daily 03:00, 365 days default)

This commit is contained in:
Agent Zero
2026-08-20 13:50:38 +02:00
parent 10b1f83fb3
commit 2a173c9909
2 changed files with 136 additions and 3 deletions
+88 -3
View File
@@ -1,12 +1,16 @@
"""Audit log routes — admin-only, paginated, filterable."""
"""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
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from fastapi.responses import StreamingResponse
from sqlalchemy import func, select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
@@ -92,3 +96,84 @@ async def list_audit_logs(
"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()}