Add governed form handoffs

This commit is contained in:
2026-08-01 20:57:26 +02:00
parent 9dc49fe27b
commit b4388b1e1e
15 changed files with 2236 additions and 89 deletions
+213 -7
View File
@@ -19,19 +19,24 @@ from govoplan_forms_runtime.backend.manifest import (
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(
@@ -162,7 +167,12 @@ def create_router(registry: object | None) -> APIRouter:
allow_all=has_scope(principal, WRITE_SCOPE),
)
session.commit()
except (FormRuntimeError, InstitutionalContextError, LookupError, PermissionError) as exc:
except (
FormRuntimeError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@@ -192,7 +202,12 @@ def create_router(registry: object | None) -> APIRouter:
allow_all=has_scope(principal, WRITE_SCOPE),
)
session.commit()
except (FormRuntimeError, InstitutionalContextError, LookupError, PermissionError) as exc:
except (
FormRuntimeError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@@ -221,7 +236,12 @@ def create_router(registry: object | None) -> APIRouter:
allow_all=True,
)
session.commit()
except (FormRuntimeError, InstitutionalContextError, LookupError, PermissionError) as exc:
except (
FormRuntimeError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@@ -250,11 +270,199 @@ def create_router(registry: object | None) -> APIRouter:
allow_all=True,
)
session.commit()
except (FormRuntimeError, InstitutionalContextError, LookupError, PermissionError) as exc:
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,
@@ -278,9 +486,7 @@ def create_router(registry: object | None) -> APIRouter:
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]
)
return FormInstanceHistoryResponse(revisions=[item.to_dict() for item in items])
@router.get(
"/instances/{instance_id}/events",