diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index d96de3dbc..6c720265c 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -946,6 +946,27 @@ paths: schema: $ref: "#/components/schemas/Workflow" + /process-instances/find-by-id/{process_instance_id}: + parameters: + - name: process_instance_id + in: path + required: true + description: The unique id of an existing process instance. + schema: + type: integer + get: + operationId: spiffworkflow_backend.routes.process_instances_controller.process_instance_find_by_id + summary: Find a process instance based on its id only + tags: + - Process Instances + responses: + "200": + description: One Process Instance + content: + application/json: + schema: + $ref: "#/components/schemas/Workflow" + /process-instances/{modified_process_model_identifier}/{process_instance_id}: parameters: - name: modified_process_model_identifier diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/testing.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/testing.yml index 79a137104..d3edf0a8a 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/testing.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/testing.yml @@ -30,6 +30,12 @@ permissions: allowed_permissions: [read] uri: /* + process-instances-find-by-id: + groups: [everybody] + users: [] + allowed_permissions: [read] + uri: /process-instances/find-by-id/* + tasks-crud: groups: [everybody] users: [] diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model.py index c737b274b..5e0ba6ca0 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model.py @@ -58,6 +58,14 @@ class ProcessModelInfo: """Id_for_file_path.""" return self.id.replace("/", os.sep) + @classmethod + def modify_process_identifier_for_path_param(cls, identifier: str) -> str: + """Identifier.""" + if "\\" in identifier: + raise Exception(f"Found backslash in identifier: {identifier}") + + return identifier.replace("/", ":") + class ProcessModelInfoSchema(Schema): """ProcessModelInfoSchema.""" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py index ed27f2b24..5fbaecdf4 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py @@ -31,6 +31,7 @@ from spiffworkflow_backend.models.process_instance_metadata import ( from spiffworkflow_backend.models.process_instance_report import ( ProcessInstanceReportModel, ) +from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.spec_reference import SpecReferenceCache from spiffworkflow_backend.models.spec_reference import SpecReferenceNotFoundError from spiffworkflow_backend.models.spiff_logging import SpiffLoggingModel @@ -43,6 +44,7 @@ from spiffworkflow_backend.routes.process_api_blueprint import _get_process_mode from spiffworkflow_backend.routes.process_api_blueprint import ( _un_modify_modified_process_model_id, ) +from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService from spiffworkflow_backend.services.git_service import GitCommandError from spiffworkflow_backend.services.git_service import GitService @@ -88,9 +90,7 @@ def process_instance_run( do_engine_steps: bool = True, ) -> flask.wrappers.Response: """Process_instance_run.""" - process_instance = ProcessInstanceService().get_process_instance( - process_instance_id - ) + process_instance = _find_process_instance_by_id_or_raise(process_instance_id) if process_instance.status != "not_started": raise ApiError( error_code="process_instance_not_runnable", @@ -138,9 +138,7 @@ def process_instance_terminate( modified_process_model_identifier: str, ) -> flask.wrappers.Response: """Process_instance_run.""" - process_instance = ProcessInstanceService().get_process_instance( - process_instance_id - ) + process_instance = _find_process_instance_by_id_or_raise(process_instance_id) processor = ProcessInstanceProcessor(process_instance) processor.terminate() return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") @@ -151,9 +149,7 @@ def process_instance_suspend( modified_process_model_identifier: str, ) -> flask.wrappers.Response: """Process_instance_suspend.""" - process_instance = ProcessInstanceService().get_process_instance( - process_instance_id - ) + process_instance = _find_process_instance_by_id_or_raise(process_instance_id) processor = ProcessInstanceProcessor(process_instance) processor.suspend() return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") @@ -164,9 +160,7 @@ def process_instance_resume( modified_process_model_identifier: str, ) -> flask.wrappers.Response: """Process_instance_resume.""" - process_instance = ProcessInstanceService().get_process_instance( - process_instance_id - ) + process_instance = _find_process_instance_by_id_or_raise(process_instance_id) processor = ProcessInstanceProcessor(process_instance) processor.resume() return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") @@ -575,14 +569,43 @@ def process_instance_reset( spiff_step: int = 0, ) -> flask.wrappers.Response: """Reset a process instance to a particular step.""" - process_instance = ProcessInstanceService().get_process_instance( - process_instance_id - ) + process_instance = _find_process_instance_by_id_or_raise(process_instance_id) processor = ProcessInstanceProcessor(process_instance) processor.reset_process(spiff_step) return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") +def process_instance_find_by_id( + process_instance_id: int, +) -> flask.wrappers.Response: + """Process_instance_find_by_id.""" + process_instance = _find_process_instance_by_id_or_raise(process_instance_id) + modified_process_model_identifier = ( + ProcessModelInfo.modify_process_identifier_for_path_param( + process_instance.process_model_identifier + ) + ) + process_instance_uri = ( + f"/process-instances/{modified_process_model_identifier}/{process_instance.id}" + ) + has_permission = AuthorizationService.user_has_permission( + user=g.user, + permission="read", + target_uri=process_instance_uri, + ) + + uri_type = None + if not has_permission: + process_instance = _find_process_instance_for_me_or_raise(process_instance_id) + uri_type = "for-me" + + response_json = { + "process_instance": process_instance, + "uri_type": uri_type, + } + return make_response(jsonify(response_json), 200) + + def _get_process_instance( modified_process_model_identifier: str, process_instance: ProcessInstanceModel, diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py index 69d19cb7f..9abe25970 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py @@ -624,6 +624,84 @@ class AuthorizationService: return permissions_to_assign + @classmethod + def set_basic_permissions(cls) -> list[PermissionToAssign]: + """Set_basic_permissions.""" + permissions_to_assign: list[PermissionToAssign] = [] + permissions_to_assign.append( + PermissionToAssign( + permission="read", target_uri="/process-instances/for-me" + ) + ) + permissions_to_assign.append( + PermissionToAssign(permission="read", target_uri="/processes") + ) + permissions_to_assign.append( + PermissionToAssign(permission="read", target_uri="/service-tasks") + ) + permissions_to_assign.append( + PermissionToAssign( + permission="read", target_uri="/user-groups/for-current-user" + ) + ) + permissions_to_assign.append( + PermissionToAssign( + permission="read", target_uri="/process-instances/find-by-id/*" + ) + ) + + for permission in ["create", "read", "update", "delete"]: + permissions_to_assign.append( + PermissionToAssign( + permission=permission, target_uri="/process-instances/reports/*" + ) + ) + permissions_to_assign.append( + PermissionToAssign(permission=permission, target_uri="/tasks/*") + ) + return permissions_to_assign + + @classmethod + def set_process_group_permissions( + cls, target: str, permission_set: str + ) -> list[PermissionToAssign]: + """Set_process_group_permissions.""" + permissions_to_assign: list[PermissionToAssign] = [] + process_group_identifier = ( + target.removeprefix("PG:").replace("/", ":").removeprefix(":") + ) + process_related_path_segment = f"{process_group_identifier}:*" + if process_group_identifier == "ALL": + process_related_path_segment = "*" + target_uris = [ + f"/process-groups/{process_related_path_segment}", + f"/process-models/{process_related_path_segment}", + ] + permissions_to_assign = permissions_to_assign + cls.get_permissions_to_assign( + permission_set, process_related_path_segment, target_uris + ) + return permissions_to_assign + + @classmethod + def set_process_model_permissions( + cls, target: str, permission_set: str + ) -> list[PermissionToAssign]: + """Set_process_model_permissions.""" + permissions_to_assign: list[PermissionToAssign] = [] + process_model_identifier = ( + target.removeprefix("PM:").replace("/", ":").removeprefix(":") + ) + process_related_path_segment = f"{process_model_identifier}/*" + + if process_model_identifier == "ALL": + process_related_path_segment = "*" + + target_uris = [f"/process-models/{process_related_path_segment}"] + permissions_to_assign = permissions_to_assign + cls.get_permissions_to_assign( + permission_set, process_related_path_segment, target_uris + ) + return permissions_to_assign + @classmethod def explode_permissions( cls, permission_set: str, target: str @@ -654,72 +732,20 @@ class AuthorizationService: permissions = ["create", "read", "update", "delete"] if target.startswith("PG:"): - process_group_identifier = ( - target.removeprefix("PG:").replace("/", ":").removeprefix(":") + permissions_to_assign += cls.set_process_group_permissions( + target, permission_set ) - process_related_path_segment = f"{process_group_identifier}:*" - if process_group_identifier == "ALL": - process_related_path_segment = "*" - target_uris = [ - f"/process-groups/{process_related_path_segment}", - f"/process-models/{process_related_path_segment}", - ] - permissions_to_assign = ( - permissions_to_assign - + cls.get_permissions_to_assign( - permission_set, process_related_path_segment, target_uris - ) - ) - elif target.startswith("PM:"): - process_model_identifier = ( - target.removeprefix("PM:").replace("/", ":").removeprefix(":") + permissions_to_assign += cls.set_process_model_permissions( + target, permission_set ) - process_related_path_segment = f"{process_model_identifier}/*" - - if process_model_identifier == "ALL": - process_related_path_segment = "*" - - target_uris = [f"/process-models/{process_related_path_segment}"] - permissions_to_assign = ( - permissions_to_assign - + cls.get_permissions_to_assign( - permission_set, process_related_path_segment, target_uris - ) - ) - elif permission_set == "start": raise InvalidPermissionError( "Permission 'start' is only available for macros PM and PG." ) elif target.startswith("BASIC"): - permissions_to_assign.append( - PermissionToAssign( - permission="read", target_uri="/process-instances/for-me" - ) - ) - permissions_to_assign.append( - PermissionToAssign(permission="read", target_uri="/processes") - ) - permissions_to_assign.append( - PermissionToAssign(permission="read", target_uri="/service-tasks") - ) - permissions_to_assign.append( - PermissionToAssign( - permission="read", target_uri="/user-groups/for-current-user" - ) - ) - - for permission in ["create", "read", "update", "delete"]: - permissions_to_assign.append( - PermissionToAssign( - permission=permission, target_uri="/process-instances/reports/*" - ) - ) - permissions_to_assign.append( - PermissionToAssign(permission=permission, target_uri="/tasks/*") - ) + permissions_to_assign += cls.set_basic_permissions() elif target == "ALL": for permission in permissions: permissions_to_assign.append( diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py b/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py index 47cf2d876..df62f5be1 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py @@ -354,11 +354,8 @@ class BaseTest: assert has_permission is expected_result def modify_process_identifier_for_path_param(self, identifier: str) -> str: - """Identifier.""" - if "\\" in identifier: - raise Exception(f"Found backslash in identifier: {identifier}") - - return identifier.replace("/", ":") + """Modify_process_identifier_for_path_param.""" + return ProcessModelInfo.modify_process_identifier_for_path_param(identifier) def un_modify_modified_process_identifier_for_path_param( self, modified_identifier: str diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_instances_controller.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_instances_controller.py new file mode 100644 index 000000000..e9c403a4e --- /dev/null +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_instances_controller.py @@ -0,0 +1,61 @@ +"""Test_users_controller.""" +from flask.app import Flask +from flask.testing import FlaskClient +from tests.spiffworkflow_backend.helpers.base_test import BaseTest +from tests.spiffworkflow_backend.helpers.test_data import load_test_spec + +from spiffworkflow_backend.models.user import UserModel + + +class TestProcessInstancesController(BaseTest): + """TestProcessInstancesController.""" + + def test_find_by_id( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + """Test_user_search_returns_a_user.""" + user_one = self.create_user_with_permission( + username="user_one", target_uri="/process-instances/find-by-id/*" + ) + user_two = self.create_user_with_permission( + username="user_two", target_uri="/process-instances/find-by-id/*" + ) + + process_model = load_test_spec( + process_model_id="group/sample", + bpmn_file_name="sample.bpmn", + process_model_source_directory="sample", + ) + process_instance = self.create_process_instance_from_process_model( + process_model=process_model, user=user_one + ) + + response = client.get( + f"/v1.0/process-instances/find-by-id/{process_instance.id}", + headers=self.logged_in_headers(user_one), + ) + assert response.status_code == 200 + assert response.json + assert 'process_instance' in response.json + assert response.json['process_instance']["id"] == process_instance.id + assert response.json['uri_type'] == 'for-me' + + response = client.get( + f"/v1.0/process-instances/find-by-id/{process_instance.id}", + headers=self.logged_in_headers(user_two), + ) + assert response.status_code == 400 + + response = client.get( + f"/v1.0/process-instances/find-by-id/{process_instance.id}", + headers=self.logged_in_headers(with_super_admin_user), + ) + assert response.status_code == 200 + assert response.json + assert 'process_instance' in response.json + assert response.json['process_instance']["id"] == process_instance.id + assert response.json['uri_type'] is None diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py index 83ed7fd8e..9e7af5d0f 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py @@ -277,6 +277,7 @@ class TestAuthorizationService(BaseTest): ) -> None: """Test_explode_permissions_basic.""" expected_permissions = [ + ("/process-instances/find-by-id/*", "read"), ("/process-instances/for-me", "read"), ("/process-instances/reports/*", "create"), ("/process-instances/reports/*", "delete"), diff --git a/spiffworkflow-frontend/src/helpers.test.tsx b/spiffworkflow-frontend/src/helpers.test.tsx index 660f65f67..5a7889a65 100644 --- a/spiffworkflow-frontend/src/helpers.test.tsx +++ b/spiffworkflow-frontend/src/helpers.test.tsx @@ -1,5 +1,6 @@ import { convertSecondsToFormattedDateString, + isInteger, slugifyString, underscorizeString, } from './helpers'; @@ -20,3 +21,11 @@ test('it can keep the correct date when converting seconds to date', () => { const dateString = convertSecondsToFormattedDateString(1666325400); expect(dateString).toEqual('2022-10-21'); }); + +test('it can validate numeric values', () => { + expect(isInteger('11')).toEqual(true); + expect(isInteger('hey')).toEqual(false); + expect(isInteger(' ')).toEqual(false); + expect(isInteger('1 2')).toEqual(false); + expect(isInteger(2)).toEqual(true); +}); diff --git a/spiffworkflow-frontend/src/helpers.tsx b/spiffworkflow-frontend/src/helpers.tsx index d91f05439..b3edd7766 100644 --- a/spiffworkflow-frontend/src/helpers.tsx +++ b/spiffworkflow-frontend/src/helpers.tsx @@ -253,3 +253,7 @@ export const setErrorMessageSafely = ( errorMessageSetter({ message: newErrorMessageString }); return null; }; + +export const isInteger = (str: string | number) => { + return /^\d+$/.test(str.toString()); +}; diff --git a/spiffworkflow-frontend/src/routes/AdminRoutes.tsx b/spiffworkflow-frontend/src/routes/AdminRoutes.tsx index 2d61439bf..8d21a5b9d 100644 --- a/spiffworkflow-frontend/src/routes/AdminRoutes.tsx +++ b/spiffworkflow-frontend/src/routes/AdminRoutes.tsx @@ -23,6 +23,7 @@ import MessageInstanceList from './MessageInstanceList'; import Configuration from './Configuration'; import JsonSchemaFormBuilder from './JsonSchemaFormBuilder'; import ProcessModelNewExperimental from './ProcessModelNewExperimental'; +import ProcessInstanceFindById from './ProcessInstanceFindById'; export default function AdminRoutes() { const location = useLocation(); @@ -133,6 +134,10 @@ export default function AdminRoutes() { path="process-models/:process_model_id/form-builder" element={} /> + } + /> ); } diff --git a/spiffworkflow-frontend/src/routes/ProcessInstanceFindById.tsx b/spiffworkflow-frontend/src/routes/ProcessInstanceFindById.tsx new file mode 100644 index 000000000..e55520ef6 --- /dev/null +++ b/spiffworkflow-frontend/src/routes/ProcessInstanceFindById.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +// @ts-ignore +import { Button, ButtonSet, Form, Stack, TextInput } from '@carbon/react'; +import { isInteger, modifyProcessIdentifierForPathParam } from '../helpers'; +import HttpService from '../services/HttpService'; +import { ProcessInstance } from '../interfaces'; + +export default function ProcessInstanceFindById() { + const navigate = useNavigate(); + const [processInstanceId, setProcessInstanceId] = useState(''); + const [processInstanceIdValid, setProcessInstanceIdValid] = + useState(true); + + useEffect(() => {}, []); + + const handleProcessInstanceNavigation = (result: any) => { + const processInstance: ProcessInstance = result.process_instance; + let path = '/admin/process-instances/'; + if (result.uri_type === 'for-me') { + path += 'for-me/'; + } + path += `${modifyProcessIdentifierForPathParam( + processInstance.process_model_identifier + )}/${processInstance.id}`; + navigate(path); + }; + + const handleFormSubmission = (event: any) => { + event.preventDefault(); + + if (!processInstanceId) { + setProcessInstanceIdValid(false); + } + + if (processInstanceId && processInstanceIdValid) { + HttpService.makeCallToBackend({ + path: `/process-instances/find-by-id/${processInstanceId}`, + successCallback: handleProcessInstanceNavigation, + }); + } + }; + + const handleProcessInstanceIdChange = (event: any) => { + if (isInteger(event.target.value)) { + setProcessInstanceIdValid(true); + } else { + setProcessInstanceIdValid(false); + } + setProcessInstanceId(event.target.value); + }; + + const formElements = () => { + return ( + + ); + }; + + const formButtons = () => { + const buttons = []; + return {buttons}; + }; + + return ( +
+ + {formElements()} + {formButtons()} + +
+ ); +} diff --git a/spiffworkflow-frontend/src/routes/ProcessInstanceList.tsx b/spiffworkflow-frontend/src/routes/ProcessInstanceList.tsx index a18f48c80..33af0c1fb 100644 --- a/spiffworkflow-frontend/src/routes/ProcessInstanceList.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessInstanceList.tsx @@ -85,6 +85,15 @@ export default function ProcessInstanceList({ variant }: OwnProps) { All + { + navigate('/admin/process-instances/find-by-id'); + }} + > + Find By Id +
diff --git a/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx b/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx index 9c1e4bef5..a46d02a25 100644 --- a/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx @@ -100,7 +100,7 @@ export default function ProcessModelShow() { onClose={() => setProcessInstance(null)} > view