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