Files
govoplan-approvals/src/govoplan_approvals/backend/router.py
T

380 lines
12 KiB
Python

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"]