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 <jasquat@users.noreply.github.com>
Co-authored-by: Kevin Burnett <18027+burnettk@users.noreply.github.com>
Co-authored-by: danfunk <daniel.h.funk@gmail.com>
Co-authored-by: burnettk <burnettk@users.noreply.github.com>
This commit is contained in:
jasquat 2023-09-07 10:10:44 -04:00 committed by GitHub
parent f218805a2d
commit 9bb9ce47f8
21 changed files with 348 additions and 50 deletions

View File

@ -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:

View File

@ -39,6 +39,7 @@ function run_autofixers() {
fi
python_dirs="$(get_python_dirs) bin"
# shellcheck disable=2086
ruff --fix $python_dirs || echo ''
}

View File

@ -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
;
'

View File

@ -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 ###

View File

@ -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]:

View File

@ -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()

View File

@ -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:

View File

@ -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
)

View File

@ -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:

View File

@ -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)

View File

@ -0,0 +1,62 @@
<?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: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_caw9m9m" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_088o54g</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_088o54g" sourceRef="StartEvent_1" targetRef="milestone_started" />
<bpmn:endEvent id="Event_12t9aet">
<bpmn:incoming>Flow_062tiay</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_06ih0jm" sourceRef="milestone_started" targetRef="call_activity" />
<bpmn:manualTask id="milestone_started">
<bpmn:incoming>Flow_088o54g</bpmn:incoming>
<bpmn:outgoing>Flow_06ih0jm</bpmn:outgoing>
</bpmn:manualTask>
<bpmn:sequenceFlow id="Flow_0c0z9vd" sourceRef="call_activity" targetRef="milestone_done_with_call_activity" />
<bpmn:callActivity id="call_activity" calledElement="Process_TestLastMilestoneCallActivity">
<bpmn:incoming>Flow_06ih0jm</bpmn:incoming>
<bpmn:outgoing>Flow_0c0z9vd</bpmn:outgoing>
</bpmn:callActivity>
<bpmn:sequenceFlow id="Flow_062tiay" sourceRef="milestone_done_with_call_activity" targetRef="Event_12t9aet" />
<bpmn:manualTask id="milestone_done_with_call_activity">
<bpmn:incoming>Flow_0c0z9vd</bpmn:incoming>
<bpmn:outgoing>Flow_062tiay</bpmn:outgoing>
</bpmn:manualTask>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_caw9m9m">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1ef2bzh_di" bpmnElement="milestone_started">
<dc:Bounds x="270" y="137" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_02y238p_di" bpmnElement="call_activity">
<dc:Bounds x="420" y="137" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_12t9aet_di" bpmnElement="Event_12t9aet">
<dc:Bounds x="672" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_10b7ooz_di" bpmnElement="milestone_done_with_call_activity">
<dc:Bounds x="540" y="137" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_088o54g_di" bpmnElement="Flow_088o54g">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_06ih0jm_di" bpmnElement="Flow_06ih0jm">
<di:waypoint x="370" y="177" />
<di:waypoint x="420" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0c0z9vd_di" bpmnElement="Flow_0c0z9vd">
<di:waypoint x="520" y="177" />
<di:waypoint x="540" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_062tiay_di" bpmnElement="Flow_062tiay">
<di:waypoint x="640" y="177" />
<di:waypoint x="672" y="177" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,56 @@
<?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: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_TestLastMilestoneCallActivity" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_0vc53yr</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_0vc53yr" sourceRef="StartEvent_1" targetRef="in_call_activity" />
<bpmn:intermediateThrowEvent id="in_call_activity" name="In Call Activity">
<bpmn:incoming>Flow_0vc53yr</bpmn:incoming>
<bpmn:outgoing>Flow_1o2uee3</bpmn:outgoing>
</bpmn:intermediateThrowEvent>
<bpmn:sequenceFlow id="Flow_1o2uee3" sourceRef="in_call_activity" targetRef="milestone_in_call_activity" />
<bpmn:manualTask id="milestone_in_call_activity">
<bpmn:incoming>Flow_1o2uee3</bpmn:incoming>
<bpmn:outgoing>Flow_0jcfams</bpmn:outgoing>
</bpmn:manualTask>
<bpmn:endEvent id="Event_00alhkl" name="Done with call activity">
<bpmn:incoming>Flow_0jcfams</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_0jcfams" sourceRef="milestone_in_call_activity" targetRef="Event_00alhkl" />
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_TestLastMilestoneCallActivity">
<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_0q7znrl_di" bpmnElement="in_call_activity">
<dc:Bounds x="272" y="159" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="256" y="202" width="69" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0vu3k30_di" bpmnElement="milestone_in_call_activity">
<dc:Bounds x="350" y="137" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_00alhkl_di" bpmnElement="Event_00alhkl">
<dc:Bounds x="492" y="159" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="476" y="202" width="69" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_0vc53yr_di" bpmnElement="Flow_0vc53yr">
<di:waypoint x="215" y="177" />
<di:waypoint x="272" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1o2uee3_di" bpmnElement="Flow_1o2uee3">
<di:waypoint x="308" y="177" />
<di:waypoint x="350" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0jcfams_di" bpmnElement="Flow_0jcfams">
<di:waypoint x="450" y="177" />
<di:waypoint x="492" y="177" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -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]

