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.backend.governance import ( definition_decision, normalize_definition_scope, require_definition_action, ) from govoplan_workflow.backend.manifest import ( ADMIN_SCOPE, DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE, INSTANCE_READ_SCOPE, INSTANCE_START_SCOPE, INSTANCE_TRANSITION_SCOPE, ) from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY from govoplan_workflow.backend.schemas import ( WorkflowConfigFieldResponse, WorkflowDefinitionActivateRequest, WorkflowDefinitionCreateRequest, WorkflowDefinitionDeleteResponse, WorkflowDefinitionDeriveRequest, WorkflowDefinitionListResponse, WorkflowDefinitionResponse, WorkflowDefinitionRevisionListResponse, WorkflowDefinitionRevisionResponse, WorkflowDefinitionUpdateRequest, WorkflowDiagnosticResponse, WorkflowGraphValidationRequest, WorkflowGraphValidationResponse, WorkflowInstanceListResponse, WorkflowInstanceResponse, WorkflowInstanceStartRequest, WorkflowNodeLibraryResponse, WorkflowNodeTypeResponse, WorkflowPortResponse, WorkflowStepActionRequest, ) from govoplan_workflow.backend.instance_service import ( cancel_instance, get_instance, instance_response, list_instances, reconcile_instance, resolve_step, start_instance, ) from govoplan_workflow.backend.runtime import get_registry from govoplan_workflow.backend.service import ( 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, update_definition, ) from govoplan_workflow.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, 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", ) @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), ) for definition in WORKFLOW_GRAPH_LIBRARY.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( "/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, ) 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, }, ) response = instance_response(session, instance, replayed=replayed) session.commit() return response @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.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"]