feat: implement definition-aware forms runtime
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
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,
|
||||
FormInstanceCreateRequest,
|
||||
FormInstanceEventsResponse,
|
||||
FormInstanceHistoryResponse,
|
||||
FormInstanceListResponse,
|
||||
FormSubmitRequest,
|
||||
FormTransitionRequest,
|
||||
)
|
||||
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)
|
||||
|
||||
@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}/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"]
|
||||
Reference in New Issue
Block a user