View File

@ -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",

View File

@ -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"

View File

@ -42,6 +42,7 @@ import {
convertSecondsToFormattedDateTime,
convertSecondsToFormattedTimeHoursMinutes,
getKeyByValue,
getLastMilestoneFromProcessInstance,
getPageInfoFromSearchParams,
modifyProcessIdentifierForPathParam,
refreshAtInterval,
@ -1602,7 +1603,7 @@ export default function ProcessInstanceListTable({
}
return <span title={fullUsernameString}>{shortUsernameString}</span>;
};
const formatProcessInstanceId = (row: ProcessInstance, id: number) => {
const formatProcessInstanceId = (_row: ProcessInstance, id: number) => {
return <span data-qa="paginated-entity-id">{id}</span>;
};
const formatProcessModelIdentifier = (_row: any, identifier: any) => {
@ -1611,6 +1612,16 @@ export default function ProcessInstanceListTable({
const formatProcessModelDisplayName = (_row: any, identifier: any) => {
return <span>{identifier}</span>;
};
const formatLastMilestone = (
processInstance: ProcessInstance,
value: any
) => {
const [valueToUse, truncatedValue] = getLastMilestoneFromProcessInstance(
processInstance,
value
);
return <span title={valueToUse}>{truncatedValue}</span>;
};
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 =

View File

@ -46,7 +46,9 @@ export default function ProcessInstanceLogList({
processInstanceId,
}: OwnProps) {
const [clearAll, setClearAll] = useState<boolean>(false);
const [processInstanceLogs, setProcessInstanceLogs] = useState([]);
const [processInstanceLogs, setProcessInstanceLogs] = useState<
ProcessInstanceLogEntry[]
>([]);
const [pagination, setPagination] = useState(null);
const [searchParams, setSearchParams] = useSearchParams();

View File

@ -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];
};

View File

@ -196,7 +196,7 @@ dl {
dl dt {
display: inline-block;
font-weight: 600;
min-width: 5rem;
min-width: 6rem;
color: #161616;
}

View File

@ -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;

View File

@ -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 (
<Grid condensed fullWidth>
<Column sm={4} md={4} lg={5}>
@ -394,6 +401,12 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
</dd>
</dl>
{lastUpdatedTimeTag}
<dl>
<dt>Last milestone:</dt>
<dd title={lastMilestoneFullValue}>
{lastMilestoneTruncatedValue}
</dd>
</dl>
<dl>
<dt>Revision:</dt>
<dd>