from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from govoplan_core.api.v1.schemas import ( ReferenceOptionListResponse, ReferenceOptionResponse, ) from govoplan_core.audit.logging import audit_event from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.core.references import ( access_scope_reference_page, access_scope_reference_provider_available, validate_access_scope_reference, ) from govoplan_core.db.session import get_session from govoplan_workflow_engine.backend.governance import ( definition_decision, normalize_definition_scope, require_definition_action, ) from govoplan_workflow_engine.backend.contributions import ( compare_workflow_override_to_standard, reconcile_workflow_definition_contributions, reset_workflow_override_to_standard, ) from govoplan_workflow_engine.backend.manifest import ( ADMIN_SCOPE, DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE, INSTANCE_READ_SCOPE, INSTANCE_START_SCOPE, INSTANCE_TRANSITION_SCOPE, ) from govoplan_workflow_engine.backend.node_library import ( BPMN_NODE_TYPES, WORKFLOW_GRAPH_LIBRARY, ) from govoplan_workflow_engine.backend.bpmn import ( BPMN_MODEL_NAMESPACE, BpmnDiagnostic, BpmnInspectionError, NATIVE_EXECUTION_ELEMENTS, NATIVE_MAPPING_ELEMENTS, inspect_bpmn_xml, ) from govoplan_workflow_engine.backend.bpmn_adapters import ( BpmnAdapterError, bpmn_adapter_registry, compile_bpmn_to_graph, ) from govoplan_workflow_engine.backend.bpmn_graph import ( BpmnGraphError, NATIVE_BPMN_ADAPTER_ID, NATIVE_BPMN_ADAPTER_VERSION, canonical_bpmn_graph, export_bpmn_graph, ) from govoplan_workflow_engine.backend.schemas import ( BpmnAdapterProfileResponse, BpmnCompileRequest, BpmnCompileResponse, BpmnDiagnosticResponse, BpmnElementSupportResponse, BpmnInspectionRequest, BpmnInspectionResponse, BpmnRevisionDocumentResponse, BpmnRenderRequest, BpmnRenderResponse, BpmnSupportProfileResponse, WorkflowConfigFieldResponse, WorkflowDefinitionActivateRequest, WorkflowDefinitionCreateRequest, WorkflowDefinitionDeleteResponse, WorkflowDefinitionDeriveRequest, WorkflowDefinitionListResponse, WorkflowDefinitionResponse, WorkflowDefinitionRevisionListResponse, WorkflowDefinitionRevisionResponse, WorkflowDefinitionUpdateRequest, WorkflowDiagnosticResponse, WorkflowGraphValidationRequest, WorkflowGraphValidationResponse, WorkflowInstanceListResponse, WorkflowInstanceResponse, WorkflowInstanceStartRequest, WorkflowNodeLibraryResponse, WorkflowNodeTypeResponse, WorkflowPortResponse, WorkflowStandardDiffResponse, WorkflowStepActionRequest, ) from govoplan_workflow_engine.backend.instance_service import ( cancel_instance, get_instance, instance_response, list_instances, reconcile_instance, resolve_step, start_instance, ) from govoplan_workflow_engine.backend.runtime import get_registry from govoplan_workflow_engine.backend.service import ( WorkflowBpmnValidationError, WorkflowConflictError, WorkflowError, WorkflowNotFoundError, WorkflowValidationError, activate_definition, archive_definition, create_definition, definition_response, delete_definition, derive_definition, get_definition, get_definition_revision, list_definition_revisions, list_definitions, revision_response, revision_bpmn_summary, update_definition, ) from govoplan_workflow_engine.backend.validation import validate_workflow_graph router = APIRouter(prefix="/workflow", tags=["workflow"]) def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None: if any(has_scope(principal, scope) for scope in scopes): return raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing one of the required scopes: {', '.join(scopes)}", ) def _http_error(exc: WorkflowError) -> HTTPException: if isinstance(exc, WorkflowNotFoundError): return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) if isinstance(exc, WorkflowConflictError): return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) if isinstance(exc, WorkflowBpmnValidationError): return HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail={ "message": str(exc), "diagnostics": [ { "severity": item.severity, "code": item.code, "message": item.message, "element_id": item.element_id, } for item in exc.diagnostics ], }, ) if isinstance(exc, WorkflowValidationError): return HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail={ "message": str(exc), "diagnostics": [ { "severity": item.severity, "code": item.code, "message": item.message, "node_id": item.node_id, "field": item.field, } for item in exc.diagnostics ], }, ) return HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) def _governance_http_error( exc: PermissionError | ValueError, ) -> HTTPException: return HTTPException( status_code=( status.HTTP_403_FORBIDDEN if isinstance(exc, PermissionError) else status.HTTP_422_UNPROCESSABLE_CONTENT ), detail=str(exc), ) def _definition_response( session: Session, definition, principal: ApiPrincipal, *, revision: int | None = None, ) -> WorkflowDefinitionResponse: return definition_response( session, definition, principal=principal, registry=get_registry(), revision=revision, ) def _actor_id(principal: ApiPrincipal) -> str | None: return principal.account_id or principal.membership_id or principal.identity_id def _audit( session: Session, principal: ApiPrincipal, *, action: str, definition_id: str, details: dict[str, object], ) -> None: audit_event( session, tenant_id=principal.tenant_id, user_id=getattr(principal.user, "id", None), api_key_id=principal.api_key_id, action=action, object_type="workflow_definition", object_id=definition_id, details=details, ) def _audit_instance( session: Session, principal: ApiPrincipal, *, action: str, instance_id: str, details: dict[str, object], ) -> None: audit_event( session, tenant_id=principal.tenant_id, user_id=getattr(principal.user, "id", None), api_key_id=principal.api_key_id, action=action, object_type="workflow_instance", object_id=instance_id, details=details, ) def _require_instance_view(instance, principal: ApiPrincipal) -> None: require_definition_action( instance.definition, principal=principal, registry=get_registry(), action="view", ) def _adapter_profile_response(profile) -> BpmnAdapterProfileResponse: return BpmnAdapterProfileResponse( id=profile.id, version=profile.version, label=profile.label, description=profile.description, conformance=profile.conformance, runtime_kind=profile.runtime_kind, executable=profile.executable, supported_elements=list(profile.supported_elements), supported_event_definitions=list(profile.supported_event_definitions), requirements=list(profile.requirements), ) def _bpmn_inspection_response( xml: str, *, adapter_id: str, adapter_version: str | None = None, activation: bool, ) -> BpmnInspectionResponse: result = inspect_bpmn_xml(xml) adapter = bpmn_adapter_registry().resolve(adapter_id, adapter_version) adapter_diagnostics = ( adapter.diagnostics(xml, result, activation=activation) if adapter is not None else () ) diagnostics = list(result.diagnostics) diagnostics.extend(adapter_diagnostics) if adapter is None: suffix = f"@{adapter_version}" if adapter_version else "" diagnostics.append( BpmnDiagnostic( severity="error", code="adapter.unavailable", message=f"BPMN execution adapter {adapter_id}{suffix} is not installed.", ) ) deduplicated = { (item.code, item.element_id, item.message): item for item in diagnostics }.values() diagnostic_items = list(deduplicated) return BpmnInspectionResponse( valid_xml=result.valid_xml, definitions_id=result.definitions_id, target_namespace=result.target_namespace, process_count=result.process_count, executable_process_count=result.executable_process_count, collaboration_count=result.collaboration_count, choreography_count=result.choreography_count, element_counts=result.element_counts, support_counts=result.support_counts, elements=[ BpmnElementSupportResponse( element_type=item.element_type, element_id=item.element_id, name=item.name, parent_type=item.parent_type, parent_id=item.parent_id, support_level=item.support_level, ) for item in result.elements ], diagnostics=[ BpmnDiagnosticResponse( severity=item.severity, code=item.code, message=item.message, element_id=item.element_id, ) for item in diagnostic_items ], adapter_id=adapter.profile.id if adapter else adapter_id, adapter_version=( adapter.profile.version if adapter else adapter_version ), runtime_kind=adapter.profile.runtime_kind if adapter else None, executable=bool(adapter and adapter.profile.executable), activatable=bool( activation and adapter and adapter.profile.executable and not any(item.severity == "error" for item in diagnostic_items) ), ) @router.get("/bpmn/profile", response_model=BpmnSupportProfileResponse) def api_bpmn_support_profile( principal: ApiPrincipal = Depends(get_api_principal), ) -> BpmnSupportProfileResponse: _require_any_scope( principal, DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE, ) return BpmnSupportProfileResponse( specification="BPMN 2.0.2", model_namespace=BPMN_MODEL_NAMESPACE, interchange=( "BPMN 2.0 is the native Workflow graph language. XML import and " "export preserve standard notation and diagram geometry without " "a browser-side BPMN modeler." ), native_runtime=( "Activation is fail-closed and requires a pinned adapter whose " "declared conformance profile covers the complete document." ), native_execution_elements=sorted(NATIVE_EXECUTION_ELEMENTS), native_mapping_elements=sorted( NATIVE_MAPPING_ELEMENTS - NATIVE_EXECUTION_ELEMENTS ), adapters=[ _adapter_profile_response(profile) for profile in bpmn_adapter_registry().profiles() ], ) @router.post("/bpmn/inspect", response_model=BpmnInspectionResponse) def api_inspect_bpmn( payload: BpmnInspectionRequest, principal: ApiPrincipal = Depends(get_api_principal), ) -> BpmnInspectionResponse: _require_any_scope( principal, DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE, ) try: return _bpmn_inspection_response( payload.xml, adapter_id=payload.adapter_id, adapter_version=payload.adapter_version, activation=payload.activation, ) except (BpmnAdapterError, BpmnInspectionError) as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc @router.post("/bpmn/compile", response_model=BpmnCompileResponse) def api_compile_bpmn( payload: BpmnCompileRequest, principal: ApiPrincipal = Depends(get_api_principal), ) -> BpmnCompileResponse: _require_any_scope( principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE, ) try: adapter, _inspection, graph = compile_bpmn_to_graph( payload.xml, adapter_id=payload.adapter_id, adapter_version=payload.adapter_version, ) if graph is None: raise BpmnAdapterError( ( BpmnDiagnostic( severity="error", code="adapter.no_runtime_materialization", message="The selected adapter does not compile to a native graph.", ), ) ) inspection_response = _bpmn_inspection_response( payload.xml, adapter_id=adapter.profile.id, adapter_version=adapter.profile.version, activation=False, ) except (BpmnAdapterError, BpmnInspectionError) as exc: diagnostics = getattr(exc, "diagnostics", ()) raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail={ "message": str(exc), "diagnostics": [ { "severity": item.severity, "code": item.code, "message": item.message, "element_id": item.element_id, } for item in diagnostics ], }, ) from exc return BpmnCompileResponse( adapter=_adapter_profile_response(adapter.profile), graph=graph, inspection=inspection_response, ) @router.post("/bpmn/render", response_model=BpmnRenderResponse) def api_render_bpmn( payload: BpmnRenderRequest, principal: ApiPrincipal = Depends(get_api_principal), ) -> BpmnRenderResponse: _require_any_scope( principal, DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE, ) try: xml = export_bpmn_graph( canonical_bpmn_graph(payload.graph), name=payload.name, ) inspection = _bpmn_inspection_response( xml, adapter_id=NATIVE_BPMN_ADAPTER_ID, adapter_version=NATIVE_BPMN_ADAPTER_VERSION, activation=False, ) except (BpmnGraphError, BpmnInspectionError) as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc return BpmnRenderResponse(xml=xml, inspection=inspection) @router.get("/node-types", response_model=WorkflowNodeLibraryResponse) def api_node_types( principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowNodeLibraryResponse: _require_any_scope( principal, DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE, ) return WorkflowNodeLibraryResponse( id=WORKFLOW_GRAPH_LIBRARY.id, version=WORKFLOW_GRAPH_LIBRARY.version, allows_cycles=WORKFLOW_GRAPH_LIBRARY.constraints.allow_cycles, nodes=[ WorkflowNodeTypeResponse( type=definition.type, category=definition.category, category_label=WORKFLOW_GRAPH_LIBRARY.category_labels[definition.category], label=definition.label, description=definition.description, icon=definition.icon, input_ports=[ WorkflowPortResponse( id=port.id, label=port.label, required=port.required, multiple=port.multiple, minimum_connections=port.minimum_connections, ) for port in definition.input_ports ], output_ports=[ WorkflowPortResponse( id=port.id, label=port.label, required=port.required, multiple=port.multiple, minimum_connections=port.minimum_connections, ) for port in definition.output_ports ], config_fields=[ WorkflowConfigFieldResponse( id=field.id, label=field.label, kind=field.kind, required=field.required, description=field.description, options=list(field.options), ) for field in definition.config_fields ], default_config=dict(definition.default_config), metadata=dict(definition.metadata), ) for definition in BPMN_NODE_TYPES ], ) @router.get("/scope-targets", response_model=ReferenceOptionListResponse) def api_scope_targets( scope_type: str, q: str = "", selected: list[str] = Query(default=[]), limit: int = Query(default=50, ge=1, le=200), cursor: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> ReferenceOptionListResponse: _require_any_scope( principal, DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE, ) registry = get_registry() try: page = access_scope_reference_page( registry, principal, scope_type=scope_type, query=q, selected_values=selected, limit=limit, cursor=cursor, administrative=has_scope(principal, ADMIN_SCOPE), session=session, ) except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc return ReferenceOptionListResponse( options=[ReferenceOptionResponse(**item.to_dict()) for item in page.options], provider_available=access_scope_reference_provider_available(registry), next_cursor=page.next_cursor, has_more=page.has_more, ) @router.post( "/definitions/validate", response_model=WorkflowGraphValidationResponse, ) def api_validate_definition( payload: WorkflowGraphValidationRequest, principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowGraphValidationResponse: _require_any_scope( principal, DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE, ) diagnostics = validate_workflow_graph(payload.graph) return WorkflowGraphValidationResponse( valid=not any(item.severity == "error" for item in diagnostics), diagnostics=[ WorkflowDiagnosticResponse( severity=item.severity, code=item.code, message=item.message, node_id=item.node_id, field=item.field, ) for item in diagnostics ], ) @router.get("/definitions", response_model=WorkflowDefinitionListResponse) def api_list_definitions( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowDefinitionListResponse: _require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE) registry = get_registry() definitions = [ definition for definition in list_definitions( session, tenant_id=principal.tenant_id, ) if definition_decision( definition, principal=principal, registry=registry, action="view", ).allowed ] return WorkflowDefinitionListResponse( definitions=[ definition_response( session, definition, principal=principal, registry=registry, ) for definition in definitions ] ) @router.get("/instances", response_model=WorkflowInstanceListResponse) def api_list_instances( definition_id: str | None = None, limit: int = Query(default=100, ge=1, le=200), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowInstanceListResponse: _require_any_scope(principal, INSTANCE_READ_SCOPE, ADMIN_SCOPE) try: instances = [ instance for instance in list_instances( session, tenant_id=principal.tenant_id, definition_id=definition_id, limit=limit, ) if definition_decision( instance.definition, principal=principal, registry=get_registry(), action="view", ).allowed ] return WorkflowInstanceListResponse( instances=[ instance_response(session, instance) for instance in instances ] ) except WorkflowError as exc: raise _http_error(exc) from exc @router.post("/standards/reconcile", response_model=dict[str, object]) def api_reconcile_standards( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, object]: _require_any_scope(principal, ADMIN_SCOPE) result = reconcile_workflow_definition_contributions( session, registry=get_registry(), tenant_ids=(principal.tenant_id,), ) audit_event( session, tenant_id=principal.tenant_id, user_id=getattr(principal.user, "id", None), api_key_id=principal.api_key_id, action="workflow.standards.reconciled", object_type="workflow_standard_catalogue", object_id="module-contributions", details={ key: value for key, value in result.items() if key != "items" }, ) session.commit() return result @router.post( "/definitions/{definition_id}/instances", response_model=WorkflowInstanceResponse, status_code=status.HTTP_201_CREATED, ) def api_start_instance( definition_id: str, payload: WorkflowInstanceStartRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowInstanceResponse: _require_any_scope(principal, INSTANCE_START_SCOPE, ADMIN_SCOPE) try: instance, replayed = start_instance( session, tenant_id=principal.tenant_id, definition_id=definition_id, actor_id=_actor_id(principal), principal=principal, registry=get_registry(), payload=payload, start_origin=( "user" if principal.auth_method == "session" else "api" ), ) except WorkflowError as exc: raise _http_error(exc) from exc _audit_instance( session, principal, action=( "workflow.instance.replayed" if replayed else "workflow.instance.started" ), instance_id=instance.id, details={ "definition_id": instance.definition_id, "definition_revision_id": instance.definition_revision_id, "idempotency_key": instance.idempotency_key, "start_origin": instance.start_origin, }, ) response = instance_response(session, instance, replayed=replayed) session.commit() return response @router.post( "/definitions/{definition_id}/reset-standard", response_model=WorkflowDefinitionResponse, ) def api_reset_definition_to_standard( definition_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowDefinitionResponse: _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) try: override = get_definition( session, tenant_id=principal.tenant_id, definition_id=definition_id, ) require_definition_action( override, principal=principal, registry=get_registry(), action="edit", ) baseline = reset_workflow_override_to_standard( session, tenant_id=principal.tenant_id, definition_id=definition_id, actor_id=_actor_id(principal), ) except PermissionError as exc: raise _governance_http_error(exc) from exc except WorkflowError as exc: raise _http_error(exc) from exc _audit( session, principal, action="workflow.definition.reset_to_standard", definition_id=definition_id, details={ "baseline_definition_id": baseline.id, "baseline_revision": baseline.current_revision, "baseline_hash": baseline.standard_contribution_hash, }, ) response = _definition_response(session, baseline, principal) session.commit() return response @router.get( "/definitions/{definition_id}/standard-diff", response_model=WorkflowStandardDiffResponse, ) def api_compare_definition_to_standard( definition_id: str, include_unchanged: bool = False, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowStandardDiffResponse: _require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE) try: definition = get_definition( session, tenant_id=principal.tenant_id, definition_id=definition_id, ) require_definition_action( definition, principal=principal, registry=get_registry(), action="view", ) return compare_workflow_override_to_standard( session, tenant_id=principal.tenant_id, definition_id=definition_id, include_unchanged=include_unchanged, ) except PermissionError as exc: raise _governance_http_error(exc) from exc except WorkflowError as exc: raise _http_error(exc) from exc @router.get( "/instances/{instance_id}", response_model=WorkflowInstanceResponse, ) def api_get_instance( instance_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowInstanceResponse: _require_any_scope(principal, INSTANCE_READ_SCOPE, ADMIN_SCOPE) try: instance = get_instance( session, tenant_id=principal.tenant_id, instance_id=instance_id, ) _require_instance_view(instance, principal) return instance_response(session, instance) except PermissionError as exc: raise _governance_http_error(exc) from exc except WorkflowError as exc: raise _http_error(exc) from exc @router.post( "/instances/{instance_id}/reconcile", response_model=WorkflowInstanceResponse, ) def api_reconcile_instance( instance_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowInstanceResponse: _require_any_scope(principal, INSTANCE_TRANSITION_SCOPE, ADMIN_SCOPE) try: instance = get_instance( session, tenant_id=principal.tenant_id, instance_id=instance_id, for_update=True, ) _require_instance_view(instance, principal) changed = reconcile_instance( session, instance=instance, principal=principal, registry=get_registry(), actor_id=_actor_id(principal), ) except PermissionError as exc: raise _governance_http_error(exc) from exc except WorkflowError as exc: raise _http_error(exc) from exc if changed: _audit_instance( session, principal, action="workflow.instance.reconciled", instance_id=instance.id, details={ "status": instance.status, "current_step_id": instance.current_step_id, }, ) response = instance_response(session, instance) session.commit() return response @router.post( "/instances/{instance_id}/steps/{step_id}/actions", response_model=WorkflowInstanceResponse, ) def api_resolve_instance_step( instance_id: str, step_id: str, payload: WorkflowStepActionRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowInstanceResponse: _require_any_scope(principal, INSTANCE_TRANSITION_SCOPE, ADMIN_SCOPE) try: existing = get_instance( session, tenant_id=principal.tenant_id, instance_id=instance_id, ) _require_instance_view(existing, principal) instance = resolve_step( session, tenant_id=principal.tenant_id, instance_id=instance_id, step_id=step_id, actor_id=_actor_id(principal), principal=principal, registry=get_registry(), payload=payload, ) except PermissionError as exc: raise _governance_http_error(exc) from exc except WorkflowError as exc: raise _http_error(exc) from exc _audit_instance( session, principal, action=f"workflow.instance.{payload.action}", instance_id=instance.id, details={ "step_id": step_id, "status": instance.status, }, ) response = instance_response(session, instance) session.commit() return response @router.post( "/instances/{instance_id}/cancel", response_model=WorkflowInstanceResponse, ) def api_cancel_instance( instance_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowInstanceResponse: _require_any_scope(principal, INSTANCE_TRANSITION_SCOPE, ADMIN_SCOPE) try: instance = get_instance( session, tenant_id=principal.tenant_id, instance_id=instance_id, ) _require_instance_view(instance, principal) instance = cancel_instance( session, tenant_id=principal.tenant_id, instance_id=instance_id, actor_id=_actor_id(principal), principal=principal, registry=get_registry(), ) except PermissionError as exc: raise _governance_http_error(exc) from exc except WorkflowError as exc: raise _http_error(exc) from exc _audit_instance( session, principal, action="workflow.instance.cancelled", instance_id=instance.id, details={"status": instance.status}, ) response = instance_response(session, instance) session.commit() return response @router.post( "/definitions", response_model=WorkflowDefinitionResponse, status_code=status.HTTP_201_CREATED, ) def api_create_definition( payload: WorkflowDefinitionCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowDefinitionResponse: _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) try: tenant_id, scope_type, scope_id, _scope_key = ( normalize_definition_scope( principal, scope_type=payload.scope_type, scope_id=payload.scope_id, administrative=has_scope(principal, ADMIN_SCOPE), ) ) payload = payload.model_copy( update={"scope_type": scope_type, "scope_id": scope_id} ) canonical_scope_id = validate_access_scope_reference( get_registry(), tenant_id=tenant_id or principal.tenant_id, scope_type=scope_type, scope_id=scope_id, ) payload = payload.model_copy(update={"scope_id": canonical_scope_id}) definition = create_definition( session, tenant_id=tenant_id or principal.tenant_id, actor_id=_actor_id(principal), payload=payload, ) except (PermissionError, ValueError) as exc: raise _governance_http_error(exc) from exc except WorkflowError as exc: raise _http_error(exc) from exc _audit( session, principal, action="workflow.definition.created", definition_id=definition.id, details={ "key": definition.definition_key, "revision": definition.current_revision, "scope_type": definition.scope_type, "scope_id": definition.scope_id, "definition_kind": definition.definition_kind, }, ) response = _definition_response(session, definition, principal) session.commit() return response @router.get( "/definitions/{definition_id}", response_model=WorkflowDefinitionResponse, ) def api_get_definition( definition_id: str, revision: int | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowDefinitionResponse: _require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE) try: definition = get_definition( session, tenant_id=principal.tenant_id, definition_id=definition_id, ) require_definition_action( definition, principal=principal, registry=get_registry(), action="view", ) return _definition_response( session, definition, principal, revision=revision, ) except PermissionError as exc: raise _governance_http_error(exc) from exc except WorkflowError as exc: raise _http_error(exc) from exc @router.put( "/definitions/{definition_id}", response_model=WorkflowDefinitionResponse, ) def api_update_definition( definition_id: str, payload: WorkflowDefinitionUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowDefinitionResponse: _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) try: existing = get_definition( session, tenant_id=principal.tenant_id, definition_id=definition_id, ) require_definition_action( existing, principal=principal, registry=get_registry(), action="edit", ) tenant_id, scope_type, scope_id, _scope_key = normalize_definition_scope( principal, scope_type=payload.scope_type, scope_id=payload.scope_id, administrative=has_scope(principal, ADMIN_SCOPE), ) scope_id = validate_access_scope_reference( get_registry(), tenant_id=tenant_id or principal.tenant_id, scope_type=scope_type, scope_id=scope_id, preserve_existing=( existing.scope_id if existing.scope_type == scope_type else None ), ) payload = payload.model_copy( update={"scope_type": scope_type, "scope_id": scope_id} ) definition = update_definition( session, tenant_id=principal.tenant_id, definition_id=definition_id, actor_id=_actor_id(principal), payload=payload, ) except (PermissionError, ValueError) as exc: raise _governance_http_error(exc) from exc except WorkflowError as exc: raise _http_error(exc) from exc _audit( session, principal, action="workflow.definition.updated", definition_id=definition.id, details={ "revision": definition.current_revision, "status": definition.status, }, ) response = _definition_response(session, definition, principal) session.commit() return response @router.post( "/definitions/{definition_id}/derive", response_model=WorkflowDefinitionResponse, status_code=status.HTTP_201_CREATED, ) def api_derive_definition( definition_id: str, payload: WorkflowDefinitionDeriveRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowDefinitionResponse: _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) try: tenant_id, scope_type, scope_id, _scope_key = ( normalize_definition_scope( principal, scope_type=payload.scope_type, scope_id=payload.scope_id, administrative=has_scope(principal, ADMIN_SCOPE), ) ) payload = payload.model_copy( update={"scope_type": scope_type, "scope_id": scope_id} ) canonical_scope_id = validate_access_scope_reference( get_registry(), tenant_id=tenant_id or principal.tenant_id, scope_type=scope_type, scope_id=scope_id, ) payload = payload.model_copy(update={"scope_id": canonical_scope_id}) definition = derive_definition( session, tenant_id=tenant_id or principal.tenant_id, actor_id=_actor_id(principal), principal=principal, registry=get_registry(), source_definition_id=definition_id, payload=payload, ) except (PermissionError, ValueError) as exc: raise _governance_http_error(exc) from exc except WorkflowError as exc: raise _http_error(exc) from exc _audit( session, principal, action="workflow.definition.derived", definition_id=definition.id, details={ "source_definition_id": ( definition.derived_from_definition_id ), "source_revision": definition.derived_from_revision, "source_hash": definition.derived_from_hash, "scope_type": definition.scope_type, "scope_id": definition.scope_id, }, ) response = _definition_response(session, definition, principal) session.commit() return response @router.get( "/definitions/{definition_id}/revisions", response_model=WorkflowDefinitionRevisionListResponse, ) def api_list_definition_revisions( definition_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowDefinitionRevisionListResponse: _require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE) try: definition = get_definition( session, tenant_id=principal.tenant_id, definition_id=definition_id, ) require_definition_action( definition, principal=principal, registry=get_registry(), action="view", ) revisions = list_definition_revisions(session, definition=definition) except PermissionError as exc: raise _governance_http_error(exc) from exc except WorkflowError as exc: raise _http_error(exc) from exc return WorkflowDefinitionRevisionListResponse( revisions=[revision_response(item) for item in revisions] ) @router.get( "/definitions/{definition_id}/revisions/{revision}", response_model=WorkflowDefinitionRevisionResponse, ) def api_get_definition_revision( definition_id: str, revision: int, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowDefinitionRevisionResponse: _require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE) try: definition = get_definition( session, tenant_id=principal.tenant_id, definition_id=definition_id, ) require_definition_action( definition, principal=principal, registry=get_registry(), action="view", ) item = get_definition_revision( session, definition=definition, revision=revision, ) except PermissionError as exc: raise _governance_http_error(exc) from exc except WorkflowError as exc: raise _http_error(exc) from exc return revision_response(item) @router.get( "/definitions/{definition_id}/revisions/{revision}/bpmn", response_model=BpmnRevisionDocumentResponse, ) def api_get_definition_revision_bpmn( definition_id: str, revision: int, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> BpmnRevisionDocumentResponse: _require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE) try: definition = get_definition( session, tenant_id=principal.tenant_id, definition_id=definition_id, ) require_definition_action( definition, principal=principal, registry=get_registry(), action="view", ) item = get_definition_revision( session, definition=definition, revision=revision, ) except PermissionError as exc: raise _governance_http_error(exc) from exc except WorkflowError as exc: raise _http_error(exc) from exc summary = revision_bpmn_summary(item) if summary is None or not item.bpmn_xml: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="This Workflow revision has no BPMN document.", ) try: inspection = _bpmn_inspection_response( item.bpmn_xml, adapter_id=summary.adapter_id, adapter_version=summary.adapter_version, activation=True, ) except BpmnInspectionError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc return BpmnRevisionDocumentResponse( **summary.model_dump(), definition_id=definition.id, revision=item.revision, xml=item.bpmn_xml, inspection=inspection, ) @router.post( "/definitions/{definition_id}/activate", response_model=WorkflowDefinitionResponse, ) def api_activate_definition( definition_id: str, payload: WorkflowDefinitionActivateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowDefinitionResponse: _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) try: existing = get_definition( session, tenant_id=principal.tenant_id, definition_id=definition_id, ) require_definition_action( existing, principal=principal, registry=get_registry(), action="edit", ) definition = activate_definition( session, tenant_id=principal.tenant_id, definition_id=definition_id, actor_id=_actor_id(principal), revision=payload.revision, ) except PermissionError as exc: raise _governance_http_error(exc) from exc except WorkflowError as exc: raise _http_error(exc) from exc _audit( session, principal, action="workflow.definition.activated", definition_id=definition.id, details={"active_revision": definition.active_revision}, ) response = _definition_response(session, definition, principal) session.commit() return response @router.post( "/definitions/{definition_id}/archive", response_model=WorkflowDefinitionResponse, ) def api_archive_definition( definition_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowDefinitionResponse: _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) try: existing = get_definition( session, tenant_id=principal.tenant_id, definition_id=definition_id, ) require_definition_action( existing, principal=principal, registry=get_registry(), action="edit", ) definition = archive_definition( session, tenant_id=principal.tenant_id, definition_id=definition_id, actor_id=_actor_id(principal), ) except PermissionError as exc: raise _governance_http_error(exc) from exc except WorkflowError as exc: raise _http_error(exc) from exc _audit( session, principal, action="workflow.definition.archived", definition_id=definition.id, details={"active_revision": definition.active_revision}, ) response = _definition_response(session, definition, principal) session.commit() return response @router.delete( "/definitions/{definition_id}", response_model=WorkflowDefinitionDeleteResponse, ) def api_delete_definition( definition_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> WorkflowDefinitionDeleteResponse: _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) try: existing = get_definition( session, tenant_id=principal.tenant_id, definition_id=definition_id, ) require_definition_action( existing, principal=principal, registry=get_registry(), action="edit", ) definition = delete_definition( session, tenant_id=principal.tenant_id, definition_id=definition_id, actor_id=_actor_id(principal), ) except PermissionError as exc: raise _governance_http_error(exc) from exc except WorkflowError as exc: raise _http_error(exc) from exc _audit( session, principal, action="workflow.definition.deleted", definition_id=definition.id, details={"revision": definition.current_revision}, ) session.commit() return WorkflowDefinitionDeleteResponse( deleted=True, definition_id=definition.id, ) __all__ = ["router"]