From 9bb9ce47f85d4e8b804c05e6eb406015a5f2b9f7 Mon Sep 17 00:00:00 2001 From: jasquat <2487833+jasquat@users.noreply.github.com> Date: Thu, 7 Sep 2023 10:10:44 -0400 Subject: [PATCH] Feature/business end states (#333) * WIP: some updates to support new spiff w/ burnettk * unit tests are passing * all tests except message tests are passing * fixed usage of catch message event w/ burnettk * messages are working again w/ burnettk * uncommented remaining message tests w/ burnettk * fixed cypress tests w/ burnettk * use main for spiffworkflow * translated mysql last milestone query to sqlalchemy w/ burnettk * fixed last milestone query so instances still return if no milestone found and moved some code from the main report method to own methods * added some comments * added last milestone column to process instances table * display last milestone in instance list table w/ burnettk * remove 3 characters when truncating last milestone for ellipsis * make sure we have a current processor so we don't return null * remove sleep * The background processor now only picks up processes that were last updated more than a minute ago to avoid conflicting with the interstitial page. With the understanding that we can rmeove this limitation when we can refactor to allow the backend processes to provide updates on what they are doing. * pyl w/ burnettk * cache last milestone on instances * pyl * added test for last milestone and added it to the proces instance show page w/ burnettk * fixed broken test w/ burnettk * fixed last milestone header * removed duplicated column * fixed broken test --------- Co-authored-by: jasquat Co-authored-by: Kevin Burnett <18027+burnettk@users.noreply.github.com> Co-authored-by: danfunk Co-authored-by: burnettk --- .../docker_image_for_main_builds.yml | 1 + bin/run_pyl | 1 + .../bin/last_pi_milestone_query | 4 +- .../migrations/versions/f04cbd9f43ec_.py | 32 +++++++ .../models/process_instance.py | 20 ++-- .../routes/tasks_controller.py | 4 +- .../process_instance_report_service.py | 95 ++++++++++++------- .../services/process_instance_service.py | 1 - .../services/process_model_service.py | 6 +- .../services/workflow_execution_service.py | 11 +++ .../test_last_milestone.bpmn | 62 ++++++++++++ .../test_last_milestone_call_activity.bpmn | 56 +++++++++++ .../helpers/base_test.py | 7 ++ .../integration/test_process_api.py | 4 + .../unit/test_workflow_execution_service.py | 30 ++++++ .../components/ProcessInstanceListTable.tsx | 14 ++- .../src/components/ProcessInstanceLogList.tsx | 4 +- spiffworkflow-frontend/src/helpers.tsx | 28 ++++++ spiffworkflow-frontend/src/index.css | 2 +- spiffworkflow-frontend/src/interfaces.ts | 1 + .../src/routes/ProcessInstanceShow.tsx | 15 ++- 21 files changed, 348 insertions(+), 50 deletions(-) create mode 100644 spiffworkflow-backend/migrations/versions/f04cbd9f43ec_.py create mode 100644 spiffworkflow-backend/tests/data/test-last-milestone/test_last_milestone.bpmn create mode 100644 spiffworkflow-backend/tests/data/test-last-milestone/test_last_milestone_call_activity.bpmn create mode 100644 spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_workflow_execution_service.py diff --git a/.github/workflows/docker_image_for_main_builds.yml b/.github/workflows/docker_image_for_main_builds.yml index 6e784e06..8bfeb235 100644 --- a/.github/workflows/docker_image_for_main_builds.yml +++ b/.github/workflows/docker_image_for_main_builds.yml @@ -34,6 +34,7 @@ on: - feature/event-payloads-part-2 - feature/event-payload-migration-fix - spiffdemo + - feature/business_end_states - feature/allow-markdown-in-extension-results jobs: diff --git a/bin/run_pyl b/bin/run_pyl index dfec03de..de065c4e 100755 --- a/bin/run_pyl +++ b/bin/run_pyl @@ -39,6 +39,7 @@ function run_autofixers() { fi python_dirs="$(get_python_dirs) bin" + # shellcheck disable=2086 ruff --fix $python_dirs || echo '' } diff --git a/spiffworkflow-backend/bin/last_pi_milestone_query b/spiffworkflow-backend/bin/last_pi_milestone_query index 89841fdd..0d7695a9 100755 --- a/spiffworkflow-backend/bin/last_pi_milestone_query +++ b/spiffworkflow-backend/bin/last_pi_milestone_query @@ -8,7 +8,7 @@ trap 'error_handler ${LINENO} $?' ERR set -o errtrace -o errexit -o nounset -o pipefail mysql -uroot spiffworkflow_backend_local_development -e ' - SELECT td.bpmn_identifier FROM process_instance p + SELECT td.bpmn_identifier, p.id FROM process_instance p JOIN process_instance_event pie ON pie.process_instance_id = p.id JOIN task t ON t.guid = pie.task_guid @@ -27,6 +27,6 @@ mysql -uroot spiffworkflow_backend_local_development -e ' GROUP BY pie.process_instance_id ) AS max_pie ON max_pie.max_pie_id = pie.id - WHERE pie.process_instance_id = 27 + # WHERE pie.process_instance_id = 27 ; ' diff --git a/spiffworkflow-backend/migrations/versions/f04cbd9f43ec_.py b/spiffworkflow-backend/migrations/versions/f04cbd9f43ec_.py new file mode 100644 index 00000000..36b7d52a --- /dev/null +++ b/spiffworkflow-backend/migrations/versions/f04cbd9f43ec_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: f04cbd9f43ec +Revises: 1073364bc015 +Create Date: 2023-08-23 11:05:04.368165 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f04cbd9f43ec' +down_revision = '1073364bc015' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('process_instance', schema=None) as batch_op: + batch_op.add_column(sa.Column('last_milestone_bpmn_name', sa.String(length=255), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('process_instance', schema=None) as batch_op: + batch_op.drop_column('last_milestone_bpmn_name') + + # ### end Alembic commands ### diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py index bbe2abcf..7f7f3691 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py @@ -95,6 +95,7 @@ class ProcessInstanceModel(SpiffworkflowBaseDBModel): bpmn_version_control_type: str = db.Column(db.String(50)) bpmn_version_control_identifier: str = db.Column(db.String(255)) + last_milestone_bpmn_name: str = db.Column(db.String(255)) bpmn_xml_file_contents: str | None = None process_model_with_diagram_identifier: str | None = None @@ -106,19 +107,20 @@ class ProcessInstanceModel(SpiffworkflowBaseDBModel): """Return object data in serializeable format.""" return { "id": self.id, - "process_model_identifier": self.process_model_identifier, - "process_model_display_name": self.process_model_display_name, - "status": self.status, - "start_in_seconds": self.start_in_seconds, - "end_in_seconds": self.end_in_seconds, - "created_at_in_seconds": self.created_at_in_seconds, - "updated_at_in_seconds": self.updated_at_in_seconds, - "process_initiator_id": self.process_initiator_id, - "bpmn_xml_file_contents": self.bpmn_xml_file_contents, "bpmn_version_control_identifier": self.bpmn_version_control_identifier, "bpmn_version_control_type": self.bpmn_version_control_type, + "bpmn_xml_file_contents": self.bpmn_xml_file_contents, + "created_at_in_seconds": self.created_at_in_seconds, + "end_in_seconds": self.end_in_seconds, + "last_milestone_bpmn_name": self.last_milestone_bpmn_name, + "process_initiator_id": self.process_initiator_id, "process_initiator_username": self.process_initiator.username, + "process_model_display_name": self.process_model_display_name, + "process_model_identifier": self.process_model_identifier, + "start_in_seconds": self.start_in_seconds, + "status": self.status, "task_updated_at_in_seconds": self.task_updated_at_in_seconds, + "updated_at_in_seconds": self.updated_at_in_seconds, } def serialized_with_metadata(self) -> dict[str, Any]: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py index 90a4689c..82128de4 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py @@ -203,7 +203,9 @@ def task_data_update( if json_data_dict is not None: JsonDataModel.insert_or_update_json_data_records({json_data_dict["hash"]: json_data_dict}) ProcessInstanceTmpService.add_event_to_process_instance( - process_instance, ProcessInstanceEventType.task_data_edited.value, task_guid=task_guid + process_instance, + ProcessInstanceEventType.task_data_edited.value, + task_guid=task_guid, ) try: db.session.commit() diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py index 00872fdc..94c32d0c 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py @@ -5,6 +5,7 @@ from typing import Any import sqlalchemy from flask import current_app +from flask_sqlalchemy.query import Query from spiffworkflow_backend.exceptions.api_error import ApiError from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel from spiffworkflow_backend.models.group import GroupModel @@ -16,6 +17,7 @@ from spiffworkflow_backend.models.process_instance_report import FilterValue from spiffworkflow_backend.models.process_instance_report import ProcessInstanceReportModel from spiffworkflow_backend.models.process_instance_report import ReportMetadata from spiffworkflow_backend.models.process_instance_report import ReportMetadataColumn +from spiffworkflow_backend.models.task import TaskModel # noqa: F401 from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user_group_assignment import UserGroupAssignmentModel from spiffworkflow_backend.services.process_model_service import ProcessModelService @@ -61,6 +63,7 @@ class ProcessInstanceReportService: }, {"Header": "Start time", "accessor": "start_in_seconds", "filterable": False}, {"Header": "End time", "accessor": "end_in_seconds", "filterable": False}, + {"Header": "Last milestone", "accessor": "last_milestone_bpmn_name", "filterable": False}, {"Header": "Status", "accessor": "status", "filterable": False}, ], "filter_by": [ @@ -96,6 +99,7 @@ class ProcessInstanceReportService: {"Header": "Waiting for", "accessor": "waiting_for", "filterable": False}, {"Header": "Started", "accessor": "start_in_seconds", "filterable": False}, {"Header": "Last updated", "accessor": "task_updated_at_in_seconds", "filterable": False}, + {"Header": "Last milestone", "accessor": "last_milestone_bpmn_name", "filterable": False}, {"Header": "Status", "accessor": "status", "filterable": False}, ], "filter_by": [ @@ -121,6 +125,7 @@ class ProcessInstanceReportService: {"Header": "Started by", "accessor": "process_initiator_username", "filterable": False}, {"Header": "Started", "accessor": "start_in_seconds", "filterable": False}, {"Header": "Last updated", "accessor": "task_updated_at_in_seconds", "filterable": False}, + {"Header": "Last milestone", "accessor": "last_milestone_bpmn_name", "filterable": False}, ], "filter_by": [ {"field_name": "instances_with_tasks_waiting_for_me", "field_value": True, "operator": "equals"}, @@ -145,6 +150,7 @@ class ProcessInstanceReportService: {"Header": "Started by", "accessor": "process_initiator_username", "filterable": False}, {"Header": "Started", "accessor": "start_in_seconds", "filterable": False}, {"Header": "Last updated", "accessor": "task_updated_at_in_seconds", "filterable": False}, + {"Header": "Last milestone", "accessor": "last_milestone_bpmn_name", "filterable": False}, ], "filter_by": [ {"field_name": "process_status", "field_value": active_status_values, "operator": "equals"}, @@ -244,6 +250,11 @@ class ProcessInstanceReportService: metadata_column["accessor"] ] + if "last_milestone_bpmn_name" in process_instance_mapping: + process_instance_dict["last_milestone_bpmn_name"] = process_instance_mapping[ + "last_milestone_bpmn_name" + ] + results.append(process_instance_dict) return results @@ -315,7 +326,7 @@ class ProcessInstanceReportService: @classmethod def non_metadata_columns(cls) -> list[str]: - return cls.process_instance_stock_columns() + ["process_initiator_username"] + return cls.process_instance_stock_columns() + ["process_initiator_username", "last_milestone_bpmn_name"] @classmethod def builtin_column_options(cls) -> list[ReportMetadataColumn]: @@ -334,6 +345,7 @@ class ProcessInstanceReportService: "accessor": "process_initiator_username", "filterable": False, }, + {"Header": "Last milestone", "accessor": "last_milestone_bpmn_name", "filterable": False}, {"Header": "Status", "accessor": "status", "filterable": False}, ] return return_value @@ -369,6 +381,49 @@ class ProcessInstanceReportService: if filter_found is False: filters.append(new_filter) + @classmethod + def filter_by_user_group_identifier( + cls, + process_instance_query: Query, + user_group_identifier: str, + user: UserModel, + human_task_already_joined: bool | None = False, + process_status: str | None = None, + instances_with_tasks_waiting_for_me: bool | None = False, + ) -> Query: + group_model_join_conditions = [GroupModel.id == HumanTaskModel.lane_assignment_id] + if user_group_identifier: + group_model_join_conditions.append(GroupModel.identifier == user_group_identifier) + + if human_task_already_joined is False: + process_instance_query = process_instance_query.join(HumanTaskModel) # type: ignore + if process_status is not None: + non_active_statuses = [ + s for s in process_status.split(",") if s not in ProcessInstanceModel.active_statuses() + ] + if len(non_active_statuses) == 0: + process_instance_query = process_instance_query.filter( + HumanTaskModel.completed.is_(False) # type: ignore + ) + # Check to make sure the task is not only available for the group but the user as well + if instances_with_tasks_waiting_for_me is not True: + human_task_user_alias = aliased(HumanTaskUserModel) + process_instance_query = process_instance_query.join( # type: ignore + human_task_user_alias, + and_( + human_task_user_alias.human_task_id == HumanTaskModel.id, + human_task_user_alias.user_id == user.id, + ), + ) + + process_instance_query = process_instance_query.join(GroupModel, and_(*group_model_join_conditions)) # type: ignore + process_instance_query = process_instance_query.join( # type: ignore + UserGroupAssignmentModel, + UserGroupAssignmentModel.group_id == GroupModel.id, + ) + process_instance_query = process_instance_query.filter(UserGroupAssignmentModel.user_id == user.id) + return process_instance_query + @classmethod def run_process_instance_report( cls, @@ -513,38 +568,14 @@ class ProcessInstanceReportService: raise ProcessInstanceReportCannotBeRunError( "A user must be specified to run report with a group identifier." ) - group_model_join_conditions = [GroupModel.id == HumanTaskModel.lane_assignment_id] - if user_group_identifier: - group_model_join_conditions.append(GroupModel.identifier == user_group_identifier) - - if human_task_already_joined is False: - process_instance_query = process_instance_query.join(HumanTaskModel) - if process_status is not None: - non_active_statuses = [ - s for s in process_status.split(",") if s not in ProcessInstanceModel.active_statuses() - ] - if len(non_active_statuses) == 0: - process_instance_query = process_instance_query.filter( - HumanTaskModel.completed.is_(False) # type: ignore - ) - - # Check to make sure the task is not only available for the group but the user as well - if instances_with_tasks_waiting_for_me is not True: - human_task_user_alias = aliased(HumanTaskUserModel) - process_instance_query = process_instance_query.join( - human_task_user_alias, - and_( - human_task_user_alias.human_task_id == HumanTaskModel.id, - human_task_user_alias.user_id == user.id, - ), - ) - - process_instance_query = process_instance_query.join(GroupModel, and_(*group_model_join_conditions)) - process_instance_query = process_instance_query.join( - UserGroupAssignmentModel, - UserGroupAssignmentModel.group_id == GroupModel.id, + process_instance_query = cls.filter_by_user_group_identifier( + process_instance_query=process_instance_query, + user_group_identifier=user_group_identifier, + user=user, + human_task_already_joined=human_task_already_joined, + process_status=process_status, + instances_with_tasks_waiting_for_me=instances_with_tasks_waiting_for_me, ) - process_instance_query = process_instance_query.filter(UserGroupAssignmentModel.user_id == user.id) instance_metadata_aliases = {} if report_metadata["columns"] is None or len(report_metadata["columns"]) < 1: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py index 4506d1d5..12069947 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py @@ -221,7 +221,6 @@ class ProcessInstanceService: def do_waiting(cls, status_value: str) -> None: run_at_in_seconds_threshold = round(time.time()) min_age_in_seconds = 60 # to avoid conflicts with the interstitial page, we wait 60 seconds before processing - # min_age_in_seconds = 0 # to avoid conflicts with the interstitial page, we wait 60 seconds before processing process_instance_ids_to_check = ProcessInstanceQueueService.peek_many( status_value, run_at_in_seconds_threshold, min_age_in_seconds ) 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 9700249f..6da860d7 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_service.py @@ -279,7 +279,11 @@ class ProcessModelService(FileSystemService): full_group_id_path = os.path.join(full_group_id_path, process_group_id_segment) # type: ignore parent_group = process_group_cache.get(full_group_id_path, None) if parent_group is None: - parent_group = ProcessModelService.get_process_group(full_group_id_path) + try: + parent_group = ProcessModelService.get_process_group(full_group_id_path) + except ProcessEntityNotFoundError: + # if parent_group can no longer be found then do not add it to the cache + parent_group = None if parent_group: if full_group_id_path not in process_group_cache: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/workflow_execution_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/workflow_execution_service.py index 41e4ef13..83f38f37 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/workflow_execution_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/workflow_execution_service.py @@ -160,6 +160,17 @@ class TaskModelSavingDelegate(EngineStepDelegate): # # self._add_parents(spiff_task) self.last_completed_spiff_task = spiff_task + if ( + spiff_task.task_spec.__class__.__name__ in ["StartEvent", "EndEvent", "IntermediateThrowEvent"] + and spiff_task.task_spec.bpmn_name is not None + ): + self.process_instance.last_milestone_bpmn_name = spiff_task.task_spec.bpmn_name + elif spiff_task.workflow.parent_task_id is None: + # if parent_task_id is None then this should be the top level process + if spiff_task.task_spec.__class__.__name__ == "EndEvent": + self.process_instance.last_milestone_bpmn_name = "Completed" + elif spiff_task.task_spec.__class__.__name__ == "StartEvent": + self.process_instance.last_milestone_bpmn_name = "Started" self.process_instance.task_updated_at_in_seconds = round(time.time()) if self.secondary_engine_step_delegate: self.secondary_engine_step_delegate.did_complete_task(spiff_task) diff --git a/spiffworkflow-backend/tests/data/test-last-milestone/test_last_milestone.bpmn b/spiffworkflow-backend/tests/data/test-last-milestone/test_last_milestone.bpmn new file mode 100644 index 00000000..7acffef3 --- /dev/null +++ b/spiffworkflow-backend/tests/data/test-last-milestone/test_last_milestone.bpmn @@ -0,0 +1,62 @@ + + + + + Flow_088o54g + + + + Flow_062tiay + + + + Flow_088o54g + Flow_06ih0jm + + + + Flow_06ih0jm + Flow_0c0z9vd + + + + Flow_0c0z9vd + Flow_062tiay + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spiffworkflow-backend/tests/data/test-last-milestone/test_last_milestone_call_activity.bpmn b/spiffworkflow-backend/tests/data/test-last-milestone/test_last_milestone_call_activity.bpmn new file mode 100644 index 00000000..d70a54f5 --- /dev/null +++ b/spiffworkflow-backend/tests/data/test-last-milestone/test_last_milestone_call_activity.bpmn @@ -0,0 +1,56 @@ + + + + + Flow_0vc53yr + + + + Flow_0vc53yr + Flow_1o2uee3 + + + + Flow_1o2uee3 + Flow_0jcfams + + + Flow_0jcfams + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py b/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py index 67aaa705..523cdbfc 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py @@ -501,6 +501,13 @@ class BaseTest: db.session.delete(process_instance_report) db.session.commit() + def complete_next_manual_task(self, processor: ProcessInstanceProcessor) -> None: + user_task = processor.get_ready_user_tasks()[0] + human_task = processor.process_instance_model.human_tasks[0] + ProcessInstanceService.complete_form_task( + processor, user_task, {}, processor.process_instance_model.process_initiator, human_task + ) + @contextmanager def app_config_mock(self, app: Flask, config_identifier: str, new_config_value: Any) -> Generator: initial_value = app.config[config_identifier] diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py index 459d1073..225d9719 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py @@ -202,6 +202,7 @@ class TestProcessApi(BaseTest): "start_in_seconds", "end_in_seconds", "process_initiator_username", + "last_milestone_bpmn_name", "status", "summary", "description", @@ -2844,6 +2845,7 @@ class TestProcessApi(BaseTest): assert response.json["results"][0]["id"] == process_instance.id assert response.json["results"][0]["key1"] == "value1" assert response.json["results"][0]["key2"] == "value2" + assert response.json["results"][0]["last_milestone_bpmn_name"] == "Completed" assert response.json["pagination"]["count"] == 1 assert response.json["pagination"]["pages"] == 1 assert response.json["pagination"]["total"] == 1 @@ -3037,6 +3039,7 @@ class TestProcessApi(BaseTest): "accessor": "process_initiator_username", "filterable": False, }, + {"Header": "Last milestone", "accessor": "last_milestone_bpmn_name", "filterable": False}, {"Header": "Status", "accessor": "status", "filterable": False}, {"Header": "Task", "accessor": "task_title", "filterable": False}, {"Header": "Waiting for", "accessor": "waiting_for", "filterable": False}, @@ -3055,6 +3058,7 @@ class TestProcessApi(BaseTest): "start_in_seconds", "end_in_seconds", "process_initiator_username", + "last_milestone_bpmn_name", "status", "task_title", "waiting_for", diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_workflow_execution_service.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_workflow_execution_service.py new file mode 100644 index 00000000..19d5a26e --- /dev/null +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_workflow_execution_service.py @@ -0,0 +1,30 @@ +from flask import Flask +from spiffworkflow_backend.models.task import TaskModel # noqa: F401 +from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor + +from tests.spiffworkflow_backend.helpers.base_test import BaseTest +from tests.spiffworkflow_backend.helpers.test_data import load_test_spec + + +class TestWorkflowExecutionService(BaseTest): + def test_saves_last_milestone_appropriately( + self, + app: Flask, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + process_model = load_test_spec( + "test_group/test-last-milestone", + process_model_source_directory="test-last-milestone", + ) + process_instance = self.create_process_instance_from_process_model(process_model) + processor = ProcessInstanceProcessor(process_instance) + processor.do_engine_steps(save=True, execution_strategy_name="greedy") + assert process_instance.last_milestone_bpmn_name == "Started" + + self.complete_next_manual_task(processor) + assert process_instance.last_milestone_bpmn_name == "In Call Activity" + self.complete_next_manual_task(processor) + assert process_instance.last_milestone_bpmn_name == "Done with call activity" + self.complete_next_manual_task(processor) + assert process_instance.last_milestone_bpmn_name == "Completed" + assert process_instance.status == "complete" diff --git a/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx b/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx index a427c2b5..6475bfb5 100644 --- a/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx +++ b/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx @@ -42,6 +42,7 @@ import { convertSecondsToFormattedDateTime, convertSecondsToFormattedTimeHoursMinutes, getKeyByValue, + getLastMilestoneFromProcessInstance, getPageInfoFromSearchParams, modifyProcessIdentifierForPathParam, refreshAtInterval, @@ -1602,7 +1603,7 @@ export default function ProcessInstanceListTable({ } return {shortUsernameString}; }; - const formatProcessInstanceId = (row: ProcessInstance, id: number) => { + const formatProcessInstanceId = (_row: ProcessInstance, id: number) => { return {id}; }; const formatProcessModelIdentifier = (_row: any, identifier: any) => { @@ -1611,6 +1612,16 @@ export default function ProcessInstanceListTable({ const formatProcessModelDisplayName = (_row: any, identifier: any) => { return {identifier}; }; + const formatLastMilestone = ( + processInstance: ProcessInstance, + value: any + ) => { + const [valueToUse, truncatedValue] = getLastMilestoneFromProcessInstance( + processInstance, + value + ); + return {truncatedValue}; + }; const formatSecondsForDisplay = (_row: any, seconds: any) => { return convertSecondsToFormattedDateTime(seconds) || '-'; @@ -1629,6 +1640,7 @@ export default function ProcessInstanceListTable({ end_in_seconds: formatSecondsForDisplay, updated_at_in_seconds: formatSecondsForDisplay, task_updated_at_in_seconds: formatSecondsForDisplay, + last_milestone_bpmn_name: formatLastMilestone, }; const columnAccessor = column.accessor as keyof ProcessInstance; const formatter = diff --git a/spiffworkflow-frontend/src/components/ProcessInstanceLogList.tsx b/spiffworkflow-frontend/src/components/ProcessInstanceLogList.tsx index 85f7e5d0..579bde76 100644 --- a/spiffworkflow-frontend/src/components/ProcessInstanceLogList.tsx +++ b/spiffworkflow-frontend/src/components/ProcessInstanceLogList.tsx @@ -46,7 +46,9 @@ export default function ProcessInstanceLogList({ processInstanceId, }: OwnProps) { const [clearAll, setClearAll] = useState(false); - const [processInstanceLogs, setProcessInstanceLogs] = useState([]); + const [processInstanceLogs, setProcessInstanceLogs] = useState< + ProcessInstanceLogEntry[] + >([]); const [pagination, setPagination] = useState(null); const [searchParams, setSearchParams] = useSearchParams(); diff --git a/spiffworkflow-frontend/src/helpers.tsx b/spiffworkflow-frontend/src/helpers.tsx index 15932c03..a6fdddb1 100644 --- a/spiffworkflow-frontend/src/helpers.tsx +++ b/spiffworkflow-frontend/src/helpers.tsx @@ -6,6 +6,7 @@ import { DATE_FORMAT, TIME_FORMAT_HOURS_MINUTES, } from './config'; +import { ProcessInstance } from './interfaces'; export const DEFAULT_PER_PAGE = 50; export const DEFAULT_PAGE = 1; @@ -365,3 +366,30 @@ const FOUR_HOURS_IN_SECONDS = SECONDS_IN_HOUR * 4; export const REFRESH_INTERVAL_SECONDS = 5; export const REFRESH_TIMEOUT_SECONDS = FOUR_HOURS_IN_SECONDS; + +export const getLastMilestoneFromProcessInstance = ( + processInstance: ProcessInstance, + value: any +) => { + let valueToUse = value; + if (!valueToUse) { + if (processInstance.status === 'not_started') { + valueToUse = 'Created'; + } else if ( + ['complete', 'error', 'terminated'].includes(processInstance.status) + ) { + valueToUse = 'Completed'; + } else { + valueToUse = 'Started'; + } + } + let truncatedValue = valueToUse; + const milestoneLengthLimit = 20; + if (truncatedValue.length > milestoneLengthLimit) { + truncatedValue = `${truncatedValue.substring( + 0, + milestoneLengthLimit - 3 + )}...`; + } + return [valueToUse, truncatedValue]; +}; diff --git a/spiffworkflow-frontend/src/index.css b/spiffworkflow-frontend/src/index.css index ae9755d7..b2052c9e 100644 --- a/spiffworkflow-frontend/src/index.css +++ b/spiffworkflow-frontend/src/index.css @@ -196,7 +196,7 @@ dl { dl dt { display: inline-block; font-weight: 600; - min-width: 5rem; + min-width: 6rem; color: #161616; } diff --git a/spiffworkflow-frontend/src/interfaces.ts b/spiffworkflow-frontend/src/interfaces.ts index 53c47d18..712bee82 100644 --- a/spiffworkflow-frontend/src/interfaces.ts +++ b/spiffworkflow-frontend/src/interfaces.ts @@ -172,6 +172,7 @@ export interface ProcessInstance { bpmn_version_control_type: string; process_metadata?: ProcessInstanceMetadata[]; process_model_with_diagram_identifier?: string; + last_milestone_bpmn_name?: string; // from tasks potential_owner_usernames?: string; diff --git a/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx b/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx index 3ed76928..b96b6483 100644 --- a/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx @@ -42,6 +42,7 @@ import HttpService from '../services/HttpService'; import ReactDiagramEditor from '../components/ReactDiagramEditor'; import { convertSecondsToFormattedDateTime, + getLastMilestoneFromProcessInstance, HUMAN_TASK_TYPES, modifyProcessIdentifierForPathParam, unModifyProcessIdentifierForPathParam, @@ -322,7 +323,7 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { if (!processInstance) { return null; } - let lastUpdatedTimeLabel = 'Updated at'; + let lastUpdatedTimeLabel = 'Updated'; let lastUpdatedTime = processInstance.task_updated_at_in_seconds; if (processInstance.end_in_seconds) { lastUpdatedTimeLabel = 'Completed'; @@ -351,6 +352,12 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { statusColor = 'red'; } + const [lastMilestoneFullValue, lastMilestoneTruncatedValue] = + getLastMilestoneFromProcessInstance( + processInstance, + processInstance.last_milestone_bpmn_name + ); + return ( @@ -394,6 +401,12 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { {lastUpdatedTimeTag} +
+
Last milestone:
+
+ {lastMilestoneTruncatedValue} +
+
Revision: