support bpmn executable property (#934)

* support executable property

* process model with no primary file is not executable

* filter by executable and primary file and avoid snagging g.user from service

* kill Start buttons on process group list as well

* use more similar code to check is executable and write integration test

* add a test and improve a variable name

---------

Co-authored-by: burnettk <burnettk@users.noreply.github.com>
This commit is contained in:
Kevin Burnett 2024-02-01 10:57:12 -08:00 committed by GitHub
parent aaec9f6ffc
commit 020519a724
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 254 additions and 24 deletions

View File

@ -39,6 +39,7 @@ class ProcessModelInfo:
description: str description: str
primary_file_name: str | None = None primary_file_name: str | None = None
primary_process_id: str | None = None primary_process_id: str | None = None
is_executable: bool = True
fault_or_suspend_on_exception: str = NotificationType.fault.value fault_or_suspend_on_exception: str = NotificationType.fault.value
exception_notification_addresses: list[str] = field(default_factory=list) exception_notification_addresses: list[str] = field(default_factory=list)
metadata_extraction_paths: list[dict[str, str]] | None = None metadata_extraction_paths: list[dict[str, str]] | None = None

View File

@ -62,6 +62,7 @@ def extension_list() -> flask.wrappers.Response:
process_model_extensions = [] process_model_extensions = []
if current_app.config["SPIFFWORKFLOW_BACKEND_EXTENSIONS_API_ENABLED"]: if current_app.config["SPIFFWORKFLOW_BACKEND_EXTENSIONS_API_ENABLED"]:
process_model_extensions = ProcessModelService.get_process_models_for_api( process_model_extensions = ProcessModelService.get_process_models_for_api(
user=g.user,
process_group_id=current_app.config["SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX"], process_group_id=current_app.config["SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX"],
recursive=True, recursive=True,
filter_runnable_as_extension=True, filter_runnable_as_extension=True,

View File

@ -1,4 +1,3 @@
"""APIs for dealing with process groups, process models, and process instances."""
import json import json
import os import os
import random import random
@ -24,6 +23,7 @@ from spiffworkflow_backend.models.process_group import ProcessGroup
from spiffworkflow_backend.models.process_instance_report import ProcessInstanceReportModel from spiffworkflow_backend.models.process_instance_report import ProcessInstanceReportModel
from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.process_model import ProcessModelInfo
from spiffworkflow_backend.models.process_model import ProcessModelInfoSchema from spiffworkflow_backend.models.process_model import ProcessModelInfoSchema
from spiffworkflow_backend.models.reference_cache import ReferenceCacheModel
from spiffworkflow_backend.routes.process_api_blueprint import _commit_and_push_to_git from spiffworkflow_backend.routes.process_api_blueprint import _commit_and_push_to_git
from spiffworkflow_backend.routes.process_api_blueprint import _get_process_model from spiffworkflow_backend.routes.process_api_blueprint import _get_process_model
from spiffworkflow_backend.routes.process_api_blueprint import _un_modify_modified_process_model_id from spiffworkflow_backend.routes.process_api_blueprint import _un_modify_modified_process_model_id
@ -165,10 +165,23 @@ def process_model_show(modified_process_model_identifier: str, include_file_refe
files = FileSystemService.get_sorted_files(process_model) files = FileSystemService.get_sorted_files(process_model)
process_model.files = files process_model.files = files
reference_cache_processes = (
ReferenceCacheModel.basic_query()
.filter_by(
type="process",
identifier=process_model.primary_process_id,
relative_location=process_model.id,
file_name=process_model.primary_file_name,
)
.all()
)
ProcessModelService.embellish_with_is_executable_property([process_model], reference_cache_processes)
if include_file_references: if include_file_references:
for file in process_model.files: for file in process_model.files:
file.references = SpecFileService.get_references_for_file(file, process_model) refs = SpecFileService.get_references_for_file(file, process_model)
file.references = refs
process_model.parent_groups = ProcessModelService.get_parent_group_array(process_model.id) process_model.parent_groups = ProcessModelService.get_parent_group_array(process_model.id)
try: try:
current_git_revision = GitService.get_current_revision() current_git_revision = GitService.get_current_revision()
@ -215,6 +228,7 @@ def process_model_list(
per_page: int = 100, per_page: int = 100,
) -> flask.wrappers.Response: ) -> flask.wrappers.Response:
process_models = ProcessModelService.get_process_models_for_api( process_models = ProcessModelService.get_process_models_for_api(
user=g.user,
process_group_id=process_group_identifier, process_group_id=process_group_identifier,
recursive=recursive, recursive=recursive,
filter_runnable_by_user=filter_runnable_by_user, filter_runnable_by_user=filter_runnable_by_user,

View File

@ -19,6 +19,8 @@ from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
from spiffworkflow_backend.models.process_model import PROCESS_MODEL_SUPPORTED_KEYS_FOR_DISK_SERIALIZATION from spiffworkflow_backend.models.process_model import PROCESS_MODEL_SUPPORTED_KEYS_FOR_DISK_SERIALIZATION
from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.process_model import ProcessModelInfo
from spiffworkflow_backend.models.process_model import ProcessModelInfoSchema from spiffworkflow_backend.models.process_model import ProcessModelInfoSchema
from spiffworkflow_backend.models.reference_cache import Reference
from spiffworkflow_backend.models.reference_cache import ReferenceCacheModel
from spiffworkflow_backend.models.task import TaskModel # noqa: F401 from spiffworkflow_backend.models.task import TaskModel # noqa: F401
from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.authorization_service import AuthorizationService
@ -207,6 +209,7 @@ class ProcessModelService(FileSystemService):
@classmethod @classmethod
def get_process_models_for_api( def get_process_models_for_api(
cls, cls,
user: UserModel,
process_group_id: str | None = None, process_group_id: str | None = None,
recursive: bool | None = False, recursive: bool | None = False,
filter_runnable_by_user: bool | None = False, filter_runnable_by_user: bool | None = False,
@ -226,17 +229,14 @@ class ProcessModelService(FileSystemService):
permission_to_check = "read" permission_to_check = "read"
permission_base_uri = "/v1.0/process-models" permission_base_uri = "/v1.0/process-models"
user = UserService.current_user() extension_prefix = current_app.config["SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX"]
if filter_runnable_by_user: if filter_runnable_by_user:
permission_to_check = "create" permission_to_check = "create"
permission_base_uri = "/v1.0/process-instances" permission_base_uri = "/v1.0/process-instances"
if filter_runnable_as_extension: if filter_runnable_as_extension:
permission_to_check = "create" permission_to_check = "create"
permission_base_uri = "/v1.0/extensions" permission_base_uri = "/v1.0/extensions"
process_model_identifiers = [ process_model_identifiers = [p.id.replace(f"{extension_prefix}/", "") for p in process_models]
p.id.replace(f"{current_app.config['SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX']}/", "")
for p in process_models
]
# these are the ones (identifiers, at least) you are allowed to start # these are the ones (identifiers, at least) you are allowed to start
permitted_process_model_identifiers = cls.process_model_identifiers_with_permission_for_user( permitted_process_model_identifiers = cls.process_model_identifiers_with_permission_for_user(
@ -246,17 +246,70 @@ class ProcessModelService(FileSystemService):
process_model_identifiers=process_model_identifiers, process_model_identifiers=process_model_identifiers,
) )
reference_cache_processes = ReferenceCacheModel.basic_query().filter_by(type="process").all()
process_models = cls.embellish_with_is_executable_property(process_models, reference_cache_processes)
if filter_runnable_by_user or filter_runnable_as_extension:
process_models = cls.filter_by_runnable(process_models, reference_cache_processes)
permitted_process_models = [] permitted_process_models = []
for process_model in process_models: for process_model in process_models:
process_model_identifier = process_model.id process_model_identifier = process_model.id
if filter_runnable_as_extension: if filter_runnable_as_extension:
process_model_identifier = process_model.id.replace( process_model_identifier = process_model.id.replace(f"{extension_prefix}/", "")
f"{current_app.config['SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX']}/", ""
)
if process_model_identifier in permitted_process_model_identifiers: if process_model_identifier in permitted_process_model_identifiers:
permitted_process_models.append(process_model) permitted_process_models.append(process_model)
return permitted_process_models return permitted_process_models
@classmethod
def embellish_with_is_executable_property(
cls, process_models: list[ProcessModelInfo], reference_cache_processes: list[ReferenceCacheModel]
) -> list[ProcessModelInfo]:
for process_model in process_models:
matching_reference_cache_process = cls.find_reference_cache_process_for_process_model(
reference_cache_processes, process_model
)
if (
matching_reference_cache_process
and matching_reference_cache_process.properties
and "is_executable" in matching_reference_cache_process.properties
and matching_reference_cache_process.properties["is_executable"] is False
):
process_model.is_executable = False
else:
process_model.is_executable = True
return process_models
@classmethod
def filter_by_runnable(
cls, process_models: list[ProcessModelInfo], reference_cache_processes: list[ReferenceCacheModel]
) -> list[ProcessModelInfo]:
runnable_process_models = []
for process_model in process_models:
# if you want to be able to run a process model, it must have a primary file in addition to being executable
if (
process_model.primary_file_name is not None
and process_model.primary_file_name != ""
and process_model.is_executable
):
runnable_process_models.append(process_model)
return runnable_process_models
@classmethod
def find_reference_cache_process_for_process_model(
cls, reference_cache_processes: list[ReferenceCacheModel], process_model: ProcessModelInfo
) -> ReferenceCacheModel | None:
for reference_cache_process in reference_cache_processes:
if (
reference_cache_process.identifier == process_model.primary_process_id
and reference_cache_process.file_name == process_model.primary_file_name
and reference_cache_process.relative_location == process_model.id
):
return reference_cache_process
return None
@classmethod @classmethod
def process_model_identifiers_with_permission_for_user( def process_model_identifiers_with_permission_for_user(
cls, user: UserModel, permission_to_check: str, permission_base_uri: str, process_model_identifiers: list[str] cls, user: UserModel, permission_to_check: str, permission_base_uri: str, process_model_identifiers: list[str]
@ -314,6 +367,13 @@ class ProcessModelService(FileSystemService):
parent_group_array.append({"id": parent_group.id, "display_name": parent_group.display_name}) parent_group_array.append({"id": parent_group.id, "display_name": parent_group.display_name})
return {"cache": process_group_cache, "process_groups": parent_group_array} return {"cache": process_group_cache, "process_groups": parent_group_array}
@classmethod
def reference_for_primary_file(cls, references: list[Reference], primary_file: str) -> Reference | None:
for reference in references:
if reference.file_name == primary_file:
return reference
return None
@classmethod @classmethod
def get_parent_group_array(cls, process_identifier: str) -> list[ProcessGroupLite]: def get_parent_group_array(cls, process_identifier: str) -> list[ProcessGroupLite]:
parent_group_lites_with_cache = cls.get_parent_group_array_and_cache_it(process_identifier, {}) parent_group_lites_with_cache = cls.get_parent_group_array_and_cache_it(process_identifier, {})

View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="Process_wip" isExecutable="false">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_17db3yp</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_17db3yp" sourceRef="StartEvent_1" targetRef="Activity_0qpzdpu" />
<bpmn:endEvent id="EndEvent_1">
<bpmn:extensionElements>
<spiffworkflow:instructionsForEndUser>The process instance completed successfully.</spiffworkflow:instructionsForEndUser>
</bpmn:extensionElements>
<bpmn:incoming>Flow_12pkbxb</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_12pkbxb" sourceRef="Activity_0qpzdpu" targetRef="EndEvent_1" />
<bpmn:manualTask id="Activity_0qpzdpu" name="Example manual task">
<bpmn:extensionElements>
<spiffworkflow:instructionsForEndUser>This is an example **Manual Task**. A **Manual Task** is designed to allow someone to complete a task outside of the system and then report back that it is complete. You can click the *Continue* button to proceed. When you are done running this process, you can edit the **Process Model** to include a:
* **Script Task** - write a short snippet of python code to update some data
* **User Task** - generate a form that collects information from a user
* **Service Task** - communicate with an external API to fetch or update some data.
You can also change the text you are reading here by updating the *Instructions* on this example manual task.</spiffworkflow:instructionsForEndUser>
</bpmn:extensionElements>
<bpmn:incoming>Flow_17db3yp</bpmn:incoming>
<bpmn:outgoing>Flow_12pkbxb</bpmn:outgoing>
</bpmn:manualTask>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_wip">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_14za570_di" bpmnElement="EndEvent_1">
<dc:Bounds x="432" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0zqotmb_di" bpmnElement="Activity_0qpzdpu">
<dc:Bounds x="270" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_17db3yp_di" bpmnElement="Flow_17db3yp">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_12pkbxb_di" bpmnElement="Flow_12pkbxb">
<di:waypoint x="370" y="177" />
<di:waypoint x="432" y="177" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -7,6 +7,7 @@ from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.services.spec_file_service import SpecFileService from spiffworkflow_backend.services.spec_file_service import SpecFileService
from tests.spiffworkflow_backend.helpers.base_test import BaseTest from tests.spiffworkflow_backend.helpers.base_test import BaseTest
from tests.spiffworkflow_backend.helpers.test_data import load_test_spec
class TestProcessModelsController(BaseTest): class TestProcessModelsController(BaseTest):
@ -60,3 +61,58 @@ class TestProcessModelsController(BaseTest):
assert response.json["message"].startswith( assert response.json["message"].startswith(
"NotAuthorizedError: You are not authorized to use one or more processes as a called element" "NotAuthorizedError: You are not authorized to use one or more processes as a called element"
) )
def test_process_model_show(
self,
app: Flask,
client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,
with_super_admin_user: UserModel,
) -> None:
process_model_1 = load_test_spec(
"test_group/hello_world",
bpmn_file_name="hello_world.bpmn",
process_model_source_directory="hello_world",
)
process_model_2 = load_test_spec(
"test_group/non_executable",
bpmn_file_name="non_executable.bpmn",
process_model_source_directory="non_executable",
)
json = self._get_process_show_show_response(
client, with_super_admin_user, process_model_1.modified_process_model_identifier()
)
assert json["id"] == "test_group/hello_world"
assert json["is_executable"] is True
json = self._get_process_show_show_response(
client, with_super_admin_user, process_model_2.modified_process_model_identifier()
)
assert json["id"] == "test_group/non_executable"
assert json["is_executable"] is False
def test_process_model_show_when_not_found(
self,
app: Flask,
client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,
with_super_admin_user: UserModel,
) -> None:
json = self._get_process_show_show_response(
client, with_super_admin_user, "bad-model-does-not-exist", expected_response=400
)
assert json["error_code"] == "process_model_cannot_be_found"
def _get_process_show_show_response(
self, client: FlaskClient, user: UserModel, process_model_id: str, expected_response: int = 200
) -> dict:
url = f"/v1.0/process-models/{process_model_id}"
response = client.get(
url,
follow_redirects=True,
headers=self.logged_in_headers(user),
)
assert response.status_code == expected_response
assert response.json is not None
process_model_data: dict = response.json
return process_model_data

View File

@ -129,3 +129,44 @@ class TestProcessModelService(BaseTest):
pg_identifiers = [pg.id for pg in process_groups] pg_identifiers = [pg.id for pg in process_groups]
assert len(pg_identifiers) == 1 assert len(pg_identifiers) == 1
assert process_groups[0].id == "a1/b2" assert process_groups[0].id == "a1/b2"
def test_get_process_models_for_api(
self,
app: Flask,
with_db_and_bpmn_file_cleanup: None,
) -> None:
user = BaseTest.create_user_with_permission("super_admin")
process_model = load_test_spec(
"test_group/hello_world",
bpmn_file_name="hello_world.bpmn",
process_model_source_directory="hello_world",
)
assert process_model.display_name == "test_group/hello_world"
primary_process_id = process_model.primary_process_id
assert primary_process_id == "Process_HelloWorld"
process_models = ProcessModelService.get_process_models_for_api(user=user, recursive=True, filter_runnable_by_user=True)
assert len(process_models) == 1
assert process_model.primary_process_id == primary_process_id
process_model = load_test_spec(
"test_group/hello_world_2",
bpmn_file_name="hello_world.bpmn",
process_model_source_directory="hello_world",
)
# this model should not show up in results because it has no primary_file_name
ProcessModelService.update_process_model(process_model, {"primary_file_name": None})
process_models = ProcessModelService.get_process_models_for_api(user=user, recursive=True, filter_runnable_by_user=True)
assert len(process_models) == 1
process_model = load_test_spec(
"non_executable/non_executable",
bpmn_file_name="non_executable.bpmn",
process_model_source_directory="non_executable",
)
# this model should not show up in results because it is not executable
process_models = ProcessModelService.get_process_models_for_api(user=user, recursive=True, filter_runnable_by_user=True)
assert len(process_models) == 1

View File

@ -137,7 +137,9 @@ export default function ProcessInstanceRun({
}); });
}; };
const startButton = ( let startButton = null;
if (processModel.primary_file_name && processModel.is_executable) {
startButton = (
<Button <Button
data-qa="start-process-instance" data-qa="start-process-instance"
onClick={processInstanceCreateAndRun} onClick={processInstanceCreateAndRun}
@ -148,6 +150,7 @@ export default function ProcessInstanceRun({
{buttonText} {buttonText}
</Button> </Button>
); );
}
// if checkPermissions is false then assume the page using this component has already checked the permissions // if checkPermissions is false then assume the page using this component has already checked the permissions
if (checkPermissions) { if (checkPermissions) {

View File

@ -289,6 +289,7 @@ export interface ProcessModel {
fault_or_suspend_on_exception?: string; fault_or_suspend_on_exception?: string;
exception_notification_addresses?: string[]; exception_notification_addresses?: string[];
bpmn_version_control_identifier?: string; bpmn_version_control_identifier?: string;
is_executable?: boolean;
actions?: ApiActions; actions?: ApiActions;
} }

View File

@ -108,7 +108,7 @@ export default function ProcessModelShow() {
setSelectedTabIndex(newTabIndex); setSelectedTabIndex(newTabIndex);
}; };
HttpService.makeCallToBackend({ HttpService.makeCallToBackend({
path: `/process-models/${modifiedProcessModelId}`, path: `/process-models/${modifiedProcessModelId}?include_file_references=true`,
successCallback: processResult, successCallback: processResult,
}); });
}, [reloadModel, modifiedProcessModelId]); }, [reloadModel, modifiedProcessModelId]);
@ -795,7 +795,9 @@ export default function ProcessModelShow() {
</Can> </Can>
</Stack> </Stack>
<p className="process-description">{processModel.description}</p> <p className="process-description">{processModel.description}</p>
{processModel.primary_file_name ? processStartButton : null} {processModel.primary_file_name && processModel.is_executable
? processStartButton
: null}
<div className="with-top-margin">{tabArea()}</div> <div className="with-top-margin">{tabArea()}</div>
{permissionsLoaded ? ( {permissionsLoaded ? (
<span data-qa="process-model-show-permissions-loaded" /> <span data-qa="process-model-show-permissions-loaded" />