Merge remote-tracking branch 'origin/main' into feature/multiple_editor_users_display_user

This commit is contained in:
jasquat 2023-05-04 11:42:36 -04:00
commit f7e8fd0022
21 changed files with 395 additions and 157 deletions

View File

@ -1289,6 +1289,13 @@ paths:
put:
operationId: spiffworkflow_backend.routes.process_models_controller.process_model_file_update
summary: save the contents to the given file
parameters:
- name: file_contents_hash
in: query
required: false
description: The hash of the file contents that originally came with the file.
schema:
type: string
tags:
- Process Model Files
requestBody:
@ -1632,9 +1639,9 @@ paths:
required: true
description: The unique id of the process instance
schema:
type: string
type: integer
post:
operationId: spiffworkflow_backend.routes.process_api_blueprint.send_bpmn_event
operationId: spiffworkflow_backend.routes.process_instances_controller.send_bpmn_event
summary: Send a BPMN event to the process
tags:
- Process Instances
@ -1823,6 +1830,27 @@ paths:
schema:
$ref: "#/components/schemas/OkTrue"
/tasks/{process_instance_id}/send-user-signal-event:
parameters:
- name: process_instance_id
in: path
required: true
description: The unique id of the process instance
schema:
type: integer
post:
operationId: spiffworkflow_backend.routes.process_instances_controller.send_user_signal_event
summary: Send a BPMN event to the process
tags:
- Process Instances
responses:
"200":
description: Event Sent Successfully
content:
application/json:
schema:
$ref: "#/components/schemas/Workflow"
/messages:
parameters:
- name: process_instance_id

View File

@ -127,8 +127,8 @@ class ApiError(Exception):
instance.task_trace = TaskModelError.get_task_trace(task_model)
try:
spec_reference = TaskService.get_spec_reference_from_bpmn_process(task_model.bpmn_process)
instance.file_name = spec_reference.file_name
spec_reference_filename = TaskService.get_spec_filename_from_bpmn_process(task_model.bpmn_process)
instance.file_name = spec_reference_filename
except Exception as exception:
current_app.logger.error(exception)

View File

@ -1,12 +1,9 @@
"""File."""
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import field
from datetime import datetime
from typing import Optional
import marshmallow
from marshmallow import INCLUDE
from marshmallow import Schema
from typing import Any
from spiffworkflow_backend.helpers.spiff_enum import SpiffEnum
from spiffworkflow_backend.models.spec_reference import SpecReference
@ -74,10 +71,10 @@ class File:
type: str
last_modified: datetime
size: int
references: Optional[list[SpecReference]] = None
file_contents: Optional[bytes] = None
process_model_id: Optional[str] = None
process_group_id: Optional[str] = None
references: list[SpecReference] | None = None
file_contents: bytes | None = None
process_model_id: str | None = None
file_contents_hash: str | None = None
def __post_init__(self) -> None:
"""__post_init__."""
@ -91,7 +88,7 @@ class File:
content_type: str,
last_modified: datetime,
file_size: int,
) -> "File":
) -> File:
"""From_file_system."""
instance = cls(
name=file_name,
@ -102,28 +99,9 @@ class File:
)
return instance
class FileSchema(Schema):
"""FileSchema."""
class Meta:
"""Meta."""
model = File
fields = [
"id",
"name",
"content_type",
"last_modified",
"type",
"size",
"data_store",
"user_uid",
"url",
"file_contents",
"references",
"process_group_id",
"process_model_id",
]
unknown = INCLUDE
references = marshmallow.fields.List(marshmallow.fields.Nested("SpecReferenceSchema"))
@property
def serialized(self) -> dict[str, Any]:
dictionary = self.__dict__
if isinstance(self.file_contents, bytes):
dictionary["file_contents"] = self.file_contents.decode("utf-8")
return dictionary

View File

@ -87,7 +87,7 @@ class ProcessModelInfoSchema(Schema):
display_order = marshmallow.fields.Integer(allow_none=True)
primary_file_name = marshmallow.fields.String(allow_none=True)
primary_process_id = marshmallow.fields.String(allow_none=True)
files = marshmallow.fields.List(marshmallow.fields.Nested("FileSchema"))
files = marshmallow.fields.List(marshmallow.fields.Nested("File"))
fault_or_suspend_on_exception = marshmallow.fields.String()
exception_notification_addresses = marshmallow.fields.List(marshmallow.fields.String)
metadata_extraction_paths = marshmallow.fields.List(

View File

@ -31,7 +31,6 @@ from spiffworkflow_backend.services.process_caller_service import ProcessCallerS
from spiffworkflow_backend.services.process_instance_processor import (
ProcessInstanceProcessor,
)
from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService
from spiffworkflow_backend.services.process_model_service import ProcessModelService
@ -189,25 +188,6 @@ def _get_required_parameter_or_raise(parameter: str, post_body: dict[str, Any])
return return_value
def send_bpmn_event(
modified_process_model_identifier: str,
process_instance_id: str,
body: Dict,
) -> Response:
"""Send a bpmn event to a workflow."""
process_instance = ProcessInstanceModel.query.filter(ProcessInstanceModel.id == int(process_instance_id)).first()
if process_instance:
processor = ProcessInstanceProcessor(process_instance)
processor.send_bpmn_event(body)
task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task())
return make_response(jsonify(task), 200)
else:
raise ApiError(
error_code="send_bpmn_event_error",
message=f"Could not send event to Instance: {process_instance_id}",
)
def _commit_and_push_to_git(message: str) -> None:
"""Commit_and_push_to_git."""
if current_app.config["SPIFFWORKFLOW_BACKEND_GIT_COMMIT_ON_SAVE"]:

View File

@ -298,8 +298,8 @@ def process_instance_report_show(
def process_instance_report_column_list(
process_model_identifier: Optional[str] = None,
) -> flask.wrappers.Response:
"""Process_instance_report_column_list."""
table_columns = ProcessInstanceReportService.builtin_column_options()
system_report_column_options = ProcessInstanceReportService.system_report_column_options()
columns_for_metadata_query = (
db.session.query(ProcessInstanceMetadataModel.key)
.order_by(ProcessInstanceMetadataModel.key)
@ -315,7 +315,7 @@ def process_instance_report_column_list(
columns_for_metadata_strings = [
{"Header": i[0], "accessor": i[0], "filterable": True} for i in columns_for_metadata
]
return make_response(jsonify(table_columns + columns_for_metadata_strings), 200)
return make_response(jsonify(table_columns + system_report_column_options + columns_for_metadata_strings), 200)
def process_instance_show_for_me(
@ -642,6 +642,38 @@ def process_instance_find_by_id(
return make_response(jsonify(response_json), 200)
def send_user_signal_event(
process_instance_id: int,
body: Dict,
) -> Response:
"""Send a user signal event to a process instance."""
process_instance = _find_process_instance_for_me_or_raise(process_instance_id)
return _send_bpmn_event(process_instance, body)
def send_bpmn_event(
modified_process_model_identifier: str,
process_instance_id: int,
body: Dict,
) -> Response:
"""Send a bpmn event to a process instance."""
process_instance = ProcessInstanceModel.query.filter(ProcessInstanceModel.id == int(process_instance_id)).first()
if process_instance:
return _send_bpmn_event(process_instance, body)
else:
raise ApiError(
error_code="send_bpmn_event_error",
message=f"Could not send event to Instance: {process_instance_id}",
)
def _send_bpmn_event(process_instance: ProcessInstanceModel, body: dict) -> Response:
processor = ProcessInstanceProcessor(process_instance)
processor.send_bpmn_event(body)
task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task())
return make_response(jsonify(task), 200)
def _get_process_instance(
modified_process_model_identifier: str,
process_instance: ProcessInstanceModel,

View File

@ -2,6 +2,7 @@
import json
import os
import re
from hashlib import sha256
from typing import Any
from typing import Dict
from typing import Optional
@ -18,7 +19,6 @@ from werkzeug.datastructures import FileStorage
from spiffworkflow_backend.exceptions.api_error import ApiError
from spiffworkflow_backend.interfaces import IdToProcessGroupMapping
from spiffworkflow_backend.models.file import FileSchema
from spiffworkflow_backend.models.process_group import ProcessGroup
from spiffworkflow_backend.models.process_instance_report import (
ProcessInstanceReportModel,
@ -245,10 +245,13 @@ def process_model_list(
return make_response(jsonify(response_json), 200)
def process_model_file_update(modified_process_model_identifier: str, file_name: str) -> flask.wrappers.Response:
"""Process_model_file_update."""
def process_model_file_update(
modified_process_model_identifier: str, file_name: str, file_contents_hash: str
) -> flask.wrappers.Response:
message = f"User: {g.user.username} clicked save for"
return _create_or_update_process_model_file(modified_process_model_identifier, message, 200)
return _create_or_update_process_model_file(
modified_process_model_identifier, message, 200, file_contents_hash=file_contents_hash
)
def process_model_file_delete(modified_process_model_identifier: str, file_name: str) -> flask.wrappers.Response:
@ -293,24 +296,23 @@ def process_model_file_create(
def process_model_file_show(modified_process_model_identifier: str, file_name: str) -> Any:
"""Process_model_file_show."""
process_model_identifier = modified_process_model_identifier.replace(":", "/")
process_model = _get_process_model(process_model_identifier)
files = SpecFileService.get_files(process_model, file_name)
if len(files) == 0:
raise ApiError(
error_code="unknown file",
message=(
f"No information exists for file {file_name} it does not exist in workflow {process_model_identifier}."
),
error_code="process_model_file_not_found",
message=f"File {file_name} not found in workflow {process_model_identifier}.",
status_code=404,
)
file = files[0]
file_contents = SpecFileService.get_data(process_model, file.name)
file.file_contents = file_contents
file_contents_hash = sha256(file_contents).hexdigest()
file.file_contents_hash = file_contents_hash
file.process_model_id = process_model.id
return FileSchema().dump(file)
return make_response(jsonify(file), 200)
# {
@ -477,6 +479,7 @@ def _create_or_update_process_model_file(
modified_process_model_identifier: str,
message_for_git_commit: str,
http_status_to_return: int,
file_contents_hash: Optional[str] = None,
) -> flask.wrappers.Response:
"""_create_or_update_process_model_file."""
process_model_identifier = modified_process_model_identifier.replace(":", "/")
@ -498,6 +501,21 @@ def _create_or_update_process_model_file(
status_code=400,
)
if file_contents_hash is not None:
current_file_contents_bytes = SpecFileService.get_data(process_model, request_file.filename)
if current_file_contents_bytes and file_contents_hash:
current_file_contents_hash = sha256(current_file_contents_bytes).hexdigest()
if current_file_contents_hash != file_contents_hash:
raise ApiError(
error_code="process_model_file_has_changed",
message=(
f"Process model file: {request_file.filename} was already changed by someone else. If you made"
" changes you do not want to lose, click the Download button and make sure your changes are"
" in the resulting file. If you do not need your changes, you can safely reload this page."
),
status_code=409,
)
file = None
try:
file = SpecFileService.update_file(process_model, request_file.filename, request_file_contents)
@ -514,8 +532,4 @@ def _create_or_update_process_model_file(
file.process_model_id = process_model.id
_commit_and_push_to_git(f"{message_for_git_commit} {process_model_identifier}/{file.name}")
return Response(
json.dumps(FileSchema().dump(file)),
status=http_status_to_return,
mimetype="application/json",
)
return make_response(jsonify(file), http_status_to_return)

View File

@ -532,8 +532,10 @@ class AuthorizationService:
# 1. view your own instances.
# 2. view the logs for these instances.
if permission_set == "start":
target_uri = f"/process-instances/{process_related_path_segment}"
permissions_to_assign.append(PermissionToAssign(permission="create", target_uri=target_uri))
path_prefixes_that_allow_create_access = ["process-instances"]
for path_prefix in path_prefixes_that_allow_create_access:
target_uri = f"/{path_prefix}/{process_related_path_segment}"
permissions_to_assign.append(PermissionToAssign(permission="create", target_uri=target_uri))
# giving people access to all logs for an instance actually gives them a little bit more access
# than would be optimal. ideally, you would only be able to view the logs for instances that you started

View File

@ -1115,59 +1115,54 @@ class ProcessInstanceProcessor:
def manual_complete_task(self, task_id: str, execute: bool) -> None:
"""Mark the task complete optionally executing it."""
spiff_tasks_updated = {}
start_in_seconds = time.time()
spiff_task = self.bpmn_process_instance.get_task_from_id(UUID(task_id))
event_type = ProcessInstanceEventType.task_skipped.value
start_time = time.time()
if execute:
current_app.logger.info(
f"Manually executing Task {spiff_task.task_spec.name} of process"
f" instance {self.process_instance_model.id}"
)
# Executing a subworkflow manually will restart its subprocess and allow stepping through it
# Executing a sub-workflow manually will restart its subprocess and allow stepping through it
if isinstance(spiff_task.task_spec, SubWorkflowTask):
subprocess = self.bpmn_process_instance.get_subprocess(spiff_task)
# We have to get to the actual start event
for task in self.bpmn_process_instance.get_tasks(workflow=subprocess):
task.complete()
spiff_tasks_updated[task.id] = task
if isinstance(task.task_spec, StartEvent):
for spiff_task in self.bpmn_process_instance.get_tasks(workflow=subprocess):
spiff_task.run()
if isinstance(spiff_task.task_spec, StartEvent):
break
else:
spiff_task.complete()
spiff_tasks_updated[spiff_task.id] = spiff_task
for child in spiff_task.children:
spiff_tasks_updated[child.id] = child
spiff_task.run()
event_type = ProcessInstanceEventType.task_executed_manually.value
else:
spiff_logger = logging.getLogger("spiff")
spiff_logger.info(f"Skipped task {spiff_task.task_spec.name}", extra=spiff_task.log_info())
spiff_task._set_state(TaskState.COMPLETED)
for child in spiff_task.children:
child.task_spec._update(child)
spiff_tasks_updated[child.id] = child
spiff_task.complete()
spiff_task.workflow.last_task = spiff_task
spiff_tasks_updated[spiff_task.id] = spiff_task
end_in_seconds = time.time()
if isinstance(spiff_task.task_spec, EndEvent):
for task in self.bpmn_process_instance.get_tasks(TaskState.DEFINITE_MASK, workflow=spiff_task.workflow):
task.complete()
spiff_tasks_updated[task.id] = task
# A subworkflow task will become ready when its workflow is complete. Engine steps would normally
# then complete it, but we have to do it ourselves here.
for task in self.bpmn_process_instance.get_tasks(TaskState.READY):
if isinstance(task.task_spec, SubWorkflowTask):
task.complete()
spiff_tasks_updated[task.id] = task
task_service = TaskService(
process_instance=self.process_instance_model,
serializer=self._serializer,
bpmn_definition_to_task_definitions_mappings=self.bpmn_definition_to_task_definitions_mappings,
)
spiff_tasks_updated = {}
for task in self.bpmn_process_instance.get_tasks():
if task.last_state_change > start_time:
spiff_tasks_updated[task.id] = task
for updated_spiff_task in spiff_tasks_updated.values():
(
bpmn_process,
@ -1216,6 +1211,14 @@ class ProcessInstanceProcessor:
raise TaskNotFoundError(
f"Cannot find a task with guid '{to_task_guid}' for process instance '{process_instance.id}'"
)
# If this task model has a parent boundary event, reset to that point instead,
# so we can reset all the boundary timers, etc...
parent_id = to_task_model.properties_json.get("parent", "")
parent = TaskModel.query.filter_by(guid=parent_id).first()
is_boundary_parent = False
if parent and parent.task_definition.typename == "_BoundaryEventParent":
to_task_model = parent
is_boundary_parent = True # Will need to complete this task at the end so we are on the correct process.
# NOTE: run ALL queries before making changes to ensure we get everything before anything changes
parent_bpmn_processes, task_models_of_parent_bpmn_processes = TaskService.task_models_of_parent_bpmn_processes(
@ -1320,6 +1323,11 @@ class ProcessInstanceProcessor:
db.session.commit()
processor = ProcessInstanceProcessor(process_instance)
# If this as a boundary event parent, run it, so we get back to an active task.
if is_boundary_parent:
processor.do_engine_steps(execution_strategy_name="one_at_a_time")
processor.save()
processor.suspend()

View File

@ -320,7 +320,7 @@ class ProcessInstanceReportService:
@classmethod
def builtin_column_options(cls) -> list[ReportMetadataColumn]:
"""Builtin_column_options."""
"""Columns that are actually in the process instance table."""
return_value: list[ReportMetadataColumn] = [
{"Header": "Id", "accessor": "id", "filterable": False},
{
@ -339,6 +339,15 @@ class ProcessInstanceReportService:
]
return return_value
@classmethod
def system_report_column_options(cls) -> list[ReportMetadataColumn]:
"""Columns that are used with certain system reports."""
return_value: list[ReportMetadataColumn] = [
{"Header": "Task", "accessor": "task_title", "filterable": False},
{"Header": "Waiting For", "accessor": "waiting_for", "filterable": False},
]
return return_value
@classmethod
def get_filter_value(cls, filters: list[FilterValue], filter_key: str) -> Any:
for filter in filters:

View File

@ -175,7 +175,6 @@ class SpecFileService(FileSystemService):
@classmethod
def update_file(cls, process_model_info: ProcessModelInfo, file_name: str, binary_data: bytes) -> File:
"""Update_file."""
SpecFileService.assert_valid_file_name(file_name)
cls.validate_bpmn_xml(file_name, binary_data)

View File

@ -89,15 +89,15 @@ class TaskModelError(Exception):
task_definition = task_model.task_definition
task_bpmn_name = TaskService.get_name_for_display(task_definition)
bpmn_process = task_model.bpmn_process
spec_reference = TaskService.get_spec_reference_from_bpmn_process(bpmn_process)
spec_reference_filename = TaskService.get_spec_filename_from_bpmn_process(bpmn_process)
task_trace = [f"{task_bpmn_name} ({spec_reference.file_name})"]
task_trace = [f"{task_bpmn_name} ({spec_reference_filename})"]
while bpmn_process.guid is not None:
caller_task_model = TaskModel.query.filter_by(guid=bpmn_process.guid).first()
bpmn_process = BpmnProcessModel.query.filter_by(id=bpmn_process.direct_parent_process_id).first()
spec_reference = TaskService.get_spec_reference_from_bpmn_process(bpmn_process)
spec_reference_filename = TaskService.get_spec_filename_from_bpmn_process(bpmn_process)
task_trace.append(
f"{TaskService.get_name_for_display(caller_task_model.task_definition)} ({spec_reference.file_name})"
f"{TaskService.get_name_for_display(caller_task_model.task_definition)} ({spec_reference_filename})"
)
return task_trace
@ -630,6 +630,15 @@ class TaskService:
result.append({"event": event_definition, "label": extensions["signalButtonLabel"]})
return result
@classmethod
def get_spec_filename_from_bpmn_process(cls, bpmn_process: BpmnProcessModel) -> Optional[str]:
"""Just return the filename if the bpmn process is found in spec reference cache."""
try:
filename: Optional[str] = cls.get_spec_reference_from_bpmn_process(bpmn_process).file_name
return filename
except SpecReferenceNotFoundError:
return None
@classmethod
def get_spec_reference_from_bpmn_process(cls, bpmn_process: BpmnProcessModel) -> SpecReferenceCache:
"""Get the bpmn file for a given task model.

View File

@ -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:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="Process_Admin_Tools_Test" name="AdminToolsTest" isExecutable="true">
<bpmn:startEvent id="Event_17e2qgy">
<bpmn:outgoing>Flow_1ist4rn</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_1ist4rn" sourceRef="Event_17e2qgy" targetRef="Activity_039a4i7" />
<bpmn:endEvent id="Event_1qodpuj">
<bpmn:incoming>Flow_1xbry1g</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_0vzi07z" sourceRef="Activity_039a4i7" targetRef="Activity_0sqxs4d" />
<bpmn:callActivity id="Activity_039a4i7" calledElement="Process_With_Timer">
<bpmn:incoming>Flow_1ist4rn</bpmn:incoming>
<bpmn:outgoing>Flow_0vzi07z</bpmn:outgoing>
</bpmn:callActivity>
<bpmn:sequenceFlow id="Flow_1xbry1g" sourceRef="Activity_0sqxs4d" targetRef="Event_1qodpuj" />
<bpmn:manualTask id="Activity_0sqxs4d" name="Final">
<bpmn:incoming>Flow_0vzi07z</bpmn:incoming>
<bpmn:outgoing>Flow_1xbry1g</bpmn:outgoing>
</bpmn:manualTask>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_Admin_Tools_Test">
<bpmndi:BPMNShape id="Event_17e2qgy_di" bpmnElement="Event_17e2qgy">
<dc:Bounds x="352" y="152" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_02srymo_di" bpmnElement="Activity_039a4i7">
<dc:Bounds x="440" y="130" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_1qodpuj_di" bpmnElement="Event_1qodpuj">
<dc:Bounds x="742" y="152" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1r3vbnd_di" bpmnElement="Activity_0sqxs4d">
<dc:Bounds x="600" y="130" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_1ist4rn_di" bpmnElement="Flow_1ist4rn">
<di:waypoint x="388" y="170" />
<di:waypoint x="440" y="170" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0vzi07z_di" bpmnElement="Flow_0vzi07z">
<di:waypoint x="540" y="170" />
<di:waypoint x="600" y="170" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1xbry1g_di" bpmnElement="Flow_1xbry1g">
<di:waypoint x="700" y="170" />
<di:waypoint x="742" y="170" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,89 @@
<?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:xsi="http://www.w3.org/2001/XMLSchema-instance" 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_With_Timer" name="Process With Timer" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_1e5apvr</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_1e5apvr" sourceRef="StartEvent_1" targetRef="manual_task_1" />
<bpmn:sequenceFlow id="Flow_0vtgres" sourceRef="manual_task_1" targetRef="Activity_2" />
<bpmn:endEvent id="Event_1pgaya7">
<bpmn:incoming>Flow_110vf76</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_110vf76" sourceRef="Activity_2" targetRef="Event_1pgaya7" />
<bpmn:boundaryEvent id="Timer_Event_Horror" name="Timer_Event_Horror" attachedToRef="manual_task_1">
<bpmn:outgoing>Flow_1hy0t7d</bpmn:outgoing>
<bpmn:timerEventDefinition id="TimerEventDefinition_1jkwn61">
<bpmn:timeDuration xsi:type="bpmn:tFormalExpression">'P14D'</bpmn:timeDuration>
</bpmn:timerEventDefinition>
</bpmn:boundaryEvent>
<bpmn:sequenceFlow id="Flow_1hy0t7d" sourceRef="Timer_Event_Horror" targetRef="Activity_3" />
<bpmn:endEvent id="Event_10frcbe">
<bpmn:incoming>Flow_1xbdri7</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1xbdri7" sourceRef="Activity_3" targetRef="Event_10frcbe" />
<bpmn:manualTask id="manual_task_1" name="Manual Task #1">
<bpmn:incoming>Flow_1e5apvr</bpmn:incoming>
<bpmn:outgoing>Flow_0vtgres</bpmn:outgoing>
</bpmn:manualTask>
<bpmn:manualTask id="Activity_3" name="#3">
<bpmn:incoming>Flow_1hy0t7d</bpmn:incoming>
<bpmn:outgoing>Flow_1xbdri7</bpmn:outgoing>
</bpmn:manualTask>
<bpmn:scriptTask id="Activity_2" name="#2">
<bpmn:incoming>Flow_0vtgres</bpmn:incoming>
<bpmn:outgoing>Flow_110vf76</bpmn:outgoing>
<bpmn:script>y='1000'</bpmn:script>
</bpmn:scriptTask>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_With_Timer">
<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_1pgaya7_di" bpmnElement="Event_1pgaya7">
<dc:Bounds x="592" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_10frcbe_di" bpmnElement="Event_10frcbe">
<dc:Bounds x="592" y="282" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1cl74h9_di" bpmnElement="manual_task_1">
<dc:Bounds x="270" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0yn987b_di" bpmnElement="Activity_3">
<dc:Bounds x="430" y="260" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_19c5vp3_di" bpmnElement="Activity_2">
<dc:Bounds x="430" y="137" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_1gfn4de_di" bpmnElement="Timer_Event_Horror">
<dc:Bounds x="302" y="199" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="277" y="242" width="87" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_1e5apvr_di" bpmnElement="Flow_1e5apvr">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0vtgres_di" bpmnElement="Flow_0vtgres">
<di:waypoint x="370" y="177" />
<di:waypoint x="430" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_110vf76_di" bpmnElement="Flow_110vf76">
<di:waypoint x="530" y="177" />
<di:waypoint x="592" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1hy0t7d_di" bpmnElement="Flow_1hy0t7d">
<di:waypoint x="320" y="235" />
<di:waypoint x="320" y="300" />
<di:waypoint x="430" y="300" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1xbdri7_di" bpmnElement="Flow_1xbdri7">
<di:waypoint x="530" y="300" />
<di:waypoint x="592" y="300" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -113,7 +113,6 @@ class BaseTest:
process_group_id: str,
display_name: str = "",
) -> str:
"""Create_process_group."""
process_group = ProcessGroup(id=process_group_id, display_name=display_name, display_order=0, admin=False)
response = client.post(
"/v1.0/process-groups",
@ -138,7 +137,6 @@ class BaseTest:
primary_file_name: Optional[str] = None,
user: Optional[UserModel] = None,
) -> TestResponse:
"""Create_process_model."""
if process_model_id is not None:
# make sure we have a group
process_group_id, _ = os.path.split(process_model_id)

View File

@ -15,27 +15,6 @@ from spiffworkflow_backend.routes.tasks_controller import _dequeued_interstitial
class TestForGoodErrors(BaseTest):
"""Assure when certain errors happen when rendering a jinaj2 error that it makes some sense."""
def get_next_user_task(
self,
process_instance_id: int,
client: FlaskClient,
with_super_admin_user: UserModel,
) -> Any:
# Call this to assure all engine-steps are fully processed before we search for human tasks.
_dequeued_interstitial_stream(process_instance_id)
"""Returns the next available user task for a given process instance, if possible."""
human_tasks = (
db.session.query(HumanTaskModel).filter(HumanTaskModel.process_instance_id == process_instance_id).all()
)
assert len(human_tasks) > 0, "No human tasks found for process."
human_task = human_tasks[0]
response = client.get(
f"/v1.0/tasks/{process_instance_id}/{human_task.task_id}",
headers=self.logged_in_headers(with_super_admin_user),
)
return response
def test_invalid_form(
self,
app: Flask,
@ -61,7 +40,7 @@ class TestForGoodErrors(BaseTest):
headers=self.logged_in_headers(with_super_admin_user),
)
assert response.status_code == 200
response = self.get_next_user_task(process_instance_id, client, with_super_admin_user)
response = self._get_next_user_task(process_instance_id, client, with_super_admin_user)
assert response.json is not None
assert response.json["error_type"] == "TemplateSyntaxError"
assert response.json["line_number"] == 3
@ -88,7 +67,7 @@ class TestForGoodErrors(BaseTest):
f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model.id)}/{process_instance.id}/run",
headers=self.logged_in_headers(with_super_admin_user),
)
response = self.get_next_user_task(process_instance.id, client, with_super_admin_user)
response = self._get_next_user_task(process_instance.id, client, with_super_admin_user)
assert response.status_code == 400
assert response.json is not None
@ -99,3 +78,24 @@ class TestForGoodErrors(BaseTest):
assert "instructions for end user" in response.json["message"]
assert "Jinja2" in response.json["message"]
assert "unexpected '='" in response.json["message"]
def _get_next_user_task(
self,
process_instance_id: int,
client: FlaskClient,
with_super_admin_user: UserModel,
) -> Any:
# Call this to assure all engine-steps are fully processed before we search for human tasks.
_dequeued_interstitial_stream(process_instance_id)
"""Returns the next available user task for a given process instance, if possible."""
human_tasks = (
db.session.query(HumanTaskModel).filter(HumanTaskModel.process_instance_id == process_instance_id).all()
)
assert len(human_tasks) > 0, "No human tasks found for process."
human_task = human_tasks[0]
response = client.get(
f"/v1.0/tasks/{process_instance_id}/{human_task.task_id}",
headers=self.logged_in_headers(with_super_admin_user),
)
return response

View File

@ -3,6 +3,7 @@ import io
import json
import os
import time
from hashlib import sha256
from typing import Any
from typing import Dict
@ -232,7 +233,6 @@ class TestProcessApi(BaseTest):
with_db_and_bpmn_file_cleanup: None,
with_super_admin_user: UserModel,
) -> None:
"""Test_primary_process_id_updates_via_xml."""
process_group_id = "test_group"
process_model_id = "sample"
process_model_identifier = f"{process_group_id}/{process_model_id}"
@ -258,10 +258,11 @@ class TestProcessApi(BaseTest):
updated_bpmn_file_data_string = bpmn_file_data_string.replace(old_string, new_string)
updated_bpmn_file_data_bytes = bytearray(updated_bpmn_file_data_string, "utf-8")
data = {"file": (io.BytesIO(updated_bpmn_file_data_bytes), bpmn_file_name)}
file_contents_hash = sha256(bpmn_file_data_bytes).hexdigest()
modified_process_model_id = process_model_identifier.replace("/", ":")
response = client.put(
f"/v1.0/process-models/{modified_process_model_id}/files/{bpmn_file_name}",
f"/v1.0/process-models/{modified_process_model_id}/files/{bpmn_file_name}?file_contents_hash={file_contents_hash}",
data=data,
follow_redirects=True,
content_type="multipart/form-data",
@ -780,13 +781,12 @@ class TestProcessApi(BaseTest):
with_db_and_bpmn_file_cleanup: None,
with_super_admin_user: UserModel,
) -> None:
"""Test_process_model_file_update."""
process_model_identifier = self.create_group_and_model_with_bpmn(client, with_super_admin_user)
modified_process_model_id = process_model_identifier.replace("/", ":")
data = {"key1": "THIS DATA"}
response = client.put(
f"/v1.0/process-models/{modified_process_model_id}/files/random_fact.svg",
f"/v1.0/process-models/{modified_process_model_id}/files/random_fact.svg?file_contents_hash=does_not_matter",
data=data,
follow_redirects=True,
content_type="multipart/form-data",
@ -803,13 +803,12 @@ class TestProcessApi(BaseTest):
with_db_and_bpmn_file_cleanup: None,
with_super_admin_user: UserModel,
) -> None:
"""Test_process_model_file_update."""
process_model_identifier = self.create_group_and_model_with_bpmn(client, with_super_admin_user)
modified_process_model_id = process_model_identifier.replace("/", ":")
data = {"file": (io.BytesIO(b""), "random_fact.svg")}
response = client.put(
f"/v1.0/process-models/{modified_process_model_id}/files/random_fact.svg",
f"/v1.0/process-models/{modified_process_model_id}/files/random_fact.svg?file_contents_hash=does_not_matter",
data=data,
follow_redirects=True,
content_type="multipart/form-data",
@ -827,30 +826,22 @@ class TestProcessApi(BaseTest):
with_db_and_bpmn_file_cleanup: None,
with_super_admin_user: UserModel,
) -> None:
"""Test_process_model_file_update."""
process_group_id = "test_group"
process_group_description = "Test Group"
process_model_id = "random_fact"
process_model_id = "simple_form"
process_model_identifier = f"{process_group_id}/{process_model_id}"
self.create_process_group_with_api(client, with_super_admin_user, process_group_id, process_group_description)
self.create_process_model_with_api(
client,
bpmn_file_name = "simple_form.json"
load_test_spec(
process_model_id=process_model_identifier,
user=with_super_admin_user,
)
bpmn_file_name = "random_fact.bpmn"
original_file = load_test_spec(
process_model_id=process_model_id,
bpmn_file_name=bpmn_file_name,
process_model_source_directory="random_fact",
process_model_source_directory="simple_form",
)
bpmn_file_data_bytes = self.get_test_data_file_contents(bpmn_file_name, process_model_id)
file_contents_hash = sha256(bpmn_file_data_bytes).hexdigest()
modified_process_model_id = process_model_identifier.replace("/", ":")
new_file_contents = b"THIS_IS_NEW_DATA"
data = {"file": (io.BytesIO(new_file_contents), "random_fact.svg")}
data = {"file": (io.BytesIO(new_file_contents), bpmn_file_name)}
response = client.put(
f"/v1.0/process-models/{modified_process_model_id}/files/random_fact.svg",
f"/v1.0/process-models/{modified_process_model_id}/files/{bpmn_file_name}?file_contents_hash={file_contents_hash}",
data=data,
follow_redirects=True,
content_type="multipart/form-data",
@ -862,12 +853,11 @@ class TestProcessApi(BaseTest):
assert response.json["file_contents"] is not None
response = client.get(
f"/v1.0/process-models/{modified_process_model_id}/files/random_fact.svg",
f"/v1.0/process-models/{modified_process_model_id}/files/simple_form.json",
headers=self.logged_in_headers(with_super_admin_user),
)
assert response.status_code == 200
updated_file = json.loads(response.get_data(as_text=True))
assert original_file != updated_file
assert updated_file["file_contents"] == new_file_contents.decode()
def test_process_model_file_delete_when_bad_process_model(
@ -879,9 +869,6 @@ class TestProcessApi(BaseTest):
) -> None:
"""Test_process_model_file_update."""
process_model_identifier = self.create_group_and_model_with_bpmn(client, with_super_admin_user)
# self.create_spec_file(client, user=with_super_admin_user)
# process_model = load_test_spec("random_fact")
bad_process_model_identifier = f"x{process_model_identifier}"
modified_bad_process_model_identifier = bad_process_model_identifier.replace("/", ":")
response = client.delete(

View File

@ -434,6 +434,56 @@ class TestProcessInstanceProcessor(BaseTest):
assert process_instance.status == "complete"
def test_properly_resets_process_on_tasks_with_boundary_events(
self,
app: Flask,
client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,
with_super_admin_user: UserModel,
) -> None:
self.create_process_group_with_api(client, with_super_admin_user, "test_group", "test_group")
process_model = load_test_spec(
process_model_id="test_group/boundary_event_reset",
process_model_source_directory="boundary_event_reset",
)
process_instance = self.create_process_instance_from_process_model(
process_model=process_model, user=with_super_admin_user
)
processor = ProcessInstanceProcessor(process_instance)
processor.do_engine_steps(save=True)
assert len(process_instance.active_human_tasks) == 1
human_task_one = process_instance.active_human_tasks[0]
spiff_manual_task = processor.bpmn_process_instance.get_task_from_id(UUID(human_task_one.task_id))
ProcessInstanceService.complete_form_task(
processor, spiff_manual_task, {}, with_super_admin_user, human_task_one
)
assert (
len(process_instance.active_human_tasks) == 1
), "expected 1 active human tasks after 2nd one is completed"
assert process_instance.active_human_tasks[0].task_title == "Final"
# Reset the process back to the task within the call activity that contains a timer_boundary event.
reset_to_spiff_task: SpiffTask = processor.__class__.get_task_by_bpmn_identifier(
"manual_task_1", processor.bpmn_process_instance
)
processor.suspend()
processor = ProcessInstanceProcessor(process_instance)
ProcessInstanceProcessor.reset_process(process_instance, str(reset_to_spiff_task.id))
human_task_one = process_instance.active_human_tasks[0]
assert human_task_one.task_title == "Manual Task #1"
processor = ProcessInstanceProcessor(process_instance)
processor.manual_complete_task(str(spiff_manual_task.id), execute=True)
processor = ProcessInstanceProcessor(process_instance)
processor.resume()
processor.do_engine_steps(save=True)
process_instance = ProcessInstanceModel.query.filter_by(id=process_instance.id).first()
assert len(process_instance.active_human_tasks) == 1
assert process_instance.active_human_tasks[0].task_title == "Final", (
"once we reset, resume, and complete the task, we should be back to the Final step again, and not"
"stuck waiting for the call activity to complete (which was happening in a bug I'm fixing right now)"
)
def test_properly_saves_tasks_when_running(
self,
app: Flask,

View File

@ -120,6 +120,7 @@ export interface ProcessFile {
size: number;
type: string;
file_contents?: string;
file_contents_hash?: string;
}
export interface ProcessInstanceMetadata {

View File

@ -733,7 +733,7 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
if ('payload' in eventToSend)
eventToSend.payload = JSON.parse(eventPayload);
HttpService.makeCallToBackend({
path: `/send-event/${modifiedProcessModelId}/${params.process_instance_id}`,
path: targetUris.processInstanceSendEventPath,
httpMethod: 'POST',
successCallback: saveTaskDataResult,
failureCallback: addError,

View File

@ -238,6 +238,9 @@ export default function ProcessModelEditDiagram() {
httpMethod = 'POST';
} else {
url += `/${fileNameWithExtension}`;
if (processModelFile && processModelFile.file_contents_hash) {
url += `?file_contents_hash=${processModelFile.file_contents_hash}`;
}
}
if (!fileNameWithExtension) {
handleShowFileNameEditor();