380 lines
12 KiB
Python
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"]
|