Files
govoplan-forms-runtime/src/govoplan_forms_runtime/backend/router.py
T
2026-08-01 20:57:26 +02:00

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