feat: persist and edit versioned workflow definitions
This commit is contained in:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user