feat: persist and edit versioned workflow definitions

This commit is contained in:
2026-07-28 13:48:06 +02:00
parent 85eef00913
commit 6737b60c11
25 changed files with 3913 additions and 9 deletions
+338
View File
@@ -1,8 +1,11 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.db.session import get_session
from govoplan_workflow.backend.manifest import (
ADMIN_SCOPE,
DEFINITION_READ_SCOPE,
@@ -11,6 +14,14 @@ from govoplan_workflow.backend.manifest import (
from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY
from govoplan_workflow.backend.schemas import (
WorkflowConfigFieldResponse,
WorkflowDefinitionActivateRequest,
WorkflowDefinitionCreateRequest,
WorkflowDefinitionDeleteResponse,
WorkflowDefinitionListResponse,
WorkflowDefinitionResponse,
WorkflowDefinitionRevisionListResponse,
WorkflowDefinitionRevisionResponse,
WorkflowDefinitionUpdateRequest,
WorkflowDiagnosticResponse,
WorkflowGraphValidationRequest,
WorkflowGraphValidationResponse,
@@ -18,6 +29,23 @@ from govoplan_workflow.backend.schemas import (
WorkflowNodeTypeResponse,
WorkflowPortResponse,
)
from govoplan_workflow.backend.service import (
WorkflowConflictError,
WorkflowError,
WorkflowNotFoundError,
WorkflowValidationError,
activate_definition,
archive_definition,
create_definition,
definition_response,
delete_definition,
get_definition,
get_definition_revision,
list_definition_revisions,
list_definitions,
revision_response,
update_definition,
)
from govoplan_workflow.backend.validation import validate_workflow_graph
@@ -33,6 +61,58 @@ def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
)
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 _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,
)
@router.get("/node-types", response_model=WorkflowNodeLibraryResponse)
def api_node_types(
principal: ApiPrincipal = Depends(get_api_principal),
@@ -123,4 +203,262 @@ def api_validate_definition(
)
@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)
return WorkflowDefinitionListResponse(
definitions=[
definition_response(session, definition)
for definition in list_definitions(
session,
tenant_id=principal.tenant_id,
)
]
)
@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:
definition = create_definition(
session,
tenant_id=principal.tenant_id,
actor_id=_actor_id(principal),
payload=payload,
)
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,
},
)
response = definition_response(session, definition)
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,
)
return definition_response(session, definition, revision=revision)
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:
definition = update_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
payload=payload,
)
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)
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,
)
revisions = list_definition_revisions(session, definition=definition)
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,
)
item = get_definition_revision(
session,
definition=definition,
revision=revision,
)
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:
definition = activate_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
revision=payload.revision,
)
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)
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:
definition = archive_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
)
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)
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:
definition = delete_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
)
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"]