from __future__ import annotations from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.db.session import get_session from govoplan_approvals.backend.schemas import ( ApprovalCreateRequest, ApprovalDecisionInput, ApprovalListResponse, ApprovalTemplateCreateRequest, ApprovalTemplateReviseRequest, ApprovalTransitionInput, ) from govoplan_approvals.backend.service import ApprovalStoreError, SqlApprovalRequests router = APIRouter(prefix="/approvals", tags=["approvals"]) def _require(principal: ApiPrincipal, scope: str) -> None: if not has_scope(principal, scope): raise HTTPException(status_code=403, detail=f"Missing scope: {scope}") def _error(exc: Exception) -> HTTPException: if isinstance(exc, LookupError): return HTTPException(status_code=404, detail=str(exc)) text = str(exc) return HTTPException( status_code=409 if "conflict" in text.lower() or "idempotency" in text.lower() else 400, detail=text, ) @router.get("", response_model=ApprovalListResponse) def api_list_requests( request_state: str | None = Query(default=None, alias="state"), subject_module: str | None = None, subject_id: str | None = None, limit: int = Query(default=100, ge=1, le=200), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> ApprovalListResponse: from govoplan_approvals.backend.manifest import READ_SCOPE _require(principal, READ_SCOPE) return ApprovalListResponse( requests=list( SqlApprovalRequests().list_requests( session, principal, state=request_state, subject_module=subject_module, subject_id=subject_id, limit=limit, ) ) ) @router.get("/templates", response_model=list[dict[str, Any]]) def api_list_templates( template_state: str | None = Query(default=None, alias="state"), limit: int = Query(default=100, ge=1, le=200), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> list[dict[str, Any]]: from govoplan_approvals.backend.manifest import READ_SCOPE _require(principal, READ_SCOPE) return [ dict(item) for item in SqlApprovalRequests().list_templates( session, principal, state=template_state, limit=limit ) ] @router.post( "/templates", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED ) def api_create_template( payload: ApprovalTemplateCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: from govoplan_approvals.backend.manifest import ADMIN_SCOPE _require(principal, ADMIN_SCOPE) try: result = SqlApprovalRequests().create_template( session, principal, command=payload.template.to_command(), idempotency_key=payload.idempotency_key, ) session.commit() return dict( SqlApprovalRequests().get_template( session, principal, template_id=result.id ) or {} ) except (ApprovalStoreError, LookupError) as exc: session.rollback() raise _error(exc) from exc @router.put("/templates/{template_id}", response_model=dict[str, Any]) def api_revise_template( template_id: str, payload: ApprovalTemplateReviseRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: from govoplan_approvals.backend.manifest import ADMIN_SCOPE _require(principal, ADMIN_SCOPE) try: result = SqlApprovalRequests().revise_template( session, principal, template_id=template_id, command=payload.template.to_command(), expected_revision=payload.expected_revision, idempotency_key=payload.idempotency_key, ) session.commit() return dict( SqlApprovalRequests().get_template( session, principal, template_id=result.id ) or {} ) except (ApprovalStoreError, LookupError) as exc: session.rollback() raise _error(exc) from exc @router.post("/templates/{template_id}/publish", response_model=dict[str, Any]) def api_publish_template( template_id: str, payload: ApprovalTransitionInput, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: from govoplan_approvals.backend.manifest import ADMIN_SCOPE _require(principal, ADMIN_SCOPE) try: result = SqlApprovalRequests().publish_template( session, principal, template_id=template_id, expected_revision=payload.expected_revision, idempotency_key=payload.idempotency_key, ) session.commit() return dict( SqlApprovalRequests().get_template( session, principal, template_id=result.id ) or {} ) except (ApprovalStoreError, LookupError) as exc: session.rollback() raise _error(exc) from exc @router.get("/templates/{template_id}", response_model=dict[str, Any]) def api_get_template( template_id: str, revision: int | None = Query(default=None, ge=1), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: from govoplan_approvals.backend.manifest import READ_SCOPE _require(principal, READ_SCOPE) item = SqlApprovalRequests().get_template( session, principal, template_id=template_id, revision=revision, ) if item is None: raise HTTPException(status_code=404, detail="Approval template not found") return dict(item) @router.get( "/templates/{template_id}/history", response_model=list[dict[str, Any]], ) def api_get_template_history( template_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> list[dict[str, Any]]: from govoplan_approvals.backend.manifest import READ_SCOPE _require(principal, READ_SCOPE) try: return [ dict(item) for item in SqlApprovalRequests().template_history( session, principal, template_id=template_id, ) ] except (ApprovalStoreError, LookupError) as exc: raise _error(exc) from exc @router.get( "/templates/{template_id}/compare", response_model=dict[str, Any], ) def api_compare_template_revisions( template_id: str, from_revision: int = Query(ge=1), to_revision: int = Query(ge=1), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: from govoplan_approvals.backend.manifest import READ_SCOPE _require(principal, READ_SCOPE) try: return dict( SqlApprovalRequests().compare_template_revisions( session, principal, template_id=template_id, from_revision=from_revision, to_revision=to_revision, ) ) except (ApprovalStoreError, LookupError) as exc: raise _error(exc) from exc @router.get("/{request_id}", response_model=dict[str, Any]) def api_get_request( request_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: from govoplan_approvals.backend.manifest import READ_SCOPE _require(principal, READ_SCOPE) item = SqlApprovalRequests().get_request(session, principal, request_id=request_id) if item is None: raise HTTPException(status_code=404, detail="Approval request not found") return dict(item) @router.get("/{request_id}/history", response_model=list[dict[str, Any]]) def api_get_history( request_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> list[dict[str, Any]]: from govoplan_approvals.backend.manifest import READ_SCOPE _require(principal, READ_SCOPE) if ( SqlApprovalRequests().get_request(session, principal, request_id=request_id) is None ): raise HTTPException(status_code=404, detail="Approval request not found") return [ dict(item) for item in SqlApprovalRequests().history( session, principal, request_id=request_id ) ] @router.post("", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED) def api_create_request( payload: ApprovalCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: from govoplan_approvals.backend.manifest import WRITE_SCOPE _require(principal, WRITE_SCOPE) try: result = SqlApprovalRequests().create_request( session, principal, command=payload.request.to_command(), idempotency_key=payload.idempotency_key, ) session.commit() return dict( SqlApprovalRequests().get_request(session, principal, request_id=result.id) or {} ) except (ApprovalStoreError, LookupError) as exc: session.rollback() raise _error(exc) from exc @router.post("/{request_id}/decisions", response_model=dict[str, Any]) def api_decide( request_id: str, payload: ApprovalDecisionInput, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: from govoplan_approvals.backend.manifest import DECIDE_SCOPE _require(principal, DECIDE_SCOPE) try: receipt = SqlApprovalRequests().decide( session, principal, request_id=request_id, command=payload.to_command() ) session.commit() return { "receipt": { "request_id": receipt.request_id, "revision": receipt.revision, "step_key": receipt.step_key, "outcome": receipt.outcome, "actor_id": receipt.actor_id, "recorded_at": receipt.recorded_at, "receipt_sha256": receipt.receipt_sha256, "replayed": receipt.replayed, }, "request": dict( SqlApprovalRequests().get_request( session, principal, request_id=request_id ) or {} ), } except (ApprovalStoreError, LookupError) as exc: session.rollback() raise _error(exc) from exc @router.post("/{request_id}/escalate", response_model=dict[str, Any]) def api_escalate( request_id: str, payload: ApprovalTransitionInput, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: from govoplan_approvals.backend.manifest import ADMIN_SCOPE _require(principal, ADMIN_SCOPE) try: SqlApprovalRequests().escalate_due( session, principal, request_id=request_id, expected_revision=payload.expected_revision, idempotency_key=payload.idempotency_key, ) session.commit() return dict( SqlApprovalRequests().get_request(session, principal, request_id=request_id) or {} ) except (ApprovalStoreError, LookupError) as exc: session.rollback() raise _error(exc) from exc __all__ = ["router"]