"""API routes for approval requests — /api/v1/approvals. Endpoints: create, list (filterable), get detail, approve, reject, expire. """ from __future__ import annotations import logging import uuid from datetime import UTC, datetime from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.approval import ( APPROVAL_STATUSES, ApprovalRequest, create_approval_request, expire_approval_request, resolve_approval_request, ) from app.core.db import get_db from app.deps import get_current_user, require_permission logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/approvals", tags=["approvals"]) # ─── Schemas ─── class ApprovalCreate(BaseModel): """Create an approval request.""" entity_type: str = Field(..., min_length=1, max_length=80) entity_id: str = Field(..., min_length=1) action: str = Field(..., min_length=1, max_length=120) requested_by: str | None = None requested_by_type: str = Field("agent", pattern="^(user|agent|system)$") approver_id: str | None = None approver_group: str | None = Field(None, max_length=120) expires_at: str | None = None metadata: dict[str, Any] = Field(default_factory=dict) class ApprovalResolve(BaseModel): """Approve or reject an approval request.""" comment: str | None = Field(None, max_length=2000) class ApprovalResponse(BaseModel): """Approval request response.""" id: str tenant_id: str entity_type: str entity_id: str action: str requested_by: str requested_by_type: str approver_id: str | None = None approver_group: str | None = None status: str comment: str | None = None created_at: str | None = None resolved_at: str | None = None expires_at: str | None = None metadata: dict[str, Any] = Field(default_factory=dict) class ApprovalListResponse(BaseModel): """Paginated approval request list.""" items: list[ApprovalResponse] total: int # ─── Helpers ─── def _to_response(r: ApprovalRequest) -> ApprovalResponse: return ApprovalResponse( id=str(r.id), tenant_id=str(r.tenant_id), entity_type=r.entity_type, entity_id=str(r.entity_id), action=r.action, requested_by=str(r.requested_by), requested_by_type=r.requested_by_type, approver_id=str(r.approver_id) if r.approver_id else None, approver_group=r.approver_group, status=r.status, comment=r.comment, created_at=r.created_at.isoformat() if r.created_at else None, resolved_at=r.resolved_at.isoformat() if r.resolved_at else None, expires_at=r.expires_at.isoformat() if r.expires_at else None, metadata=r.request_metadata or {}, ) def _parse_uuid(value: str, field: str) -> uuid.UUID: try: return uuid.UUID(value) except (ValueError, TypeError): raise HTTPException(status_code=400, detail=f"Invalid {field}") from None # ─── Endpoints ─── @router.post( "", dependencies=[Depends(require_permission("approvals:write"))], response_model=ApprovalResponse, status_code=201, ) async def create_approval( data: ApprovalCreate, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Create a new approval request.""" tenant_id = uuid.UUID(current_user["tenant_id"]) entity_id = _parse_uuid(data.entity_id, "entity_id") requested_by = ( _parse_uuid(data.requested_by, "requested_by") if data.requested_by else uuid.UUID(current_user["user_id"]) ) approver_id = _parse_uuid(data.approver_id, "approver_id") if data.approver_id else None expires_at = None if data.expires_at: try: expires_at = datetime.fromisoformat(data.expires_at.replace("Z", "+00:00")) except ValueError: raise HTTPException(status_code=400, detail="Invalid expires_at") from None req = await create_approval_request( db, tenant_id, entity_type=data.entity_type, entity_id=entity_id, action=data.action, requested_by=requested_by, requested_by_type=data.requested_by_type, approver_id=approver_id, approver_group=data.approver_group, expires_at=expires_at, metadata=data.metadata, ) await db.commit() return _to_response(req) @router.get( "", dependencies=[Depends(require_permission("approvals:read"))], response_model=ApprovalListResponse, ) async def list_approvals( status: str | None = Query(None), entity_type: str | None = Query(None), entity_id: str | None = Query(None), requested_by: str | None = Query(None), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List approval requests with optional filters.""" tenant_id = uuid.UUID(current_user["tenant_id"]) query = select(ApprovalRequest).where(ApprovalRequest.tenant_id == tenant_id) count_query = ( select(__import__("sqlalchemy").func.count()) .select_from(ApprovalRequest) .where(ApprovalRequest.tenant_id == tenant_id) ) if status: query = query.where(ApprovalRequest.status == status) count_query = count_query.where(ApprovalRequest.status == status) if entity_type: query = query.where(ApprovalRequest.entity_type == entity_type) count_query = count_query.where(ApprovalRequest.entity_type == entity_type) if entity_id: eid = _parse_uuid(entity_id, "entity_id") query = query.where(ApprovalRequest.entity_id == eid) count_query = count_query.where(ApprovalRequest.entity_id == eid) if requested_by: rid = _parse_uuid(requested_by, "requested_by") query = query.where(ApprovalRequest.requested_by == rid) count_query = count_query.where(ApprovalRequest.requested_by == rid) total = (await db.execute(count_query)).scalar() or 0 result = await db.execute( query.order_by(ApprovalRequest.created_at.desc()).limit(limit).offset(offset) ) items = list(result.scalars().all()) return ApprovalListResponse(items=[_to_response(r) for r in items], total=total) @router.get( "/{request_id}", dependencies=[Depends(require_permission("approvals:read"))], response_model=ApprovalResponse, ) async def get_approval( request_id: str, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Get a single approval request by ID.""" tenant_id = uuid.UUID(current_user["tenant_id"]) rid = _parse_uuid(request_id, "request_id") result = await db.execute( select(ApprovalRequest).where( ApprovalRequest.id == rid, ApprovalRequest.tenant_id == tenant_id, ) ) req = result.scalar_one_or_none() if req is None: raise HTTPException(status_code=404, detail="Approval request not found") return _to_response(req) @router.post( "/{request_id}/approve", dependencies=[Depends(require_permission("approvals:approve"))], response_model=ApprovalResponse, ) async def approve_approval( request_id: str, body: ApprovalResolve, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Approve a pending approval request.""" tenant_id = uuid.UUID(current_user["tenant_id"]) rid = _parse_uuid(request_id, "request_id") req = await resolve_approval_request( db, tenant_id, rid, decision="approved", approver_id=uuid.UUID(current_user["user_id"]), comment=body.comment, ) if req is None: raise HTTPException(status_code=404, detail="Approval request not found or not pending") await db.commit() return _to_response(req) @router.post( "/{request_id}/reject", dependencies=[Depends(require_permission("approvals:approve"))], response_model=ApprovalResponse, ) async def reject_approval( request_id: str, body: ApprovalResolve, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Reject a pending approval request.""" tenant_id = uuid.UUID(current_user["tenant_id"]) rid = _parse_uuid(request_id, "request_id") req = await resolve_approval_request( db, tenant_id, rid, decision="rejected", approver_id=uuid.UUID(current_user["user_id"]), comment=body.comment, ) if req is None: raise HTTPException(status_code=404, detail="Approval request not found or not pending") await db.commit() return _to_response(req) @router.post( "/{request_id}/expire", dependencies=[Depends(require_permission("approvals:write"))], response_model=ApprovalResponse, ) async def expire_approval( request_id: str, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Mark a pending approval request as expired (system only).""" tenant_id = uuid.UUID(current_user["tenant_id"]) rid = _parse_uuid(request_id, "request_id") req = await expire_approval_request(db, tenant_id, rid) if req is None: raise HTTPException(status_code=404, detail="Approval request not found or not pending") await db.commit() return _to_response(req)