"""HTTP surface for the automation-exception workbench. RBAC follows the platform's established split: reads are open to the admin-read audience (``global_admin`` / ``auditor`` / ``management``), because "what automation is broken right now" is governance-relevant and an auditor asking it should not need write authority. Every transition is ``global_admin`` only — each one records a judgement that changes what the platform believes about a live problem. The vocabulary endpoint exists so the frontend never hard-codes a status, severity, cause or remedy list. Those sets live in ``taxonomy.py``; a second copy in TypeScript is a copy that drifts. """ from __future__ import annotations import uuid from fastapi import APIRouter, Body, Depends, Path, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import require_admin, require_admin_read from app.core.response import success_response from app.domains.automation_exception import service from app.domains.automation_exception.collectors import SOURCE_REF_TYPES from app.domains.automation_exception.ownership import OWNER_SOURCES from app.domains.automation_exception.schemas import ( AcknowledgeRequest, AssignOwnerRequest, CloseWontFixRequest, ReopenRequest, SetRootCauseRequest, ) from app.domains.automation_exception.taxonomy import ( REMEDIES, REMEDY_BY_CAUSE, ROOT_CAUSES, SEVERITIES, SOURCES, STATUSES, ) router = APIRouter( prefix="/api/v1/admin/automation-exceptions", tags=["admin-automation-exceptions"], ) def _trace(request: Request) -> str: return str(getattr(request.state, "trace_id", "") or "") @router.get( "/vocabulary", summary="Closed vocabularies — sources, statuses, severities, causes, remedies", ) async def vocabulary( request: Request, current_user: AuthUser = Depends(require_admin_read()), ): return success_response( data={ "sources": list(SOURCES), "source_ref_types": SOURCE_REF_TYPES, "statuses": list(STATUSES), "severities": list(SEVERITIES), "root_causes": list(ROOT_CAUSES), "remedies": list(REMEDIES), # The mapping, not just the two lists: the frontend renders # "cause → what to do" and must not re-derive it. "remedy_by_cause": dict(REMEDY_BY_CAUSE), "owner_sources": list(OWNER_SOURCES), }, request=request, ) @router.get("/overview", summary="Workbench header counters and window trend") async def overview( request: Request, window_days: int = Query(7, ge=1, le=180), current_user: AuthUser = Depends(require_admin_read()), db: AsyncSession = Depends(get_db), ): data = await service.get_overview(db, window_days=window_days) return success_response(data=data, request=request) @router.get("", summary="List automation exceptions, worst first") async def list_exceptions( request: Request, status: str | None = Query( "active", description=( "'active' (default), 'closed', 'all', or one exact status value." ), ), source: str | None = Query(None), severity: str | None = Query(None), root_cause: str | None = Query(None), integration_id: uuid.UUID | None = Query(None), owner_person_id: uuid.UUID | None = Query(None), unowned_only: bool = Query(False), search: str | None = Query(None, max_length=200), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=service.MAX_PAGE_SIZE), current_user: AuthUser = Depends(require_admin_read()), db: AsyncSession = Depends(get_db), ): data = await service.list_exceptions( db, status=status, source=source, severity=severity, root_cause=root_cause, integration_id=integration_id, owner_person_id=owner_person_id, unowned_only=unowned_only, search=search, page=page, page_size=page_size, ) return success_response( data=data["items"], meta={ "total": data["total"], "page": data["page"], "page_size": data["page_size"], }, request=request, ) @router.get("/{exception_id}", summary="One exception with evidence and people") async def get_exception( request: Request, exception_id: uuid.UUID = Path(...), current_user: AuthUser = Depends(require_admin_read()), db: AsyncSession = Depends(get_db), ): data = await service.get_exception(db, exception_id) return success_response(data=data, request=request) @router.post("/{exception_id}/acknowledge", summary="Accept that it is real") async def acknowledge( request: Request, exception_id: uuid.UUID = Path(...), body: AcknowledgeRequest = Body(default_factory=AcknowledgeRequest), current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): data = await service.acknowledge( db, exception_id, actor_user_id=current_user.id, trace_id=_trace(request), note=body.note, ) await db.commit() return success_response(data=data, request=request) @router.post("/{exception_id}/root-cause", summary="Record a human verdict on the cause") async def set_root_cause( request: Request, exception_id: uuid.UUID = Path(...), body: SetRootCauseRequest = Body(...), current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): data = await service.set_root_cause( db, exception_id, root_cause=body.root_cause, actor_user_id=current_user.id, trace_id=_trace(request), note=body.note, ) await db.commit() return success_response(data=data, request=request) @router.post("/{exception_id}/wont-fix", summary="Close without a fix (reason required)") async def close_wont_fix( request: Request, exception_id: uuid.UUID = Path(...), body: CloseWontFixRequest = Body(...), current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): data = await service.close_wont_fix( db, exception_id, reason=body.reason, actor_user_id=current_user.id, trace_id=_trace(request), ) await db.commit() return success_response(data=data, request=request) @router.post("/{exception_id}/reopen", summary="Undo a close") async def reopen( request: Request, exception_id: uuid.UUID = Path(...), body: ReopenRequest = Body(default_factory=ReopenRequest), current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): data = await service.reopen( db, exception_id, actor_user_id=current_user.id, trace_id=_trace(request), note=body.note, ) await db.commit() return success_response(data=data, request=request) @router.post("/{exception_id}/owner", summary="Assign or clear the owner") async def assign_owner( request: Request, exception_id: uuid.UUID = Path(...), body: AssignOwnerRequest = Body(...), current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): data = await service.assign_owner( db, exception_id, owner_person_id=body.owner_person_id, actor_user_id=current_user.id, trace_id=_trace(request), ) await db.commit() return success_response(data=data, request=request) @router.post( "/{exception_id}/replay", summary="Re-drive this exception's open occurrences through their own domains", ) async def replay_exception( request: Request, exception_id: uuid.UUID = Path(...), current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): """Retry the failed work itself, not just the bookkeeping. Success does not close the exception: a retry that returned 200 is not proof the underlying work succeeded. The collectors decide recovery from their own evidence, on their own tick. """ data = await service.replay_exception( db, exception_id, actor_user_id=current_user.id, trace_id=_trace(request), ) await db.commit() return success_response(data=data, request=request) @router.post( "/integrations/{integration_id}/replay", summary="Credential fixed — re-drive everything under this integration", ) async def replay_integration( request: Request, integration_id: uuid.UUID = Path(...), current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): """One click after fixing a credential. Takes an integration rather than a list of rows on purpose: the admin has already made the only decision available, and handing back the affected rows to click through would be asking them to do the work twice. """ data = await service.replay_integration( db, integration_id, actor_user_id=current_user.id, trace_id=_trace(request), ) await db.commit() return success_response(data=data, request=request)