1038 lines
32 KiB
Python
1038 lines
32 KiB
Python
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"]
|