diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model.py index 370bc236c..231f37871 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model.py @@ -39,6 +39,7 @@ class ProcessModelInfo: description: str primary_file_name: str | None = None primary_process_id: str | None = None + is_executable: bool = True fault_or_suspend_on_exception: str = NotificationType.fault.value exception_notification_addresses: list[str] = field(default_factory=list) metadata_extraction_paths: list[dict[str, str]] | None = None diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/extensions_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/extensions_controller.py index 97f1ebe43..2231f6d7e 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/extensions_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/extensions_controller.py @@ -62,6 +62,7 @@ def extension_list() -> flask.wrappers.Response: process_model_extensions = [] if current_app.config["SPIFFWORKFLOW_BACKEND_EXTENSIONS_API_ENABLED"]: process_model_extensions = ProcessModelService.get_process_models_for_api( + user=g.user, process_group_id=current_app.config["SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX"], recursive=True, filter_runnable_as_extension=True, diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_models_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_models_controller.py index 53ee3d7bd..a3bf51db0 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_models_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_models_controller.py @@ -1,4 +1,3 @@ -"""APIs for dealing with process groups, process models, and process instances.""" import json import os 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_model import ProcessModelInfo 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 _get_process_model 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) 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: 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) try: current_git_revision = GitService.get_current_revision() @@ -215,6 +228,7 @@ def process_model_list( per_page: int = 100, ) -> flask.wrappers.Response: process_models = ProcessModelService.get_process_models_for_api( + user=g.user, process_group_id=process_group_identifier, recursive=recursive, filter_runnable_by_user=filter_runnable_by_user, diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_service.py index 9ba164996..2a48c6a50 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_service.py @@ -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 ProcessModelInfo 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.user import UserModel from spiffworkflow_backend.services.authorization_service import AuthorizationService @@ -207,6 +209,7 @@ class ProcessModelService(FileSystemService): @classmethod def get_process_models_for_api( cls, + user: UserModel, process_group_id: str | None = None, recursive: bool | None = False, filter_runnable_by_user: bool | None = False, @@ -226,17 +229,14 @@ class ProcessModelService(FileSystemService): permission_to_check = "read" 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: permission_to_check = "create" permission_base_uri = "/v1.0/process-instances" if filter_runnable_as_extension: permission_to_check = "create" permission_base_uri = "/v1.0/extensions" - process_model_identifiers = [ - p.id.replace(f"{current_app.config['SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX']}/", "") - for p in process_models - ] + process_model_identifiers = [p.id.replace(f"{extension_prefix}/", "") for p in process_models] # these are the ones (identifiers, at least) you are allowed to start 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, ) + 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 = [] for process_model in process_models: process_model_identifier = process_model.id if filter_runnable_as_extension: - process_model_identifier = process_model.id.replace( - f"{current_app.config['SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX']}/", "" - ) + process_model_identifier = process_model.id.replace(f"{extension_prefix}/", "") if process_model_identifier in permitted_process_model_identifiers: permitted_process_models.append(process_model) + 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 def process_model_identifiers_with_permission_for_user( 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}) 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 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, {}) diff --git a/spiffworkflow-backend/tests/data/non_executable/non_executable.bpmn b/spiffworkflow-backend/tests/data/non_executable/non_executable.bpmn new file mode 100644 index 000000000..3689c40af --- /dev/null +++ b/spiffworkflow-backend/tests/data/non_executable/non_executable.bpmn @@ -0,0 +1,51 @@ + + + + + Flow_17db3yp + + + + + The process instance completed successfully. + + Flow_12pkbxb + + + + + 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. + + Flow_17db3yp + Flow_12pkbxb + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_models_controller.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_models_controller.py index 7d9367702..e6973278e 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_models_controller.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_models_controller.py @@ -7,6 +7,7 @@ from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.services.spec_file_service import SpecFileService from tests.spiffworkflow_backend.helpers.base_test import BaseTest +from tests.spiffworkflow_backend.helpers.test_data import load_test_spec class TestProcessModelsController(BaseTest): @@ -60,3 +61,58 @@ class TestProcessModelsController(BaseTest): assert response.json["message"].startswith( "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 diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model_service.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model_service.py index fa3cf193d..9feb48953 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model_service.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model_service.py @@ -129,3 +129,44 @@ class TestProcessModelService(BaseTest): pg_identifiers = [pg.id for pg in process_groups] assert len(pg_identifiers) == 1 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 diff --git a/spiffworkflow-frontend/src/components/ProcessInstanceRun.tsx b/spiffworkflow-frontend/src/components/ProcessInstanceRun.tsx index 6129986fb..0a21c6b20 100644 --- a/spiffworkflow-frontend/src/components/ProcessInstanceRun.tsx +++ b/spiffworkflow-frontend/src/components/ProcessInstanceRun.tsx @@ -137,17 +137,20 @@ export default function ProcessInstanceRun({ }); }; - const startButton = ( - - ); + let startButton = null; + if (processModel.primary_file_name && processModel.is_executable) { + startButton = ( + + ); + } // if checkPermissions is false then assume the page using this component has already checked the permissions if (checkPermissions) { diff --git a/spiffworkflow-frontend/src/interfaces.ts b/spiffworkflow-frontend/src/interfaces.ts index fd5b8b603..9045b1100 100644 --- a/spiffworkflow-frontend/src/interfaces.ts +++ b/spiffworkflow-frontend/src/interfaces.ts @@ -289,6 +289,7 @@ export interface ProcessModel { fault_or_suspend_on_exception?: string; exception_notification_addresses?: string[]; bpmn_version_control_identifier?: string; + is_executable?: boolean; actions?: ApiActions; } diff --git a/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx b/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx index a95d17356..3239c7cf6 100644 --- a/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx @@ -108,7 +108,7 @@ export default function ProcessModelShow() { setSelectedTabIndex(newTabIndex); }; HttpService.makeCallToBackend({ - path: `/process-models/${modifiedProcessModelId}`, + path: `/process-models/${modifiedProcessModelId}?include_file_references=true`, successCallback: processResult, }); }, [reloadModel, modifiedProcessModelId]); @@ -795,7 +795,9 @@ export default function ProcessModelShow() {

{processModel.description}

- {processModel.primary_file_name ? processStartButton : null} + {processModel.primary_file_name && processModel.is_executable + ? processStartButton + : null}
{tabArea()}
{permissionsLoaded ? (