mirror of
https://github.com/sartography/spiff-arena.git
synced 2025-01-11 18:14:20 +00:00
Merge pull request #221 from sartography/feature/interstitial
Feature/interstitial
This commit is contained in:
commit
3dca7d2ed4
@ -1842,6 +1842,27 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ServiceTask"
|
$ref: "#/components/schemas/ServiceTask"
|
||||||
|
/tasks/{process_instance_id}:
|
||||||
|
parameters:
|
||||||
|
- name: process_instance_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: The unique id of an existing process instance.
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Tasks
|
||||||
|
operationId: spiffworkflow_backend.routes.tasks_controller.interstitial
|
||||||
|
summary: An SSE (Server Sent Events) endpoint that returns what tasks are currently active (running, waiting, or the final END event)
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: One task
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Task"
|
||||||
|
|
||||||
|
|
||||||
/tasks/{process_instance_id}/{task_guid}:
|
/tasks/{process_instance_id}/{task_guid}:
|
||||||
parameters:
|
parameters:
|
||||||
|
@ -140,7 +140,7 @@ SPIFFWORKFLOW_BACKEND_ENGINE_STEP_DEFAULT_STRATEGY_BACKGROUND = environ.get(
|
|||||||
)
|
)
|
||||||
|
|
||||||
SPIFFWORKFLOW_BACKEND_ENGINE_STEP_DEFAULT_STRATEGY_WEB = environ.get(
|
SPIFFWORKFLOW_BACKEND_ENGINE_STEP_DEFAULT_STRATEGY_WEB = environ.get(
|
||||||
"SPIFFWORKFLOW_BACKEND_ENGINE_STEP_DEFAULT_STRATEGY_WEB", default="greedy"
|
"SPIFFWORKFLOW_BACKEND_ENGINE_STEP_DEFAULT_STRATEGY_WEB", default="run_until_user_message"
|
||||||
)
|
)
|
||||||
|
|
||||||
# this is only used in CI. use SPIFFWORKFLOW_BACKEND_DATABASE_URI instead for real configuration
|
# this is only used in CI. use SPIFFWORKFLOW_BACKEND_DATABASE_URI instead for real configuration
|
||||||
|
@ -1,3 +1,10 @@
|
|||||||
|
users:
|
||||||
|
ciadmin1:
|
||||||
|
service: local_open_id
|
||||||
|
email: ciadmin1@spiffworkflow.org
|
||||||
|
password: ciadmin1
|
||||||
|
preferred_username: ciadmin1
|
||||||
|
|
||||||
groups:
|
groups:
|
||||||
admin:
|
admin:
|
||||||
users: [ciadmin1@spiffworkflow.org]
|
users: [ciadmin1@spiffworkflow.org]
|
||||||
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from flask import g
|
||||||
from sqlalchemy import ForeignKey
|
from sqlalchemy import ForeignKey
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
@ -68,12 +69,19 @@ class HumanTaskModel(SpiffworkflowBaseDBModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def to_task(cls, task: HumanTaskModel) -> Task:
|
def to_task(cls, task: HumanTaskModel) -> Task:
|
||||||
"""To_task."""
|
"""To_task."""
|
||||||
|
can_complete = False
|
||||||
|
for user in task.human_task_users:
|
||||||
|
if user.user_id == g.user.id:
|
||||||
|
can_complete = True
|
||||||
|
break
|
||||||
|
|
||||||
new_task = Task(
|
new_task = Task(
|
||||||
task.task_id,
|
task.task_id,
|
||||||
task.task_name,
|
task.task_name,
|
||||||
task.task_title,
|
task.task_title,
|
||||||
task.task_type,
|
task.task_type,
|
||||||
task.task_status,
|
task.task_status,
|
||||||
|
can_complete,
|
||||||
process_instance_id=task.process_instance_id,
|
process_instance_id=task.process_instance_id,
|
||||||
)
|
)
|
||||||
if hasattr(task, "process_model_display_name"):
|
if hasattr(task, "process_model_display_name"):
|
||||||
|
@ -88,6 +88,7 @@ class Task:
|
|||||||
title: str,
|
title: str,
|
||||||
type: str,
|
type: str,
|
||||||
state: str,
|
state: str,
|
||||||
|
can_complete: bool,
|
||||||
lane: Union[str, None] = None,
|
lane: Union[str, None] = None,
|
||||||
form: None = None,
|
form: None = None,
|
||||||
documentation: str = "",
|
documentation: str = "",
|
||||||
@ -116,6 +117,7 @@ class Task:
|
|||||||
self.title = title
|
self.title = title
|
||||||
self.type = type
|
self.type = type
|
||||||
self.state = state
|
self.state = state
|
||||||
|
self.can_complete = can_complete
|
||||||
self.form = form
|
self.form = form
|
||||||
self.documentation = documentation
|
self.documentation = documentation
|
||||||
self.lane = lane
|
self.lane = lane
|
||||||
@ -160,6 +162,7 @@ class Task:
|
|||||||
"type": self.type,
|
"type": self.type,
|
||||||
"state": self.state,
|
"state": self.state,
|
||||||
"lane": self.lane,
|
"lane": self.lane,
|
||||||
|
"can_complete": self.can_complete,
|
||||||
"form": self.form,
|
"form": self.form,
|
||||||
"documentation": self.documentation,
|
"documentation": self.documentation,
|
||||||
"data": self.data,
|
"data": self.data,
|
||||||
|
@ -124,7 +124,7 @@ def process_instance_run(
|
|||||||
|
|
||||||
processor = None
|
processor = None
|
||||||
try:
|
try:
|
||||||
processor = ProcessInstanceService.run_process_intance_with_processor(process_instance)
|
processor = ProcessInstanceService.run_process_instance_with_processor(process_instance)
|
||||||
except (
|
except (
|
||||||
ApiError,
|
ApiError,
|
||||||
ProcessInstanceIsNotEnqueuedError,
|
ProcessInstanceIsNotEnqueuedError,
|
||||||
|
@ -5,6 +5,7 @@ import uuid
|
|||||||
from sys import exc_info
|
from sys import exc_info
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
from typing import Generator
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
from typing import Union
|
from typing import Union
|
||||||
@ -16,6 +17,7 @@ from flask import current_app
|
|||||||
from flask import g
|
from flask import g
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
from flask import make_response
|
from flask import make_response
|
||||||
|
from flask import stream_with_context
|
||||||
from flask.wrappers import Response
|
from flask.wrappers import Response
|
||||||
from jinja2 import TemplateSyntaxError
|
from jinja2 import TemplateSyntaxError
|
||||||
from SpiffWorkflow.exceptions import WorkflowTaskException # type: ignore
|
from SpiffWorkflow.exceptions import WorkflowTaskException # type: ignore
|
||||||
@ -262,7 +264,7 @@ def manual_complete_task(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def task_show(process_instance_id: int, task_guid: str) -> flask.wrappers.Response:
|
def task_show(process_instance_id: int, task_guid: str = "next") -> flask.wrappers.Response:
|
||||||
"""Task_show."""
|
"""Task_show."""
|
||||||
process_instance = _find_process_instance_by_id_or_raise(process_instance_id)
|
process_instance = _find_process_instance_by_id_or_raise(process_instance_id)
|
||||||
|
|
||||||
@ -277,12 +279,16 @@ def task_show(process_instance_id: int, task_guid: str) -> flask.wrappers.Respon
|
|||||||
process_instance.process_model_identifier,
|
process_instance.process_model_identifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
_find_human_task_or_raise(process_instance_id, task_guid)
|
# _find_human_task_or_raise(process_instance_id, task_guid)
|
||||||
|
|
||||||
form_schema_file_name = ""
|
form_schema_file_name = ""
|
||||||
form_ui_schema_file_name = ""
|
form_ui_schema_file_name = ""
|
||||||
processor = ProcessInstanceProcessor(process_instance)
|
processor = ProcessInstanceProcessor(process_instance)
|
||||||
spiff_task = _get_spiff_task_from_process_instance(task_guid, process_instance, processor=processor)
|
if task_guid == "next":
|
||||||
|
spiff_task = processor.next_task()
|
||||||
|
task_guid = spiff_task.id
|
||||||
|
else:
|
||||||
|
spiff_task = _get_spiff_task_from_process_instance(task_guid, process_instance, processor=processor)
|
||||||
extensions = spiff_task.task_spec.extensions
|
extensions = spiff_task.task_spec.extensions
|
||||||
|
|
||||||
if "properties" in extensions:
|
if "properties" in extensions:
|
||||||
@ -344,17 +350,22 @@ def task_show(process_instance_id: int, task_guid: str) -> flask.wrappers.Respon
|
|||||||
task.form_ui_schema = ui_form_contents
|
task.form_ui_schema = ui_form_contents
|
||||||
|
|
||||||
_munge_form_ui_schema_based_on_hidden_fields_in_task_data(task)
|
_munge_form_ui_schema_based_on_hidden_fields_in_task_data(task)
|
||||||
|
_render_instructions_for_end_user(spiff_task, task)
|
||||||
|
return make_response(jsonify(task), 200)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_instructions_for_end_user(spiff_task: SpiffTask, task: Task) -> str:
|
||||||
|
"""Assure any instructions for end user are processed for jinja syntax."""
|
||||||
if task.properties and "instructionsForEndUser" in task.properties:
|
if task.properties and "instructionsForEndUser" in task.properties:
|
||||||
if task.properties["instructionsForEndUser"]:
|
if task.properties["instructionsForEndUser"]:
|
||||||
try:
|
try:
|
||||||
task.properties["instructionsForEndUser"] = _render_jinja_template(
|
instructions = _render_jinja_template(task.properties["instructionsForEndUser"], spiff_task)
|
||||||
task.properties["instructionsForEndUser"], spiff_task
|
task.properties["instructionsForEndUser"] = instructions
|
||||||
)
|
return instructions
|
||||||
except WorkflowTaskException as wfe:
|
except WorkflowTaskException as wfe:
|
||||||
wfe.add_note("Failed to render instructions for end user.")
|
wfe.add_note("Failed to render instructions for end user.")
|
||||||
raise ApiError.from_workflow_exception("instructions_error", str(wfe), exp=wfe) from wfe
|
raise ApiError.from_workflow_exception("instructions_error", str(wfe), exp=wfe) from wfe
|
||||||
return make_response(jsonify(task), 200)
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def process_data_show(
|
def process_data_show(
|
||||||
@ -381,6 +392,36 @@ def process_data_show(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _interstitial_stream(process_instance_id: int) -> Generator[str, Optional[str], None]:
|
||||||
|
process_instance = _find_process_instance_by_id_or_raise(process_instance_id)
|
||||||
|
processor = ProcessInstanceProcessor(process_instance)
|
||||||
|
reported_ids = [] # bit of an issue with end tasks showing as getting completed twice.
|
||||||
|
spiff_task = processor.next_task()
|
||||||
|
last_task = None
|
||||||
|
while last_task != spiff_task:
|
||||||
|
task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task())
|
||||||
|
instructions = _render_instructions_for_end_user(spiff_task, task)
|
||||||
|
if instructions and spiff_task.id not in reported_ids:
|
||||||
|
reported_ids.append(spiff_task.id)
|
||||||
|
yield f"data: {current_app.json.dumps(task)} \n\n"
|
||||||
|
last_task = spiff_task
|
||||||
|
processor.do_engine_steps(execution_strategy_name="run_until_user_message")
|
||||||
|
processor.do_engine_steps(execution_strategy_name="one_at_a_time")
|
||||||
|
spiff_task = processor.next_task()
|
||||||
|
# Note, this has to be done in case someone leaves the page,
|
||||||
|
# which can otherwise cancel this function and leave completed tasks un-registered.
|
||||||
|
processor.save() # Fixme - maybe find a way not to do this on every loop?
|
||||||
|
if len(reported_ids) == 0:
|
||||||
|
# Always provide some response, in the event no instructions were provided.
|
||||||
|
task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task())
|
||||||
|
yield f"data: {current_app.json.dumps(task)} \n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def interstitial(process_instance_id: int) -> Response:
|
||||||
|
"""A Server Side Events Stream for watching the execution of engine tasks."""
|
||||||
|
return Response(stream_with_context(_interstitial_stream(process_instance_id)), mimetype="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
def _task_submit_shared(
|
def _task_submit_shared(
|
||||||
process_instance_id: int,
|
process_instance_id: int,
|
||||||
task_guid: str,
|
task_guid: str,
|
||||||
@ -462,8 +503,21 @@ def _task_submit_shared(
|
|||||||
)
|
)
|
||||||
if next_human_task_assigned_to_me:
|
if next_human_task_assigned_to_me:
|
||||||
return make_response(jsonify(HumanTaskModel.to_task(next_human_task_assigned_to_me)), 200)
|
return make_response(jsonify(HumanTaskModel.to_task(next_human_task_assigned_to_me)), 200)
|
||||||
|
elif processor.next_task():
|
||||||
|
task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task())
|
||||||
|
return make_response(jsonify(task), 200)
|
||||||
|
|
||||||
return Response(json.dumps({"ok": True}), status=202, mimetype="application/json")
|
return Response(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"process_model_identifier": process_instance.process_model_identifier,
|
||||||
|
"process_instance_id": process_instance_id,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
status=202,
|
||||||
|
mimetype="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def task_submit(
|
def task_submit(
|
||||||
|
@ -1687,7 +1687,7 @@ class ProcessInstanceProcessor:
|
|||||||
self._script_engine.environment.finalize_result,
|
self._script_engine.environment.finalize_result,
|
||||||
self.save,
|
self.save,
|
||||||
)
|
)
|
||||||
execution_service.run(exit_at, save)
|
execution_service.run_and_save(exit_at, save)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_tasks_with_data(cls, bpmn_process_instance: BpmnWorkflow) -> List[SpiffTask]:
|
def get_tasks_with_data(cls, bpmn_process_instance: BpmnWorkflow) -> List[SpiffTask]:
|
||||||
|
@ -11,6 +11,7 @@ from urllib.parse import unquote
|
|||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
from flask import g
|
||||||
from SpiffWorkflow.bpmn.specs.events.IntermediateEvent import _BoundaryEventParent # type: ignore
|
from SpiffWorkflow.bpmn.specs.events.IntermediateEvent import _BoundaryEventParent # type: ignore
|
||||||
from SpiffWorkflow.task import Task as SpiffTask # type: ignore
|
from SpiffWorkflow.task import Task as SpiffTask # type: ignore
|
||||||
|
|
||||||
@ -27,6 +28,8 @@ from spiffworkflow_backend.models.process_model import ProcessModelInfo
|
|||||||
from spiffworkflow_backend.models.task import Task
|
from spiffworkflow_backend.models.task import Task
|
||||||
from spiffworkflow_backend.models.user import UserModel
|
from spiffworkflow_backend.models.user import UserModel
|
||||||
from spiffworkflow_backend.services.authorization_service import AuthorizationService
|
from spiffworkflow_backend.services.authorization_service import AuthorizationService
|
||||||
|
from spiffworkflow_backend.services.authorization_service import HumanTaskNotFoundError
|
||||||
|
from spiffworkflow_backend.services.authorization_service import UserDoesNotHaveAccessToTaskError
|
||||||
from spiffworkflow_backend.services.git_service import GitCommandError
|
from spiffworkflow_backend.services.git_service import GitCommandError
|
||||||
from spiffworkflow_backend.services.git_service import GitService
|
from spiffworkflow_backend.services.git_service import GitService
|
||||||
from spiffworkflow_backend.services.process_instance_processor import (
|
from spiffworkflow_backend.services.process_instance_processor import (
|
||||||
@ -112,10 +115,13 @@ class ProcessInstanceService:
|
|||||||
.filter(ProcessInstanceModel.id.in_(process_instance_ids_to_check)) # type: ignore
|
.filter(ProcessInstanceModel.id.in_(process_instance_ids_to_check)) # type: ignore
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
execution_strategy_name = current_app.config["SPIFFWORKFLOW_BACKEND_ENGINE_STEP_DEFAULT_STRATEGY_BACKGROUND"]
|
||||||
for process_instance in records:
|
for process_instance in records:
|
||||||
current_app.logger.info(f"Processing process_instance {process_instance.id}")
|
current_app.logger.info(f"Processing process_instance {process_instance.id}")
|
||||||
try:
|
try:
|
||||||
cls.run_process_intance_with_processor(process_instance, status_value=status_value)
|
cls.run_process_instance_with_processor(
|
||||||
|
process_instance, status_value=status_value, execution_strategy_name=execution_strategy_name
|
||||||
|
)
|
||||||
except ProcessInstanceIsAlreadyLockedError:
|
except ProcessInstanceIsAlreadyLockedError:
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -127,8 +133,11 @@ class ProcessInstanceService:
|
|||||||
current_app.logger.error(error_message)
|
current_app.logger.error(error_message)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def run_process_intance_with_processor(
|
def run_process_instance_with_processor(
|
||||||
cls, process_instance: ProcessInstanceModel, status_value: Optional[str] = None
|
cls,
|
||||||
|
process_instance: ProcessInstanceModel,
|
||||||
|
status_value: Optional[str] = None,
|
||||||
|
execution_strategy_name: Optional[str] = None,
|
||||||
) -> Optional[ProcessInstanceProcessor]:
|
) -> Optional[ProcessInstanceProcessor]:
|
||||||
processor = None
|
processor = None
|
||||||
with ProcessInstanceQueueService.dequeued(process_instance):
|
with ProcessInstanceQueueService.dequeued(process_instance):
|
||||||
@ -139,9 +148,6 @@ class ProcessInstanceService:
|
|||||||
|
|
||||||
db.session.refresh(process_instance)
|
db.session.refresh(process_instance)
|
||||||
if status_value is None or process_instance.status == status_value:
|
if status_value is None or process_instance.status == status_value:
|
||||||
execution_strategy_name = current_app.config[
|
|
||||||
"SPIFFWORKFLOW_BACKEND_ENGINE_STEP_DEFAULT_STRATEGY_BACKGROUND"
|
|
||||||
]
|
|
||||||
processor.do_engine_steps(save=True, execution_strategy_name=execution_strategy_name)
|
processor.do_engine_steps(save=True, execution_strategy_name=execution_strategy_name)
|
||||||
|
|
||||||
return processor
|
return processor
|
||||||
@ -432,6 +438,19 @@ class ProcessInstanceService:
|
|||||||
else:
|
else:
|
||||||
lane = None
|
lane = None
|
||||||
|
|
||||||
|
# Check for a human task, and if it exists, check to see if the current user
|
||||||
|
# can complete it.
|
||||||
|
can_complete = False
|
||||||
|
try:
|
||||||
|
AuthorizationService.assert_user_can_complete_spiff_task(
|
||||||
|
processor.process_instance_model.id, spiff_task, g.user
|
||||||
|
)
|
||||||
|
can_complete = True
|
||||||
|
except HumanTaskNotFoundError:
|
||||||
|
can_complete = False
|
||||||
|
except UserDoesNotHaveAccessToTaskError:
|
||||||
|
can_complete = False
|
||||||
|
|
||||||
if hasattr(spiff_task.task_spec, "spec"):
|
if hasattr(spiff_task.task_spec, "spec"):
|
||||||
call_activity_process_identifier = spiff_task.task_spec.spec
|
call_activity_process_identifier = spiff_task.task_spec.spec
|
||||||
else:
|
else:
|
||||||
@ -449,8 +468,12 @@ class ProcessInstanceService:
|
|||||||
spiff_task.task_spec.description,
|
spiff_task.task_spec.description,
|
||||||
task_type,
|
task_type,
|
||||||
spiff_task.get_state_name(),
|
spiff_task.get_state_name(),
|
||||||
|
can_complete=can_complete,
|
||||||
lane=lane,
|
lane=lane,
|
||||||
process_identifier=spiff_task.task_spec._wf_spec.name,
|
process_identifier=spiff_task.task_spec._wf_spec.name,
|
||||||
|
process_instance_id=processor.process_instance_model.id,
|
||||||
|
process_model_identifier=processor.process_model_identifier,
|
||||||
|
process_model_display_name=processor.process_model_display_name,
|
||||||
properties=props,
|
properties=props,
|
||||||
parent=parent_id,
|
parent=parent_id,
|
||||||
event_definition=serialized_task_spec.get("event_definition"),
|
event_definition=serialized_task_spec.get("event_definition"),
|
||||||
|
@ -90,6 +90,15 @@ class ExecutionStrategy:
|
|||||||
def save(self, bpmn_process_instance: BpmnWorkflow) -> None:
|
def save(self, bpmn_process_instance: BpmnWorkflow) -> None:
|
||||||
self.delegate.save(bpmn_process_instance)
|
self.delegate.save(bpmn_process_instance)
|
||||||
|
|
||||||
|
def get_ready_engine_steps(self, bpmn_process_instance: BpmnWorkflow) -> list[SpiffTask]:
|
||||||
|
return list(
|
||||||
|
[
|
||||||
|
t
|
||||||
|
for t in bpmn_process_instance.get_tasks(TaskState.READY)
|
||||||
|
if bpmn_process_instance._is_engine_task(t.task_spec)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TaskModelSavingDelegate(EngineStepDelegate):
|
class TaskModelSavingDelegate(EngineStepDelegate):
|
||||||
"""Engine step delegate that takes care of saving a task model to the database.
|
"""Engine step delegate that takes care of saving a task model to the database.
|
||||||
@ -275,30 +284,55 @@ class RunUntilServiceTaskExecutionStrategy(ExecutionStrategy):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def spiff_run(self, bpmn_process_instance: BpmnWorkflow, exit_at: None = None) -> None:
|
def spiff_run(self, bpmn_process_instance: BpmnWorkflow, exit_at: None = None) -> None:
|
||||||
self.bpmn_process_instance = bpmn_process_instance
|
engine_steps = self.get_ready_engine_steps(bpmn_process_instance)
|
||||||
engine_steps = list(
|
|
||||||
[
|
|
||||||
t
|
|
||||||
for t in bpmn_process_instance.get_tasks(TaskState.READY)
|
|
||||||
if bpmn_process_instance._is_engine_task(t.task_spec)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
while engine_steps:
|
while engine_steps:
|
||||||
for spiff_task in engine_steps:
|
for spiff_task in engine_steps:
|
||||||
if spiff_task.task_spec.spec_type == "Service Task":
|
if spiff_task.task_spec.spec_type == "Service Task":
|
||||||
return
|
return
|
||||||
self.delegate.will_complete_task(spiff_task)
|
self.delegate.will_complete_task(spiff_task)
|
||||||
spiff_task.complete()
|
spiff_task.run()
|
||||||
self.delegate.did_complete_task(spiff_task)
|
self.delegate.did_complete_task(spiff_task)
|
||||||
|
bpmn_process_instance.refresh_waiting_tasks()
|
||||||
|
engine_steps = self.get_ready_engine_steps(bpmn_process_instance)
|
||||||
|
self.delegate.after_engine_steps(bpmn_process_instance)
|
||||||
|
|
||||||
engine_steps = list(
|
|
||||||
[
|
|
||||||
t
|
|
||||||
for t in bpmn_process_instance.get_tasks(TaskState.READY)
|
|
||||||
if bpmn_process_instance._is_engine_task(t.task_spec)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
class RunUntilUserTaskOrMessageExecutionStrategy(ExecutionStrategy):
|
||||||
|
"""When you want to run tasks until you hit something to report to the end user."""
|
||||||
|
|
||||||
|
def get_engine_steps(self, bpmn_process_instance: BpmnWorkflow) -> list[SpiffTask]:
|
||||||
|
return list(
|
||||||
|
[
|
||||||
|
t
|
||||||
|
for t in bpmn_process_instance.get_tasks(TaskState.READY)
|
||||||
|
if t.task_spec.spec_type not in ["User Task", "Manual Task"]
|
||||||
|
and not (
|
||||||
|
hasattr(t.task_spec, "extensions") and t.task_spec.extensions.get("instructionsForEndUser", None)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def spiff_run(self, bpmn_process_instance: BpmnWorkflow, exit_at: None = None) -> None:
|
||||||
|
engine_steps = self.get_engine_steps(bpmn_process_instance)
|
||||||
|
while engine_steps:
|
||||||
|
for task in engine_steps:
|
||||||
|
self.delegate.will_complete_task(task)
|
||||||
|
task.run()
|
||||||
|
self.delegate.did_complete_task(task)
|
||||||
|
engine_steps = self.get_engine_steps(bpmn_process_instance)
|
||||||
|
self.delegate.after_engine_steps(bpmn_process_instance)
|
||||||
|
|
||||||
|
|
||||||
|
class OneAtATimeExecutionStrategy(ExecutionStrategy):
|
||||||
|
"""When you want to run only one engine step at a time."""
|
||||||
|
|
||||||
|
def spiff_run(self, bpmn_process_instance: BpmnWorkflow, exit_at: None = None) -> None:
|
||||||
|
engine_steps = self.get_ready_engine_steps(bpmn_process_instance)
|
||||||
|
if len(engine_steps) > 0:
|
||||||
|
spiff_task = engine_steps[0]
|
||||||
|
self.delegate.will_complete_task(spiff_task)
|
||||||
|
spiff_task.run()
|
||||||
|
self.delegate.did_complete_task(spiff_task)
|
||||||
self.delegate.after_engine_steps(bpmn_process_instance)
|
self.delegate.after_engine_steps(bpmn_process_instance)
|
||||||
|
|
||||||
|
|
||||||
@ -306,9 +340,11 @@ def execution_strategy_named(name: str, delegate: EngineStepDelegate) -> Executi
|
|||||||
cls = {
|
cls = {
|
||||||
"greedy": GreedyExecutionStrategy,
|
"greedy": GreedyExecutionStrategy,
|
||||||
"run_until_service_task": RunUntilServiceTaskExecutionStrategy,
|
"run_until_service_task": RunUntilServiceTaskExecutionStrategy,
|
||||||
|
"run_until_user_message": RunUntilUserTaskOrMessageExecutionStrategy,
|
||||||
|
"one_at_a_time": OneAtATimeExecutionStrategy,
|
||||||
}[name]
|
}[name]
|
||||||
|
|
||||||
return cls(delegate)
|
return cls(delegate) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
ProcessInstanceCompleter = Callable[[BpmnWorkflow], None]
|
ProcessInstanceCompleter = Callable[[BpmnWorkflow], None]
|
||||||
@ -338,7 +374,7 @@ class WorkflowExecutionService:
|
|||||||
# run
|
# run
|
||||||
# execution_strategy.spiff_run
|
# execution_strategy.spiff_run
|
||||||
# spiff.[some_run_task_method]
|
# spiff.[some_run_task_method]
|
||||||
def run(self, exit_at: None = None, save: bool = False) -> None:
|
def run_and_save(self, exit_at: None = None, save: bool = False) -> None:
|
||||||
"""Do_engine_steps."""
|
"""Do_engine_steps."""
|
||||||
with safe_assertion(ProcessInstanceLockService.has_lock(self.process_instance_model.id)) as tripped:
|
with safe_assertion(ProcessInstanceLockService.has_lock(self.process_instance_model.id)) as tripped:
|
||||||
if tripped:
|
if tripped:
|
||||||
@ -384,7 +420,8 @@ class WorkflowExecutionService:
|
|||||||
for bpmn_message in bpmn_messages:
|
for bpmn_message in bpmn_messages:
|
||||||
message_instance = MessageInstanceModel(
|
message_instance = MessageInstanceModel(
|
||||||
process_instance_id=self.process_instance_model.id,
|
process_instance_id=self.process_instance_model.id,
|
||||||
user_id=self.process_instance_model.process_initiator_id, # TODO: use the correct swimlane user when that is set up
|
user_id=self.process_instance_model.process_initiator_id,
|
||||||
|
# TODO: use the correct swimlane user when that is set up
|
||||||
message_type="send",
|
message_type="send",
|
||||||
name=bpmn_message.name,
|
name=bpmn_message.name,
|
||||||
payload=bpmn_message.payload,
|
payload=bpmn_message.payload,
|
||||||
@ -449,11 +486,11 @@ class WorkflowExecutionService:
|
|||||||
class ProfiledWorkflowExecutionService(WorkflowExecutionService):
|
class ProfiledWorkflowExecutionService(WorkflowExecutionService):
|
||||||
"""A profiled version of the workflow execution service."""
|
"""A profiled version of the workflow execution service."""
|
||||||
|
|
||||||
def run(self, exit_at: None = None, save: bool = False) -> None:
|
def run_and_save(self, exit_at: None = None, save: bool = False) -> None:
|
||||||
"""__do_engine_steps."""
|
"""__do_engine_steps."""
|
||||||
import cProfile
|
import cProfile
|
||||||
from pstats import SortKey
|
from pstats import SortKey
|
||||||
|
|
||||||
with cProfile.Profile() as pr:
|
with cProfile.Profile() as pr:
|
||||||
super().run(exit_at=exit_at, save=save)
|
super().run_and_save(exit_at=exit_at, save=save)
|
||||||
pr.print_stats(sort=SortKey.CUMULATIVE)
|
pr.print_stats(sort=SortKey.CUMULATIVE)
|
||||||
|
123
spiffworkflow-backend/tests/data/interstitial/interstitial.bpmn
Normal file
123
spiffworkflow-backend/tests/data/interstitial/interstitial.bpmn
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
|
||||||
|
<bpmn:collaboration id="Collaboration_1ullb3f">
|
||||||
|
<bpmn:participant id="Participant_1mug4yn" processRef="Process_a6ss9w7" />
|
||||||
|
</bpmn:collaboration>
|
||||||
|
<bpmn:process id="Process_a6ss9w7" isExecutable="true">
|
||||||
|
<bpmn:laneSet id="LaneSet_1m2geb1">
|
||||||
|
<bpmn:lane id="Lane_0518vyo">
|
||||||
|
<bpmn:flowNodeRef>StartEvent_1</bpmn:flowNodeRef>
|
||||||
|
<bpmn:flowNodeRef>Activity_16m8jvv</bpmn:flowNodeRef>
|
||||||
|
<bpmn:flowNodeRef>Activity_1qrme8m</bpmn:flowNodeRef>
|
||||||
|
<bpmn:flowNodeRef>Activity_0bi0v5d</bpmn:flowNodeRef>
|
||||||
|
<bpmn:flowNodeRef>Event_1vyxv42</bpmn:flowNodeRef>
|
||||||
|
</bpmn:lane>
|
||||||
|
<bpmn:lane id="Lane_0mx423x" name="Finance Team">
|
||||||
|
<bpmn:flowNodeRef>Activity_02ldrj6</bpmn:flowNodeRef>
|
||||||
|
</bpmn:lane>
|
||||||
|
</bpmn:laneSet>
|
||||||
|
<bpmn:startEvent id="StartEvent_1">
|
||||||
|
<bpmn:outgoing>Flow_1rnrr8l</bpmn:outgoing>
|
||||||
|
</bpmn:startEvent>
|
||||||
|
<bpmn:sequenceFlow id="Flow_1rnrr8l" sourceRef="StartEvent_1" targetRef="Activity_16m8jvv" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_011ysja" sourceRef="Activity_16m8jvv" targetRef="Activity_1qrme8m" />
|
||||||
|
<bpmn:scriptTask id="Activity_16m8jvv" name="Script Task #1">
|
||||||
|
<bpmn:extensionElements>
|
||||||
|
<spiffworkflow:instructionsForEndUser />
|
||||||
|
</bpmn:extensionElements>
|
||||||
|
<bpmn:incoming>Flow_1rnrr8l</bpmn:incoming>
|
||||||
|
<bpmn:outgoing>Flow_011ysja</bpmn:outgoing>
|
||||||
|
<bpmn:script>x = 2</bpmn:script>
|
||||||
|
</bpmn:scriptTask>
|
||||||
|
<bpmn:scriptTask id="Activity_1qrme8m" name="Script Task #2">
|
||||||
|
<bpmn:extensionElements>
|
||||||
|
<spiffworkflow:instructionsForEndUser>I am Script Task {{x}}</spiffworkflow:instructionsForEndUser>
|
||||||
|
</bpmn:extensionElements>
|
||||||
|
<bpmn:incoming>Flow_011ysja</bpmn:incoming>
|
||||||
|
<bpmn:outgoing>Flow_1rab9xv</bpmn:outgoing>
|
||||||
|
<bpmn:script>x = 2</bpmn:script>
|
||||||
|
</bpmn:scriptTask>
|
||||||
|
<bpmn:sequenceFlow id="Flow_1rab9xv" sourceRef="Activity_1qrme8m" targetRef="Activity_0bi0v5d" />
|
||||||
|
<bpmn:manualTask id="Activity_0bi0v5d" name="Manual Task">
|
||||||
|
<bpmn:extensionElements>
|
||||||
|
<spiffworkflow:instructionsForEndUser>I am a manual task</spiffworkflow:instructionsForEndUser>
|
||||||
|
</bpmn:extensionElements>
|
||||||
|
<bpmn:incoming>Flow_1rab9xv</bpmn:incoming>
|
||||||
|
<bpmn:outgoing>Flow_1icul0s</bpmn:outgoing>
|
||||||
|
</bpmn:manualTask>
|
||||||
|
<bpmn:sequenceFlow id="Flow_1icul0s" sourceRef="Activity_0bi0v5d" targetRef="Activity_02ldrj6" />
|
||||||
|
<bpmn:manualTask id="Activity_02ldrj6" name="Please Approve">
|
||||||
|
<bpmn:extensionElements>
|
||||||
|
<spiffworkflow:instructionsForEndUser>I am a manual task in another lane</spiffworkflow:instructionsForEndUser>
|
||||||
|
</bpmn:extensionElements>
|
||||||
|
<bpmn:incoming>Flow_1icul0s</bpmn:incoming>
|
||||||
|
<bpmn:outgoing>Flow_06qy6r3</bpmn:outgoing>
|
||||||
|
</bpmn:manualTask>
|
||||||
|
<bpmn:sequenceFlow id="Flow_06qy6r3" sourceRef="Activity_02ldrj6" targetRef="Event_1vyxv42" />
|
||||||
|
<bpmn:endEvent id="Event_1vyxv42">
|
||||||
|
<bpmn:extensionElements>
|
||||||
|
<spiffworkflow:instructionsForEndUser>I am the end task</spiffworkflow:instructionsForEndUser>
|
||||||
|
</bpmn:extensionElements>
|
||||||
|
<bpmn:incoming>Flow_06qy6r3</bpmn:incoming>
|
||||||
|
</bpmn:endEvent>
|
||||||
|
</bpmn:process>
|
||||||
|
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
||||||
|
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Collaboration_1ullb3f">
|
||||||
|
<bpmndi:BPMNShape id="Participant_1mug4yn_di" bpmnElement="Participant_1mug4yn" isHorizontal="true">
|
||||||
|
<dc:Bounds x="129" y="130" width="971" height="250" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Lane_0mx423x_di" bpmnElement="Lane_0mx423x" isHorizontal="true">
|
||||||
|
<dc:Bounds x="159" y="255" width="941" height="125" />
|
||||||
|
<bpmndi:BPMNLabel />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Lane_0518vyo_di" bpmnElement="Lane_0518vyo" isHorizontal="true">
|
||||||
|
<dc:Bounds x="159" y="130" width="941" height="125" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
|
||||||
|
<dc:Bounds x="179" y="172" width="36" height="36" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Activity_0lp6dyb_di" bpmnElement="Activity_16m8jvv">
|
||||||
|
<dc:Bounds x="270" y="150" width="100" height="80" />
|
||||||
|
<bpmndi:BPMNLabel />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Activity_1dlfog4_di" bpmnElement="Activity_1qrme8m">
|
||||||
|
<dc:Bounds x="430" y="150" width="100" height="80" />
|
||||||
|
<bpmndi:BPMNLabel />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Activity_0bpymtg_di" bpmnElement="Activity_0bi0v5d">
|
||||||
|
<dc:Bounds x="580" y="150" width="100" height="80" />
|
||||||
|
<bpmndi:BPMNLabel />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Activity_06oz8dg_di" bpmnElement="Activity_02ldrj6">
|
||||||
|
<dc:Bounds x="730" y="280" width="100" height="80" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Event_1vyxv42_di" bpmnElement="Event_1vyxv42">
|
||||||
|
<dc:Bounds x="872" y="172" width="36" height="36" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNEdge id="Flow_1rnrr8l_di" bpmnElement="Flow_1rnrr8l">
|
||||||
|
<di:waypoint x="215" y="190" />
|
||||||
|
<di:waypoint x="270" y="190" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="Flow_011ysja_di" bpmnElement="Flow_011ysja">
|
||||||
|
<di:waypoint x="370" y="190" />
|
||||||
|
<di:waypoint x="430" y="190" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="Flow_1rab9xv_di" bpmnElement="Flow_1rab9xv">
|
||||||
|
<di:waypoint x="530" y="190" />
|
||||||
|
<di:waypoint x="580" y="190" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="Flow_1icul0s_di" bpmnElement="Flow_1icul0s">
|
||||||
|
<di:waypoint x="680" y="190" />
|
||||||
|
<di:waypoint x="705" y="190" />
|
||||||
|
<di:waypoint x="705" y="320" />
|
||||||
|
<di:waypoint x="730" y="320" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="Flow_06qy6r3_di" bpmnElement="Flow_06qy6r3">
|
||||||
|
<di:waypoint x="830" y="320" />
|
||||||
|
<di:waypoint x="851" y="320" />
|
||||||
|
<di:waypoint x="851" y="190" />
|
||||||
|
<di:waypoint x="872" y="190" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
</bpmndi:BPMNPlane>
|
||||||
|
</bpmndi:BPMNDiagram>
|
||||||
|
</bpmn:definitions>
|
@ -9,6 +9,7 @@ from tests.spiffworkflow_backend.helpers.test_data import load_test_spec
|
|||||||
from spiffworkflow_backend import db
|
from spiffworkflow_backend import db
|
||||||
from spiffworkflow_backend.models.human_task import HumanTaskModel
|
from spiffworkflow_backend.models.human_task import HumanTaskModel
|
||||||
from spiffworkflow_backend.models.user import UserModel
|
from spiffworkflow_backend.models.user import UserModel
|
||||||
|
from spiffworkflow_backend.routes.tasks_controller import _interstitial_stream
|
||||||
|
|
||||||
|
|
||||||
class TestForGoodErrors(BaseTest):
|
class TestForGoodErrors(BaseTest):
|
||||||
@ -20,6 +21,9 @@ class TestForGoodErrors(BaseTest):
|
|||||||
client: FlaskClient,
|
client: FlaskClient,
|
||||||
with_super_admin_user: UserModel,
|
with_super_admin_user: UserModel,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
|
# Call this to assure all engine-steps are fully processed before we search for human tasks.
|
||||||
|
_interstitial_stream(process_instance_id)
|
||||||
|
|
||||||
"""Returns the next available user task for a given process instance, if possible."""
|
"""Returns the next available user task for a given process instance, if possible."""
|
||||||
human_tasks = (
|
human_tasks = (
|
||||||
db.session.query(HumanTaskModel).filter(HumanTaskModel.process_instance_id == process_instance_id).all()
|
db.session.query(HumanTaskModel).filter(HumanTaskModel.process_instance_id == process_instance_id).all()
|
||||||
|
@ -33,6 +33,7 @@ from spiffworkflow_backend.models.process_model import ProcessModelInfoSchema
|
|||||||
from spiffworkflow_backend.models.spec_reference import SpecReferenceCache
|
from spiffworkflow_backend.models.spec_reference import SpecReferenceCache
|
||||||
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
|
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
|
||||||
from spiffworkflow_backend.models.user import UserModel
|
from spiffworkflow_backend.models.user import UserModel
|
||||||
|
from spiffworkflow_backend.routes.tasks_controller import _interstitial_stream
|
||||||
from spiffworkflow_backend.services.authorization_service import AuthorizationService
|
from spiffworkflow_backend.services.authorization_service import AuthorizationService
|
||||||
from spiffworkflow_backend.services.file_system_service import FileSystemService
|
from spiffworkflow_backend.services.file_system_service import FileSystemService
|
||||||
from spiffworkflow_backend.services.process_caller_service import ProcessCallerService
|
from spiffworkflow_backend.services.process_caller_service import ProcessCallerService
|
||||||
@ -1628,7 +1629,8 @@ class TestProcessApi(BaseTest):
|
|||||||
f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model_identifier)}/{process_instance_id}/run",
|
f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model_identifier)}/{process_instance_id}/run",
|
||||||
headers=self.logged_in_headers(with_super_admin_user),
|
headers=self.logged_in_headers(with_super_admin_user),
|
||||||
)
|
)
|
||||||
|
# Call this to assure all engine-steps are fully processed.
|
||||||
|
_interstitial_stream(process_instance_id)
|
||||||
assert response.json is not None
|
assert response.json is not None
|
||||||
assert response.json["next_task"] is not None
|
assert response.json["next_task"] is not None
|
||||||
|
|
||||||
@ -1653,6 +1655,91 @@ class TestProcessApi(BaseTest):
|
|||||||
"veryImportantFieldButOnlySometimes": {"ui:widget": "hidden"},
|
"veryImportantFieldButOnlySometimes": {"ui:widget": "hidden"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def test_interstitial_page(
|
||||||
|
self,
|
||||||
|
app: Flask,
|
||||||
|
client: FlaskClient,
|
||||||
|
with_db_and_bpmn_file_cleanup: None,
|
||||||
|
with_super_admin_user: UserModel,
|
||||||
|
) -> None:
|
||||||
|
process_group_id = "my_process_group"
|
||||||
|
process_model_id = "interstitial"
|
||||||
|
bpmn_file_location = "interstitial"
|
||||||
|
# Assure we have someone in the finance team
|
||||||
|
finance_user = self.find_or_create_user("testuser2")
|
||||||
|
AuthorizationService.import_permissions_from_yaml_file()
|
||||||
|
process_model_identifier = self.create_group_and_model_with_bpmn(
|
||||||
|
client,
|
||||||
|
with_super_admin_user,
|
||||||
|
process_group_id=process_group_id,
|
||||||
|
process_model_id=process_model_id,
|
||||||
|
bpmn_file_location=bpmn_file_location,
|
||||||
|
)
|
||||||
|
headers = self.logged_in_headers(with_super_admin_user)
|
||||||
|
response = self.create_process_instance_from_process_model_id_with_api(
|
||||||
|
client, process_model_identifier, headers
|
||||||
|
)
|
||||||
|
assert response.json is not None
|
||||||
|
process_instance_id = response.json["id"]
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model_identifier)}/{process_instance_id}/run",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.json is not None
|
||||||
|
assert response.json["next_task"] is not None
|
||||||
|
assert response.json["next_task"]["state"] == "READY"
|
||||||
|
assert response.json["next_task"]["title"] == "Script Task #2"
|
||||||
|
|
||||||
|
# Rather that call the API and deal with the Server Side Events, call the loop directly and covert it to
|
||||||
|
# a list. It tests all of our code. No reason to test Flasks SSE support.
|
||||||
|
stream_results = _interstitial_stream(process_instance_id)
|
||||||
|
results = list(stream_results)
|
||||||
|
# strip the "data:" prefix and convert remaining string to dict.
|
||||||
|
json_results = list(map(lambda x: json.loads(x[5:]), results)) # type: ignore
|
||||||
|
# There should be 2 results back -
|
||||||
|
# the first script task should not be returned (it contains no end user instructions)
|
||||||
|
# The second script task should produce rendered jinja text
|
||||||
|
# The Manual Task should then return a message as well.
|
||||||
|
assert len(results) == 2
|
||||||
|
assert json_results[0]["state"] == "READY"
|
||||||
|
assert json_results[0]["title"] == "Script Task #2"
|
||||||
|
assert json_results[0]["properties"]["instructionsForEndUser"] == "I am Script Task 2"
|
||||||
|
assert json_results[1]["state"] == "READY"
|
||||||
|
assert json_results[1]["title"] == "Manual Task"
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/v1.0/tasks/{process_instance_id}/{json_results[1]['id']}",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.json is not None
|
||||||
|
|
||||||
|
# we should now be on a task that does not belong to the original user, and the interstitial page should know this.
|
||||||
|
results = list(_interstitial_stream(process_instance_id))
|
||||||
|
json_results = list(map(lambda x: json.loads(x[5:]), results)) # type: ignore
|
||||||
|
assert len(results) == 1
|
||||||
|
assert json_results[0]["state"] == "READY"
|
||||||
|
assert json_results[0]["can_complete"] is False
|
||||||
|
assert json_results[0]["title"] == "Please Approve"
|
||||||
|
assert json_results[0]["properties"]["instructionsForEndUser"] == "I am a manual task in another lane"
|
||||||
|
|
||||||
|
# Complete task as the finance user.
|
||||||
|
response = client.put(
|
||||||
|
f"/v1.0/tasks/{process_instance_id}/{json_results[0]['id']}",
|
||||||
|
headers=self.logged_in_headers(finance_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
# We should now be on the end task with a valid message, even after loading it many times.
|
||||||
|
list(_interstitial_stream(process_instance_id))
|
||||||
|
list(_interstitial_stream(process_instance_id))
|
||||||
|
results = list(_interstitial_stream(process_instance_id))
|
||||||
|
json_results = list(map(lambda x: json.loads(x[5:]), results)) # type: ignore
|
||||||
|
assert len(json_results) == 1
|
||||||
|
assert json_results[0]["state"] == "COMPLETED"
|
||||||
|
assert json_results[0]["properties"]["instructionsForEndUser"] == "I am the end task"
|
||||||
|
|
||||||
def test_process_instance_list_with_default_list(
|
def test_process_instance_list_with_default_list(
|
||||||
self,
|
self,
|
||||||
app: Flask,
|
app: Flask,
|
||||||
@ -2347,7 +2434,7 @@ class TestProcessApi(BaseTest):
|
|||||||
f"/v1.0/tasks/{process_instance_id}/{task_id}",
|
f"/v1.0/tasks/{process_instance_id}/{task_id}",
|
||||||
headers=self.logged_in_headers(initiator_user),
|
headers=self.logged_in_headers(initiator_user),
|
||||||
)
|
)
|
||||||
assert response.status_code == 202
|
assert response.status_code == 200
|
||||||
|
|
||||||
response = client.get(
|
response = client.get(
|
||||||
"/v1.0/tasks",
|
"/v1.0/tasks",
|
||||||
|
@ -371,12 +371,13 @@ class TestProcessInstanceProcessor(BaseTest):
|
|||||||
)
|
)
|
||||||
assert top_level_subprocess_script_spiff_task is not None
|
assert top_level_subprocess_script_spiff_task is not None
|
||||||
processor.resume()
|
processor.resume()
|
||||||
processor.do_engine_steps(save=True)
|
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||||
|
|
||||||
assert len(process_instance.active_human_tasks) == 1
|
assert len(process_instance.active_human_tasks) == 1
|
||||||
human_task_one = process_instance.active_human_tasks[0]
|
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))
|
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, {}, initiator_user, human_task_one)
|
ProcessInstanceService.complete_form_task(processor, spiff_manual_task, {}, initiator_user, human_task_one)
|
||||||
|
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||||
|
|
||||||
assert process_instance.status == "complete"
|
assert process_instance.status == "complete"
|
||||||
|
|
||||||
@ -405,7 +406,7 @@ class TestProcessInstanceProcessor(BaseTest):
|
|||||||
process_model=process_model, user=initiator_user
|
process_model=process_model, user=initiator_user
|
||||||
)
|
)
|
||||||
processor = ProcessInstanceProcessor(process_instance)
|
processor = ProcessInstanceProcessor(process_instance)
|
||||||
processor.do_engine_steps(save=True)
|
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||||
assert len(process_instance.active_human_tasks) == 1
|
assert len(process_instance.active_human_tasks) == 1
|
||||||
initial_human_task_id = process_instance.active_human_tasks[0].id
|
initial_human_task_id = process_instance.active_human_tasks[0].id
|
||||||
|
|
||||||
@ -424,9 +425,11 @@ class TestProcessInstanceProcessor(BaseTest):
|
|||||||
|
|
||||||
process_instance = ProcessInstanceModel.query.filter_by(id=process_instance.id).first()
|
process_instance = ProcessInstanceModel.query.filter_by(id=process_instance.id).first()
|
||||||
processor = ProcessInstanceProcessor(process_instance)
|
processor = ProcessInstanceProcessor(process_instance)
|
||||||
|
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||||
human_task_one = process_instance.active_human_tasks[0]
|
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))
|
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, {}, initiator_user, human_task_one)
|
ProcessInstanceService.complete_form_task(processor, spiff_manual_task, {}, initiator_user, human_task_one)
|
||||||
|
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||||
human_task_one = process_instance.active_human_tasks[0]
|
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))
|
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, {}, initiator_user, human_task_one)
|
ProcessInstanceService.complete_form_task(processor, spiff_manual_task, {}, initiator_user, human_task_one)
|
||||||
@ -434,6 +437,8 @@ class TestProcessInstanceProcessor(BaseTest):
|
|||||||
# recreate variables to ensure all bpmn json was recreated from scratch from the db
|
# recreate variables to ensure all bpmn json was recreated from scratch from the db
|
||||||
process_instance_relookup = ProcessInstanceModel.query.filter_by(id=process_instance.id).first()
|
process_instance_relookup = ProcessInstanceModel.query.filter_by(id=process_instance.id).first()
|
||||||
processor_final = ProcessInstanceProcessor(process_instance_relookup)
|
processor_final = ProcessInstanceProcessor(process_instance_relookup)
|
||||||
|
processor_final.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||||
|
|
||||||
assert process_instance_relookup.status == "complete"
|
assert process_instance_relookup.status == "complete"
|
||||||
|
|
||||||
data_set_1 = {"set_in_top_level_script": 1}
|
data_set_1 = {"set_in_top_level_script": 1}
|
||||||
@ -603,7 +608,7 @@ class TestProcessInstanceProcessor(BaseTest):
|
|||||||
)
|
)
|
||||||
assert task_models_that_are_predicted_count == 0
|
assert task_models_that_are_predicted_count == 0
|
||||||
|
|
||||||
assert processor.get_data() == data_set_7
|
assert processor_final.get_data() == data_set_7
|
||||||
|
|
||||||
def test_does_not_recreate_human_tasks_on_multiple_saves(
|
def test_does_not_recreate_human_tasks_on_multiple_saves(
|
||||||
self,
|
self,
|
||||||
@ -690,7 +695,7 @@ class TestProcessInstanceProcessor(BaseTest):
|
|||||||
process_model=process_model, user=initiator_user
|
process_model=process_model, user=initiator_user
|
||||||
)
|
)
|
||||||
processor = ProcessInstanceProcessor(process_instance)
|
processor = ProcessInstanceProcessor(process_instance)
|
||||||
processor.do_engine_steps(save=True)
|
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||||
|
|
||||||
assert len(process_instance.active_human_tasks) == 1
|
assert len(process_instance.active_human_tasks) == 1
|
||||||
assert len(process_instance.human_tasks) == 1
|
assert len(process_instance.human_tasks) == 1
|
||||||
@ -700,6 +705,7 @@ class TestProcessInstanceProcessor(BaseTest):
|
|||||||
ProcessInstanceService.complete_form_task(processor, spiff_task, {}, initiator_user, human_task_one)
|
ProcessInstanceService.complete_form_task(processor, spiff_task, {}, initiator_user, human_task_one)
|
||||||
|
|
||||||
processor = ProcessInstanceProcessor(process_instance)
|
processor = ProcessInstanceProcessor(process_instance)
|
||||||
|
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||||
assert len(process_instance.active_human_tasks) == 1
|
assert len(process_instance.active_human_tasks) == 1
|
||||||
assert len(process_instance.human_tasks) == 2
|
assert len(process_instance.human_tasks) == 2
|
||||||
human_task_two = process_instance.active_human_tasks[0]
|
human_task_two = process_instance.active_human_tasks[0]
|
||||||
@ -708,6 +714,7 @@ class TestProcessInstanceProcessor(BaseTest):
|
|||||||
|
|
||||||
# ensure this does not raise a KeyError
|
# ensure this does not raise a KeyError
|
||||||
processor = ProcessInstanceProcessor(process_instance)
|
processor = ProcessInstanceProcessor(process_instance)
|
||||||
|
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||||
assert len(process_instance.active_human_tasks) == 1
|
assert len(process_instance.active_human_tasks) == 1
|
||||||
assert len(process_instance.human_tasks) == 3
|
assert len(process_instance.human_tasks) == 3
|
||||||
human_task_three = process_instance.active_human_tasks[0]
|
human_task_three = process_instance.active_human_tasks[0]
|
||||||
|
@ -43,7 +43,7 @@ class TestProcessModel(BaseTest):
|
|||||||
|
|
||||||
process_instance = self.create_process_instance_from_process_model(process_model)
|
process_instance = self.create_process_instance_from_process_model(process_model)
|
||||||
processor = ProcessInstanceProcessor(process_instance)
|
processor = ProcessInstanceProcessor(process_instance)
|
||||||
processor.do_engine_steps(save=True)
|
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||||
assert process_instance.status == "complete"
|
assert process_instance.status == "complete"
|
||||||
|
|
||||||
def test_can_run_process_model_with_call_activities_when_not_in_same_directory(
|
def test_can_run_process_model_with_call_activities_when_not_in_same_directory(
|
||||||
@ -74,7 +74,7 @@ class TestProcessModel(BaseTest):
|
|||||||
)
|
)
|
||||||
process_instance = self.create_process_instance_from_process_model(process_model)
|
process_instance = self.create_process_instance_from_process_model(process_model)
|
||||||
processor = ProcessInstanceProcessor(process_instance)
|
processor = ProcessInstanceProcessor(process_instance)
|
||||||
processor.do_engine_steps(save=True)
|
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||||
assert process_instance.status == "complete"
|
assert process_instance.status == "complete"
|
||||||
|
|
||||||
def test_can_run_process_model_with_call_activities_when_process_identifier_is_not_in_database(
|
def test_can_run_process_model_with_call_activities_when_process_identifier_is_not_in_database(
|
||||||
@ -110,7 +110,7 @@ class TestProcessModel(BaseTest):
|
|||||||
db.session.query(SpecReferenceCache).delete()
|
db.session.query(SpecReferenceCache).delete()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
processor = ProcessInstanceProcessor(process_instance)
|
processor = ProcessInstanceProcessor(process_instance)
|
||||||
processor.do_engine_steps(save=True)
|
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||||
assert process_instance.status == "complete"
|
assert process_instance.status == "complete"
|
||||||
|
|
||||||
def test_extract_metadata(
|
def test_extract_metadata(
|
||||||
|
@ -21,6 +21,12 @@ module.exports = {
|
|||||||
importSource: '@bpmn-io/properties-panel/preact',
|
importSource: '@bpmn-io/properties-panel/preact',
|
||||||
runtime: 'automatic',
|
runtime: 'automatic',
|
||||||
},
|
},
|
||||||
|
'@babel/plugin-proposal-class-properties',
|
||||||
|
{ loose: true },
|
||||||
|
'@babel/plugin-proposal-private-methods',
|
||||||
|
{ loose: true },
|
||||||
|
'@babel/plugin-proposal-private-property-in-object',
|
||||||
|
{ loose: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
15
spiffworkflow-frontend/package-lock.json
generated
15
spiffworkflow-frontend/package-lock.json
generated
@ -16,6 +16,7 @@
|
|||||||
"@carbon/styles": "^1.16.0",
|
"@carbon/styles": "^1.16.0",
|
||||||
"@casl/ability": "^6.3.2",
|
"@casl/ability": "^6.3.2",
|
||||||
"@casl/react": "^3.1.0",
|
"@casl/react": "^3.1.0",
|
||||||
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
"@monaco-editor/react": "^4.4.5",
|
"@monaco-editor/react": "^4.4.5",
|
||||||
"@mui/material": "^5.10.14",
|
"@mui/material": "^5.10.14",
|
||||||
"@react-icons/all-files": "^4.1.0",
|
"@react-icons/all-files": "^4.1.0",
|
||||||
@ -4486,6 +4487,11 @@
|
|||||||
"@lezer/highlight": "^1.0.0"
|
"@lezer/highlight": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@microsoft/fetch-event-source": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="
|
||||||
|
},
|
||||||
"node_modules/@monaco-editor/loader": {
|
"node_modules/@monaco-editor/loader": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.3.3.tgz",
|
||||||
@ -8337,7 +8343,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/bpmn-js-spiffworkflow": {
|
"node_modules/bpmn-js-spiffworkflow": {
|
||||||
"version": "0.0.8",
|
"version": "0.0.8",
|
||||||
"resolved": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#69135655f8a5282bcdaef82705c3d522ef5b4464",
|
"resolved": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#2214ac6432dec77cb2d1362615e6320bfea7df1f",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"inherits": "^2.0.4",
|
"inherits": "^2.0.4",
|
||||||
@ -35363,6 +35369,11 @@
|
|||||||
"@lezer/highlight": "^1.0.0"
|
"@lezer/highlight": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@microsoft/fetch-event-source": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="
|
||||||
|
},
|
||||||
"@monaco-editor/loader": {
|
"@monaco-editor/loader": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.3.3.tgz",
|
||||||
@ -38204,7 +38215,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bpmn-js-spiffworkflow": {
|
"bpmn-js-spiffworkflow": {
|
||||||
"version": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#69135655f8a5282bcdaef82705c3d522ef5b4464",
|
"version": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#2214ac6432dec77cb2d1362615e6320bfea7df1f",
|
||||||
"from": "bpmn-js-spiffworkflow@sartography/bpmn-js-spiffworkflow#main",
|
"from": "bpmn-js-spiffworkflow@sartography/bpmn-js-spiffworkflow#main",
|
||||||
"requires": {
|
"requires": {
|
||||||
"inherits": "^2.0.4",
|
"inherits": "^2.0.4",
|
||||||
|
@ -11,6 +11,7 @@
|
|||||||
"@carbon/styles": "^1.16.0",
|
"@carbon/styles": "^1.16.0",
|
||||||
"@casl/ability": "^6.3.2",
|
"@casl/ability": "^6.3.2",
|
||||||
"@casl/react": "^3.1.0",
|
"@casl/react": "^3.1.0",
|
||||||
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
"@monaco-editor/react": "^4.4.5",
|
"@monaco-editor/react": "^4.4.5",
|
||||||
"@mui/material": "^5.10.14",
|
"@mui/material": "^5.10.14",
|
||||||
"@react-icons/all-files": "^4.1.0",
|
"@react-icons/all-files": "^4.1.0",
|
||||||
|
BIN
spiffworkflow-frontend/public/interstitial/completed.png
Normal file
BIN
spiffworkflow-frontend/public/interstitial/completed.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.0 KiB |
BIN
spiffworkflow-frontend/public/interstitial/locked.png
Normal file
BIN
spiffworkflow-frontend/public/interstitial/locked.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.9 KiB |
BIN
spiffworkflow-frontend/public/interstitial/redirect.png
Normal file
BIN
spiffworkflow-frontend/public/interstitial/redirect.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
BIN
spiffworkflow-frontend/public/interstitial/waiting.png
Normal file
BIN
spiffworkflow-frontend/public/interstitial/waiting.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.7 KiB |
@ -8,6 +8,7 @@ import NavigationBar from './components/NavigationBar';
|
|||||||
import HomePageRoutes from './routes/HomePageRoutes';
|
import HomePageRoutes from './routes/HomePageRoutes';
|
||||||
import ErrorBoundary from './components/ErrorBoundary';
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
import AdminRoutes from './routes/AdminRoutes';
|
import AdminRoutes from './routes/AdminRoutes';
|
||||||
|
import ProcessRoutes from './routes/ProcessRoutes';
|
||||||
|
|
||||||
import { AbilityContext } from './contexts/Can';
|
import { AbilityContext } from './contexts/Can';
|
||||||
import UserService from './services/UserService';
|
import UserService from './services/UserService';
|
||||||
@ -35,6 +36,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/*" element={<HomePageRoutes />} />
|
<Route path="/*" element={<HomePageRoutes />} />
|
||||||
<Route path="/tasks/*" element={<HomePageRoutes />} />
|
<Route path="/tasks/*" element={<HomePageRoutes />} />
|
||||||
|
<Route path="/process/*" element={<ProcessRoutes />} />
|
||||||
<Route path="/admin/*" element={<AdminRoutes />} />
|
<Route path="/admin/*" element={<AdminRoutes />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react';
|
||||||
|
// @ts-ignore
|
||||||
|
import MDEditor from '@uiw/react-md-editor';
|
||||||
|
|
||||||
|
export default function InstructionsForEndUser({ task }: any) {
|
||||||
|
if (!task) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let instructions =
|
||||||
|
'There is no additional instructions or information for this task.';
|
||||||
|
const { properties } = task;
|
||||||
|
const { instructionsForEndUser } = properties;
|
||||||
|
if (instructionsForEndUser) {
|
||||||
|
instructions = instructionsForEndUser;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="markdown">
|
||||||
|
{/*
|
||||||
|
https://www.npmjs.com/package/@uiw/react-md-editor switches to dark mode by default by respecting @media (prefers-color-scheme: dark)
|
||||||
|
This makes it look like our site is broken, so until the rest of the site supports dark mode, turn off dark mode for this component.
|
||||||
|
*/}
|
||||||
|
<div data-color-mode="light">
|
||||||
|
<MDEditor.Markdown source={instructions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -10,6 +10,7 @@ export default function MyCompletedInstances() {
|
|||||||
perPageOptions={[2, 5, 25]}
|
perPageOptions={[2, 5, 25]}
|
||||||
reportIdentifier="system_report_completed_instances_initiated_by_me"
|
reportIdentifier="system_report_completed_instances_initiated_by_me"
|
||||||
showReports={false}
|
showReports={false}
|
||||||
|
showActionsColumn
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1458,28 +1458,28 @@ export default function ProcessInstanceListTable({
|
|||||||
});
|
});
|
||||||
if (showActionsColumn) {
|
if (showActionsColumn) {
|
||||||
let buttonElement = null;
|
let buttonElement = null;
|
||||||
if (row.task_id) {
|
const interstitialUrl = `/process/${modifyProcessIdentifierForPathParam(
|
||||||
const taskUrl = `/tasks/${row.id}/${row.task_id}`;
|
row.process_model_identifier
|
||||||
const regex = new RegExp(`\\b(${preferredUsername}|${userEmail})\\b`);
|
)}/${row.id}/interstitial`;
|
||||||
let hasAccessToCompleteTask = false;
|
const regex = new RegExp(`\\b(${preferredUsername}|${userEmail})\\b`);
|
||||||
if (
|
let hasAccessToCompleteTask = false;
|
||||||
canCompleteAllTasks ||
|
if (
|
||||||
(row.potential_owner_usernames || '').match(regex)
|
canCompleteAllTasks ||
|
||||||
) {
|
(row.potential_owner_usernames || '').match(regex)
|
||||||
hasAccessToCompleteTask = true;
|
) {
|
||||||
}
|
hasAccessToCompleteTask = true;
|
||||||
buttonElement = (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
href={taskUrl}
|
|
||||||
hidden={row.status === 'suspended'}
|
|
||||||
disabled={!hasAccessToCompleteTask}
|
|
||||||
>
|
|
||||||
Go
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buttonElement = (
|
||||||
|
<Button
|
||||||
|
kind={
|
||||||
|
hasAccessToCompleteTask && row.task_id ? 'secondary' : 'tertiary'
|
||||||
|
}
|
||||||
|
href={interstitialUrl}
|
||||||
|
>
|
||||||
|
Go
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
currentRow.push(<td>{buttonElement}</td>);
|
currentRow.push(<td>{buttonElement}</td>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,9 +42,7 @@ export interface Task {
|
|||||||
id: number;
|
id: number;
|
||||||
guid: string;
|
guid: string;
|
||||||
bpmn_identifier: string;
|
bpmn_identifier: string;
|
||||||
|
|
||||||
bpmn_name?: string;
|
bpmn_name?: string;
|
||||||
|
|
||||||
bpmn_process_direct_parent_guid: string;
|
bpmn_process_direct_parent_guid: string;
|
||||||
bpmn_process_definition_identifier: string;
|
bpmn_process_definition_identifier: string;
|
||||||
data: any;
|
data: any;
|
||||||
@ -59,7 +57,7 @@ export interface Task {
|
|||||||
export interface ProcessInstanceTask {
|
export interface ProcessInstanceTask {
|
||||||
id: string;
|
id: string;
|
||||||
task_id: string;
|
task_id: string;
|
||||||
|
can_complete: boolean;
|
||||||
calling_subprocess_task_id: string;
|
calling_subprocess_task_id: string;
|
||||||
created_at_in_seconds: number;
|
created_at_in_seconds: number;
|
||||||
current_user_is_potential_owner: number;
|
current_user_is_potential_owner: number;
|
||||||
|
@ -33,6 +33,7 @@ export default function CompletedInstances() {
|
|||||||
showReports={false}
|
showReports={false}
|
||||||
textToShowIfEmpty="This group has no completed instances at this time."
|
textToShowIfEmpty="This group has no completed instances at this time."
|
||||||
additionalParams={`user_group_identifier=${userGroup}`}
|
additionalParams={`user_group_identifier=${userGroup}`}
|
||||||
|
showActionsColumn
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -61,6 +62,7 @@ export default function CompletedInstances() {
|
|||||||
textToShowIfEmpty="You have no completed instances at this time."
|
textToShowIfEmpty="You have no completed instances at this time."
|
||||||
paginationClassName="with-large-bottom-margin"
|
paginationClassName="with-large-bottom-margin"
|
||||||
autoReload
|
autoReload
|
||||||
|
showActionsColumn
|
||||||
/>
|
/>
|
||||||
<h2
|
<h2
|
||||||
title={withTasksCompletedByMeTitleText}
|
title={withTasksCompletedByMeTitleText}
|
||||||
@ -76,6 +78,7 @@ export default function CompletedInstances() {
|
|||||||
showReports={false}
|
showReports={false}
|
||||||
textToShowIfEmpty="You have no completed instances at this time."
|
textToShowIfEmpty="You have no completed instances at this time."
|
||||||
paginationClassName="with-large-bottom-margin"
|
paginationClassName="with-large-bottom-margin"
|
||||||
|
showActionsColumn
|
||||||
/>
|
/>
|
||||||
{groupTableComponents()}
|
{groupTableComponents()}
|
||||||
</>
|
</>
|
||||||
|
@ -7,6 +7,7 @@ import MyTasks from './MyTasks';
|
|||||||
import CompletedInstances from './CompletedInstances';
|
import CompletedInstances from './CompletedInstances';
|
||||||
import CreateNewInstance from './CreateNewInstance';
|
import CreateNewInstance from './CreateNewInstance';
|
||||||
import InProgressInstances from './InProgressInstances';
|
import InProgressInstances from './InProgressInstances';
|
||||||
|
import ProcessInterstitial from './ProcessInterstitial';
|
||||||
|
|
||||||
export default function HomePageRoutes() {
|
export default function HomePageRoutes() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -55,6 +56,10 @@ export default function HomePageRoutes() {
|
|||||||
<Route path="my-tasks" element={<MyTasks />} />
|
<Route path="my-tasks" element={<MyTasks />} />
|
||||||
<Route path=":process_instance_id/:task_id" element={<TaskShow />} />
|
<Route path=":process_instance_id/:task_id" element={<TaskShow />} />
|
||||||
<Route path="grouped" element={<InProgressInstances />} />
|
<Route path="grouped" element={<InProgressInstances />} />
|
||||||
|
<Route
|
||||||
|
path="process/:process_instance_id/interstitial"
|
||||||
|
element={<ProcessInterstitial />}
|
||||||
|
/>
|
||||||
<Route path="completed-instances" element={<CompletedInstances />} />
|
<Route path="completed-instances" element={<CompletedInstances />} />
|
||||||
<Route path="create-new-instance" element={<CreateNewInstance />} />
|
<Route path="create-new-instance" element={<CreateNewInstance />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
@ -51,7 +51,7 @@ export default function ProcessInstanceList({ variant }: OwnProps) {
|
|||||||
<br />
|
<br />
|
||||||
{processInstanceBreadcrumbElement()}
|
{processInstanceBreadcrumbElement()}
|
||||||
{processInstanceTitleElement()}
|
{processInstanceTitleElement()}
|
||||||
<ProcessInstanceListTable variant={variant} />
|
<ProcessInstanceListTable variant={variant} showActionsColumn />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
161
spiffworkflow-frontend/src/routes/ProcessInterstitial.tsx
Normal file
161
spiffworkflow-frontend/src/routes/ProcessInterstitial.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
||||||
|
// @ts-ignore
|
||||||
|
import { Loading, Grid, Column } from '@carbon/react';
|
||||||
|
import { BACKEND_BASE_URL } from '../config';
|
||||||
|
import { getBasicHeaders } from '../services/HttpService';
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import InstructionsForEndUser from '../components/InstructionsForEndUser';
|
||||||
|
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
|
||||||
|
import { ProcessInstanceTask } from '../interfaces';
|
||||||
|
|
||||||
|
export default function ProcessInterstitial() {
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
const [lastTask, setLastTask] = useState<any>(null);
|
||||||
|
const [state, setState] = useState<string>('RUNNING');
|
||||||
|
const params = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const userTasks = useMemo(() => {
|
||||||
|
return ['User Task', 'Manual Task'];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEventSource(
|
||||||
|
`${BACKEND_BASE_URL}/tasks/${params.process_instance_id}`,
|
||||||
|
{
|
||||||
|
headers: getBasicHeaders(),
|
||||||
|
onmessage(ev) {
|
||||||
|
console.log(data, ev.data);
|
||||||
|
const task = JSON.parse(ev.data);
|
||||||
|
setData((prevData) => [...prevData, task]);
|
||||||
|
setLastTask(task);
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
setState('CLOSED');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []); // it is critical to only run this once.
|
||||||
|
|
||||||
|
const shouldRedirect = useCallback(
|
||||||
|
(myTask: ProcessInstanceTask): boolean => {
|
||||||
|
return myTask && myTask.can_complete && userTasks.includes(myTask.type);
|
||||||
|
},
|
||||||
|
[userTasks]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Added this seperate use effect so that the timer interval will be cleared if
|
||||||
|
// we end up redirecting back to the TaskShow page.
|
||||||
|
if (shouldRedirect(lastTask)) {
|
||||||
|
setState('REDIRECTING');
|
||||||
|
lastTask.properties.instructionsForEndUser = '';
|
||||||
|
const timerId = setInterval(() => {
|
||||||
|
navigate(`/tasks/${lastTask.process_instance_id}/${lastTask.id}`);
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(timerId);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [lastTask, navigate, userTasks, shouldRedirect]);
|
||||||
|
|
||||||
|
const getStatus = (): string => {
|
||||||
|
if (!lastTask.can_complete && userTasks.includes(lastTask.type)) {
|
||||||
|
return 'LOCKED';
|
||||||
|
}
|
||||||
|
if (state === 'CLOSED') {
|
||||||
|
return lastTask.state;
|
||||||
|
}
|
||||||
|
console.log('The State is: ', state);
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusImage = () => {
|
||||||
|
switch (getStatus()) {
|
||||||
|
case 'RUNNING':
|
||||||
|
return (
|
||||||
|
<Loading description="Active loading indicator" withOverlay={false} />
|
||||||
|
);
|
||||||
|
case 'LOCKED':
|
||||||
|
return <img src="/interstitial/locked.png" alt="Locked" />;
|
||||||
|
case 'REDIRECTING':
|
||||||
|
return <img src="/interstitial/redirect.png" alt="Redirecting ...." />;
|
||||||
|
case 'WAITING':
|
||||||
|
return <img src="/interstitial/waiting.png" alt="Waiting ...." />;
|
||||||
|
case 'COMPLETED':
|
||||||
|
return <img src="/interstitial/completed.png" alt="Completed" />;
|
||||||
|
default:
|
||||||
|
return <br />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function capitalize(str: string): string {
|
||||||
|
console.log('Capitalizing: ', str);
|
||||||
|
if (str && str.length > 0) {
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMessage = (myTask: ProcessInstanceTask) => {
|
||||||
|
if (!myTask.can_complete && userTasks.includes(myTask.type)) {
|
||||||
|
return <div>This next task must be completed by a different person.</div>;
|
||||||
|
}
|
||||||
|
if (shouldRedirect(myTask)) {
|
||||||
|
return <div>Redirecting you to the next task now ...</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<InstructionsForEndUser task={myTask} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** In the event there is no task information and the connection closed,
|
||||||
|
* redirect to the home page. */
|
||||||
|
if (state === 'closed' && lastTask === null) {
|
||||||
|
navigate(`/tasks`);
|
||||||
|
}
|
||||||
|
if (lastTask) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProcessBreadcrumb
|
||||||
|
hotCrumbs={[
|
||||||
|
['Process Groups', '/admin'],
|
||||||
|
{
|
||||||
|
entityToExplode: lastTask.process_model_identifier,
|
||||||
|
entityType: 'process-model-id',
|
||||||
|
linkLastItem: true,
|
||||||
|
},
|
||||||
|
[`Process Instance Id: ${lastTask.process_instance_id}`],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
{getStatusImage()}
|
||||||
|
<div>
|
||||||
|
<h1 style={{ marginBottom: '0em' }}>
|
||||||
|
{lastTask.process_model_display_name}:{' '}
|
||||||
|
{lastTask.process_instance_id}
|
||||||
|
</h1>
|
||||||
|
<div>Status: {capitalize(getStatus())}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
{data.map((d) => (
|
||||||
|
<Grid fullWidth style={{ marginBottom: '1em' }}>
|
||||||
|
<Column md={2} lg={4} sm={2}>
|
||||||
|
Task: <em>{d.title}</em>
|
||||||
|
</Column>
|
||||||
|
<Column md={6} lg={8} sm={4}>
|
||||||
|
{userMessage(d)}
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
14
spiffworkflow-frontend/src/routes/ProcessRoutes.tsx
Normal file
14
spiffworkflow-frontend/src/routes/ProcessRoutes.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
// @ts-ignore
|
||||||
|
import ProcessInterstitial from './ProcessInterstitial';
|
||||||
|
|
||||||
|
export default function ProcessRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path=":process_model_identifier/:process_instance_id/interstitial"
|
||||||
|
element={<ProcessInterstitial />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
@ -13,7 +13,6 @@ import {
|
|||||||
ButtonSet,
|
ButtonSet,
|
||||||
} from '@carbon/react';
|
} from '@carbon/react';
|
||||||
|
|
||||||
import MDEditor from '@uiw/react-md-editor';
|
|
||||||
// eslint-disable-next-line import/no-named-as-default
|
// eslint-disable-next-line import/no-named-as-default
|
||||||
import Form from '../themes/carbon';
|
import Form from '../themes/carbon';
|
||||||
import HttpService from '../services/HttpService';
|
import HttpService from '../services/HttpService';
|
||||||
@ -21,6 +20,7 @@ import useAPIError from '../hooks/UseApiError';
|
|||||||
import { modifyProcessIdentifierForPathParam } from '../helpers';
|
import { modifyProcessIdentifierForPathParam } from '../helpers';
|
||||||
import { ProcessInstanceTask } from '../interfaces';
|
import { ProcessInstanceTask } from '../interfaces';
|
||||||
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
|
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
|
||||||
|
import InstructionsForEndUser from '../components/InstructionsForEndUser';
|
||||||
|
|
||||||
// TODO: move this somewhere else
|
// TODO: move this somewhere else
|
||||||
function TypeAheadWidget({
|
function TypeAheadWidget({
|
||||||
@ -89,13 +89,6 @@ function TypeAheadWidget({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class UnexpectedHumanTaskType extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'UnexpectedHumanTaskType';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum FormSubmitType {
|
enum FormSubmitType {
|
||||||
Default,
|
Default,
|
||||||
Draft,
|
Draft,
|
||||||
@ -107,19 +100,28 @@ export default function TaskShow() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [disabled, setDisabled] = useState(false);
|
const [disabled, setDisabled] = useState(false);
|
||||||
|
|
||||||
// save current form data so that we can avoid validations in certain situations
|
// save current form data so that we can avoid validations in certain situations
|
||||||
const [currentFormObject, setCurrentFormObject] = useState<any>({});
|
const [currentFormObject, setCurrentFormObject] = useState<any>({});
|
||||||
|
|
||||||
const { addError, removeError } = useAPIError();
|
const { addError, removeError } = useAPIError();
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
const navigateToInterstitial = (myTask: ProcessInstanceTask) => {
|
||||||
const supportedHumanTaskTypes = ['User Task', 'Manual Task'];
|
navigate(
|
||||||
|
`/process/${modifyProcessIdentifierForPathParam(
|
||||||
|
myTask.process_model_identifier
|
||||||
|
)}/${myTask.process_instance_id}/interstitial`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const processResult = (result: ProcessInstanceTask) => {
|
const processResult = (result: ProcessInstanceTask) => {
|
||||||
setTask(result);
|
setTask(result);
|
||||||
setDisabled(false);
|
setDisabled(false);
|
||||||
|
|
||||||
|
if (!result.can_complete) {
|
||||||
|
navigateToInterstitial(result);
|
||||||
|
}
|
||||||
|
|
||||||
/* Disable call to load previous tasks -- do not display menu.
|
/* Disable call to load previous tasks -- do not display menu.
|
||||||
const url = `/v1.0/process-instances/for-me/${modifyProcessIdentifierForPathParam(
|
const url = `/v1.0/process-instances/for-me/${modifyProcessIdentifierForPathParam(
|
||||||
result.process_model_identifier
|
result.process_model_identifier
|
||||||
@ -156,7 +158,11 @@ export default function TaskShow() {
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
navigate(`/tasks`);
|
navigate(`/tasks`);
|
||||||
} else if (result.process_instance_id) {
|
} else if (result.process_instance_id) {
|
||||||
navigate(`/tasks/${result.process_instance_id}/${result.id}`);
|
if (result.can_complete) {
|
||||||
|
navigate(`/tasks/${result.process_instance_id}/${result.id}`);
|
||||||
|
} else {
|
||||||
|
navigateToInterstitial(result);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
addError(result);
|
addError(result);
|
||||||
}
|
}
|
||||||
@ -342,10 +348,6 @@ export default function TaskShow() {
|
|||||||
Save as draft
|
Save as draft
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
throw new UnexpectedHumanTaskType(
|
|
||||||
`Invalid task type given: ${task.type}. Only supported types: ${supportedHumanTaskTypes}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
reactFragmentToHideSubmitButton = (
|
reactFragmentToHideSubmitButton = (
|
||||||
<ButtonSet>
|
<ButtonSet>
|
||||||
@ -386,27 +388,6 @@ export default function TaskShow() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const instructionsElement = () => {
|
|
||||||
if (!task) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let instructions = '';
|
|
||||||
if (task.properties.instructionsForEndUser) {
|
|
||||||
instructions = task.properties.instructionsForEndUser;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="markdown">
|
|
||||||
{/*
|
|
||||||
https://www.npmjs.com/package/@uiw/react-md-editor switches to dark mode by default by respecting @media (prefers-color-scheme: dark)
|
|
||||||
This makes it look like our site is broken, so until the rest of the site supports dark mode, turn off dark mode for this component.
|
|
||||||
*/}
|
|
||||||
<div data-color-mode="light">
|
|
||||||
<MDEditor.Markdown source={instructions} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (task) {
|
if (task) {
|
||||||
let statusString = '';
|
let statusString = '';
|
||||||
if (task.state !== 'READY') {
|
if (task.state !== 'READY') {
|
||||||
@ -430,7 +411,7 @@ export default function TaskShow() {
|
|||||||
<h3>
|
<h3>
|
||||||
Task: {task.title} ({task.process_model_display_name}){statusString}
|
Task: {task.title} ({task.process_model_display_name}){statusString}
|
||||||
</h3>
|
</h3>
|
||||||
{instructionsElement()}
|
<InstructionsForEndUser task={task} />
|
||||||
{formElement()}
|
{formElement()}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
@ -8,7 +8,7 @@ const HttpMethods = {
|
|||||||
DELETE: 'DELETE',
|
DELETE: 'DELETE',
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBasicHeaders = (): object => {
|
export const getBasicHeaders = (): Record<string, string> => {
|
||||||
if (UserService.isLoggedIn()) {
|
if (UserService.isLoggedIn()) {
|
||||||
return {
|
return {
|
||||||
Authorization: `Bearer ${UserService.getAccessToken()}`,
|
Authorization: `Bearer ${UserService.getAccessToken()}`,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user