"""Compliance incidents — admin API.""" from __future__ import annotations from uuid import UUID from fastapi import APIRouter, Body, Depends, Query, Request from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthUser from app.compliance.models import ComplianceIncident from app.compliance.service import ComplianceIncidentService from app.core.database import get_db from app.core.dependencies import require_admin from app.core.response import paginated_response, success_response router = APIRouter(prefix="/api/v1/compliance-incidents", tags=["compliance"]) class IncidentResponse(BaseModel): id: UUID incident_type: str severity: str status: str person_id: UUID | None = None resource_type: str | None = None resource_id: UUID | None = None resource_label: str | None = None title: str detail: dict | None = None detected_at: str resolved_at: str | None = None resolved_by_person_id: UUID | None = None resolution_note: str | None = None def _serialize(row: ComplianceIncident) -> dict: return IncidentResponse( id=row.id, incident_type=row.incident_type, severity=row.severity, status=row.status, person_id=row.person_id, resource_type=row.resource_type, resource_id=row.resource_id, resource_label=row.resource_label, title=row.title, detail=row.detail, detected_at=row.detected_at.isoformat(), resolved_at=row.resolved_at.isoformat() if row.resolved_at else None, resolved_by_person_id=row.resolved_by_person_id, resolution_note=row.resolution_note, ).model_dump(mode="json") class ResolveIncidentRequest(BaseModel): accepted: bool = Field(default=False, description="True = known/expected, False = fixed") resolution_note: str | None = Field(default=None, max_length=2000) @router.get("", summary="List compliance incidents") async def list_incidents( request: Request, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), status: str | None = Query(default="open"), severity: str | None = Query(default=None), q: str | None = Query( default=None, max_length=200, description=( "Free-text search. ILIKE match against title, incident_type, " "and resource_label." ), ), page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=200), ): service = ComplianceIncidentService(db) rows, total = await service.list_incidents( status=status, severity=severity, q=q, page=page, page_size=page_size, ) return paginated_response( data=[_serialize(r) for r in rows], total=total, page=page, page_size=page_size, request=request, ) @router.get("/summary", summary="Open-incident counts by severity") async def summary_endpoint( request: Request, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): service = ComplianceIncidentService(db) counts = await service.count_open_by_severity() return success_response(data=counts, request=request) @router.get("/{incident_id}", summary="Get compliance incident detail") async def get_incident( incident_id: UUID, request: Request, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): service = ComplianceIncidentService(db) row = await service.get_incident(incident_id) return success_response(data=_serialize(row), request=request) @router.post("/{incident_id}/resolve", summary="Mark an incident resolved") async def resolve_incident( incident_id: UUID, request: Request, body: ResolveIncidentRequest = Body(default=ResolveIncidentRequest()), current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): service = ComplianceIncidentService(db) row = await service.resolve_incident( incident_id, resolved_by_person_id=current_user.id, resolution_note=body.resolution_note, accepted=body.accepted, trace_id=request.state.trace_id, ) await db.commit() return success_response(data=_serialize(row), request=request)