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:
parent
aaec9f6ffc
commit
020519a724
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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, {})
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -137,17 +137,20 @@ export default function ProcessInstanceRun({
|
|||
});
|
||||
};
|
||||
|
||||
const startButton = (
|
||||
<Button
|
||||
data-qa="start-process-instance"
|
||||
onClick={processInstanceCreateAndRun}
|
||||
className={className}
|
||||
disabled={disableStartButton}
|
||||
size="md"
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
);
|
||||
let startButton = null;
|
||||
if (processModel.primary_file_name && processModel.is_executable) {
|
||||
startButton = (
|
||||
<Button
|
||||
data-qa="start-process-instance"
|
||||
onClick={processInstanceCreateAndRun}
|
||||
className={className}
|
||||
disabled={disableStartButton}
|
||||
size="md"
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// if checkPermissions is false then assume the page using this component has already checked the permissions
|
||||
if (checkPermissions) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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() {
|
|||
</Can>
|
||||
</Stack>
|
||||
<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>
|
||||
{permissionsLoaded ? (
|
||||
<span data-qa="process-model-show-permissions-loaded" />
|
||||
|
|
Loading…
Reference in New Issue