546 lines
19 KiB
Python
546 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
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.core.institutional import (
|
|
EvidenceReference,
|
|
InstitutionalContextError,
|
|
InstitutionalReference,
|
|
)
|
|
from govoplan_core.db.session import get_session
|
|
from govoplan_forms_runtime.backend.manifest import (
|
|
ADMIN_SCOPE,
|
|
PARTICIPATE_SCOPE,
|
|
READ_SCOPE,
|
|
WRITE_SCOPE,
|
|
)
|
|
from govoplan_forms_runtime.backend.schemas import (
|
|
FormDraftUpdateRequest,
|
|
FormHandoffRequest,
|
|
FormHandoffActionRequest,
|
|
FormHandoffCompensateRequest,
|
|
FormInstanceCreateRequest,
|
|
FormInstanceEventsResponse,
|
|
FormInstanceHistoryResponse,
|
|
FormInstanceListResponse,
|
|
FormSubmitRequest,
|
|
FormTransitionRequest,
|
|
FormNativeHandoffRequest,
|
|
)
|
|
from govoplan_forms_runtime.backend.handoffs import FormHandoffService
|
|
from govoplan_forms_runtime.backend.service import FormRuntimeError, FormRuntimeService
|
|
|
|
|
|
def create_router(registry: object | None) -> APIRouter:
|
|
router = APIRouter(prefix="/forms-runtime", tags=["forms-runtime"])
|
|
runtime = FormRuntimeService(registry)
|
|
handoffs = FormHandoffService(registry)
|
|
|
|
@router.get("/instances", response_model=FormInstanceListResponse)
|
|
def api_list_instances(
|
|
instance_status: list[str] | None = Query(default=None, alias="status"),
|
|
definition_id: str | None = Query(default=None, max_length=255),
|
|
offset: int = Query(default=0, ge=0),
|
|
limit: int = Query(default=100, ge=1, le=200),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> FormInstanceListResponse:
|
|
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
|
|
try:
|
|
items, total = runtime.list_instances(
|
|
session,
|
|
principal,
|
|
statuses=instance_status,
|
|
definition_id=definition_id,
|
|
offset=offset,
|
|
limit=limit,
|
|
allow_all=has_scope(principal, READ_SCOPE),
|
|
)
|
|
except FormRuntimeError as exc:
|
|
raise _error(exc) from exc
|
|
return FormInstanceListResponse(
|
|
instances=[item.to_dict(include_values=False) for item in items],
|
|
total=total,
|
|
offset=offset,
|
|
limit=limit,
|
|
)
|
|
|
|
@router.post(
|
|
"/instances",
|
|
response_model=dict[str, object],
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_create_instance(
|
|
payload: FormInstanceCreateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> dict[str, object]:
|
|
_require_any(principal, PARTICIPATE_SCOPE, WRITE_SCOPE)
|
|
try:
|
|
item = runtime.create_instance(
|
|
session,
|
|
principal,
|
|
definition_ref=InstitutionalReference.from_mapping(
|
|
payload.definition_ref
|
|
),
|
|
values=payload.values,
|
|
attachment_refs=_evidence(payload.attachment_refs),
|
|
signature_refs=_evidence(payload.signature_refs),
|
|
idempotency_key=payload.idempotency_key,
|
|
recorded_at=payload.recorded_at,
|
|
instance_id=payload.instance_id,
|
|
metadata=payload.metadata,
|
|
)
|
|
session.commit()
|
|
except (FormRuntimeError, InstitutionalContextError, PermissionError) as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
return item.to_dict()
|
|
|
|
@router.get("/instances/{instance_id}", response_model=dict[str, object])
|
|
def api_get_instance(
|
|
instance_id: str,
|
|
revision: int | None = Query(default=None, ge=1),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> dict[str, object]:
|
|
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
|
|
try:
|
|
item = runtime.get_instance(
|
|
session,
|
|
principal,
|
|
instance_id=instance_id,
|
|
revision=revision,
|
|
allow_all=has_scope(principal, READ_SCOPE),
|
|
)
|
|
except PermissionError as exc:
|
|
raise _error(exc) from exc
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="Form instance not found")
|
|
return item.to_dict()
|
|
|
|
@router.get(
|
|
"/instances/{instance_id}/definition",
|
|
response_model=dict[str, object],
|
|
)
|
|
def api_get_instance_definition(
|
|
instance_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> dict[str, object]:
|
|
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
|
|
try:
|
|
item = runtime.get_instance_definition(
|
|
session,
|
|
principal,
|
|
instance_id=instance_id,
|
|
allow_all=has_scope(principal, READ_SCOPE),
|
|
)
|
|
except PermissionError as exc:
|
|
raise _error(exc) from exc
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="Form instance not found")
|
|
return item.to_dict()
|
|
|
|
@router.patch("/instances/{instance_id}", response_model=dict[str, object])
|
|
def api_update_draft(
|
|
instance_id: str,
|
|
payload: FormDraftUpdateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> dict[str, object]:
|
|
_require_any(principal, PARTICIPATE_SCOPE, WRITE_SCOPE)
|
|
try:
|
|
item = runtime.update_draft(
|
|
session,
|
|
principal,
|
|
instance_id=instance_id,
|
|
expected_revision=payload.expected_revision,
|
|
values=payload.values,
|
|
attachment_refs=_evidence(payload.attachment_refs),
|
|
signature_refs=_evidence(payload.signature_refs),
|
|
idempotency_key=payload.idempotency_key,
|
|
recorded_at=payload.recorded_at,
|
|
change_reason=payload.change_reason,
|
|
allow_all=has_scope(principal, WRITE_SCOPE),
|
|
)
|
|
session.commit()
|
|
except (
|
|
FormRuntimeError,
|
|
InstitutionalContextError,
|
|
LookupError,
|
|
PermissionError,
|
|
) as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
return item.to_dict()
|
|
|
|
@router.post(
|
|
"/instances/{instance_id}/submit",
|
|
response_model=dict[str, object],
|
|
)
|
|
def api_submit_instance(
|
|
instance_id: str,
|
|
payload: FormSubmitRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> dict[str, object]:
|
|
_require_any(principal, PARTICIPATE_SCOPE, WRITE_SCOPE)
|
|
try:
|
|
item = runtime.submit_instance(
|
|
session,
|
|
principal,
|
|
instance_id=instance_id,
|
|
expected_revision=payload.expected_revision,
|
|
values=payload.values,
|
|
attachment_refs=_evidence(payload.attachment_refs),
|
|
signature_refs=_evidence(payload.signature_refs),
|
|
idempotency_key=payload.idempotency_key,
|
|
recorded_at=payload.recorded_at,
|
|
allow_all=has_scope(principal, WRITE_SCOPE),
|
|
)
|
|
session.commit()
|
|
except (
|
|
FormRuntimeError,
|
|
InstitutionalContextError,
|
|
LookupError,
|
|
PermissionError,
|
|
) as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
return item.to_dict()
|
|
|
|
@router.post(
|
|
"/instances/{instance_id}/transition",
|
|
response_model=dict[str, object],
|
|
)
|
|
def api_transition_instance(
|
|
instance_id: str,
|
|
payload: FormTransitionRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> dict[str, object]:
|
|
_require_any(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
|
try:
|
|
item = runtime.transition_instance(
|
|
session,
|
|
principal,
|
|
instance_id=instance_id,
|
|
expected_revision=payload.expected_revision,
|
|
status=payload.status,
|
|
idempotency_key=payload.idempotency_key,
|
|
recorded_at=payload.recorded_at,
|
|
change_reason=payload.change_reason,
|
|
allow_all=True,
|
|
)
|
|
session.commit()
|
|
except (
|
|
FormRuntimeError,
|
|
InstitutionalContextError,
|
|
LookupError,
|
|
PermissionError,
|
|
) as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
return item.to_dict()
|
|
|
|
@router.post(
|
|
"/instances/{instance_id}/handoffs",
|
|
response_model=dict[str, object],
|
|
)
|
|
def api_handoff_instance(
|
|
instance_id: str,
|
|
payload: FormHandoffRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> dict[str, object]:
|
|
_require_any(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
|
try:
|
|
item = runtime.handoff_instance(
|
|
session,
|
|
principal,
|
|
instance_id=instance_id,
|
|
expected_revision=payload.expected_revision,
|
|
target_ref=InstitutionalReference.from_mapping(payload.target_ref),
|
|
idempotency_key=payload.idempotency_key,
|
|
recorded_at=payload.recorded_at,
|
|
change_reason=payload.change_reason,
|
|
allow_all=True,
|
|
)
|
|
session.commit()
|
|
except (
|
|
FormRuntimeError,
|
|
InstitutionalContextError,
|
|
LookupError,
|
|
PermissionError,
|
|
) as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
return item.to_dict()
|
|
|
|
@router.get(
|
|
"/instances/{instance_id}/handoffs",
|
|
response_model=dict[str, object],
|
|
)
|
|
def api_list_handoffs(
|
|
instance_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> dict[str, object]:
|
|
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
|
|
try:
|
|
items = handoffs.list(
|
|
session,
|
|
principal,
|
|
instance_id=instance_id,
|
|
allow_all=has_scope(principal, READ_SCOPE),
|
|
)
|
|
except (FormRuntimeError, LookupError, PermissionError) as exc:
|
|
raise _error(exc) from exc
|
|
return {"handoffs": [item.to_dict() for item in items]}
|
|
|
|
@router.post(
|
|
"/instances/{instance_id}/handoffs/native",
|
|
response_model=dict[str, object],
|
|
)
|
|
def api_native_handoff(
|
|
instance_id: str,
|
|
payload: FormNativeHandoffRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> dict[str, object]:
|
|
_require_any(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
|
try:
|
|
prepared = handoffs.prepare(
|
|
session,
|
|
principal,
|
|
instance_id=instance_id,
|
|
expected_revision=payload.expected_revision,
|
|
binding_kind=payload.binding_kind,
|
|
binding_reference=payload.binding_reference,
|
|
idempotency_key=payload.idempotency_key,
|
|
requested_at=payload.requested_at,
|
|
allow_all=True,
|
|
)
|
|
# The intent is durable before owner capability execution starts.
|
|
session.commit()
|
|
item, instance = handoffs.execute(
|
|
session,
|
|
principal,
|
|
effect_id=prepared.effect_id,
|
|
executed_at=payload.requested_at,
|
|
allow_all=True,
|
|
)
|
|
session.commit()
|
|
except (
|
|
FormRuntimeError,
|
|
InstitutionalContextError,
|
|
LookupError,
|
|
PermissionError,
|
|
) as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
return {
|
|
"handoff": item.to_dict(),
|
|
"instance": instance.to_dict() if instance is not None else None,
|
|
}
|
|
|
|
@router.post(
|
|
"/instances/{instance_id}/handoffs/{effect_id}/retry",
|
|
response_model=dict[str, object],
|
|
)
|
|
def api_retry_handoff(
|
|
instance_id: str,
|
|
effect_id: str,
|
|
payload: FormHandoffActionRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> dict[str, object]:
|
|
_require_any(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
|
try:
|
|
prepared = handoffs.retry_rejected(
|
|
session,
|
|
principal,
|
|
effect_id=effect_id,
|
|
requested_at=payload.recorded_at,
|
|
allow_all=True,
|
|
)
|
|
if prepared.instance_id != instance_id:
|
|
raise LookupError("Form handoff does not belong to this instance.")
|
|
session.commit()
|
|
item, instance = handoffs.execute(
|
|
session,
|
|
principal,
|
|
effect_id=effect_id,
|
|
executed_at=payload.recorded_at,
|
|
allow_all=True,
|
|
)
|
|
session.commit()
|
|
except (
|
|
FormRuntimeError,
|
|
InstitutionalContextError,
|
|
LookupError,
|
|
PermissionError,
|
|
) as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
return {
|
|
"handoff": item.to_dict(),
|
|
"instance": instance.to_dict() if instance is not None else None,
|
|
}
|
|
|
|
@router.post(
|
|
"/instances/{instance_id}/handoffs/{effect_id}/reconcile",
|
|
response_model=dict[str, object],
|
|
)
|
|
def api_reconcile_handoff(
|
|
instance_id: str,
|
|
effect_id: str,
|
|
payload: FormHandoffActionRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> dict[str, object]:
|
|
_require_any(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
|
try:
|
|
item, instance = handoffs.execute(
|
|
session,
|
|
principal,
|
|
effect_id=effect_id,
|
|
executed_at=payload.recorded_at,
|
|
allow_all=True,
|
|
reconcile=True,
|
|
)
|
|
if item.instance_id != instance_id:
|
|
raise LookupError("Form handoff does not belong to this instance.")
|
|
session.commit()
|
|
except (
|
|
FormRuntimeError,
|
|
InstitutionalContextError,
|
|
LookupError,
|
|
PermissionError,
|
|
) as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
return {
|
|
"handoff": item.to_dict(),
|
|
"instance": instance.to_dict() if instance is not None else None,
|
|
}
|
|
|
|
@router.post(
|
|
"/instances/{instance_id}/handoffs/{effect_id}/compensate",
|
|
response_model=dict[str, object],
|
|
)
|
|
def api_compensate_handoff(
|
|
instance_id: str,
|
|
effect_id: str,
|
|
payload: FormHandoffCompensateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> dict[str, object]:
|
|
_require_any(principal, ADMIN_SCOPE)
|
|
try:
|
|
item = handoffs.compensate(
|
|
session,
|
|
principal,
|
|
effect_id=effect_id,
|
|
compensated_at=payload.recorded_at,
|
|
confirmed_absent=payload.confirmed_absent,
|
|
change_reason=payload.change_reason,
|
|
allow_all=True,
|
|
)
|
|
if item.instance_id != instance_id:
|
|
raise LookupError("Form handoff does not belong to this instance.")
|
|
session.commit()
|
|
except (
|
|
FormRuntimeError,
|
|
InstitutionalContextError,
|
|
LookupError,
|
|
PermissionError,
|
|
) as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
return {"handoff": item.to_dict()}
|
|
|
|
@router.get(
|
|
"/instances/{instance_id}/history",
|
|
response_model=FormInstanceHistoryResponse,
|
|
)
|
|
def api_instance_history(
|
|
instance_id: str,
|
|
limit: int = Query(default=100, ge=1, le=200),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> FormInstanceHistoryResponse:
|
|
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
|
|
try:
|
|
items = runtime.history(
|
|
session,
|
|
principal,
|
|
instance_id=instance_id,
|
|
limit=limit,
|
|
allow_all=has_scope(principal, READ_SCOPE),
|
|
)
|
|
except PermissionError as exc:
|
|
raise _error(exc) from exc
|
|
if not items:
|
|
raise HTTPException(status_code=404, detail="Form instance not found")
|
|
return FormInstanceHistoryResponse(revisions=[item.to_dict() for item in items])
|
|
|
|
@router.get(
|
|
"/instances/{instance_id}/events",
|
|
response_model=FormInstanceEventsResponse,
|
|
)
|
|
def api_instance_events(
|
|
instance_id: str,
|
|
limit: int = Query(default=200, ge=1, le=500),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> FormInstanceEventsResponse:
|
|
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
|
|
try:
|
|
items = runtime.events(
|
|
session,
|
|
principal,
|
|
instance_id=instance_id,
|
|
limit=limit,
|
|
allow_all=has_scope(principal, READ_SCOPE),
|
|
)
|
|
except PermissionError as exc:
|
|
raise _error(exc) from exc
|
|
return FormInstanceEventsResponse(events=[dict(item) for item in items])
|
|
|
|
return router
|
|
|
|
|
|
def _evidence(values: list[dict[str, object]]) -> tuple[EvidenceReference, ...]:
|
|
return tuple(EvidenceReference.from_mapping(item) for item in values)
|
|
|
|
|
|
def _require_any(principal: ApiPrincipal, *scopes: str) -> None:
|
|
if not any(has_scope(principal, scope) for scope in scopes):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"Missing one of the scopes: {', '.join(scopes)}",
|
|
)
|
|
|
|
|
|
def _error(exc: Exception) -> HTTPException:
|
|
message = str(exc)
|
|
lowered = message.casefold()
|
|
if isinstance(exc, LookupError):
|
|
code = 404
|
|
elif isinstance(exc, PermissionError):
|
|
code = 403
|
|
elif any(word in lowered for word in ("conflict", "stale", "already")):
|
|
code = 409
|
|
elif "validation" in lowered:
|
|
code = 422
|
|
else:
|
|
code = 400
|
|
return HTTPException(status_code=code, detail=message)
|
|
|
|
|
|
__all__ = ["create_router"]
|