Implement generic approval runtime
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
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("/{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"]
|
||||
Reference in New Issue
Block a user