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

This commit is contained in:
jasquat 2023-04-27 07:28:47 -04:00
commit d29a4ca0ae
22 changed files with 2518 additions and 2393 deletions

File diff suppressed because it is too large Load Diff

View File

@ -519,16 +519,16 @@ paths:
schema:
type: string
/processes/{bpmn_process_identifier}/callers:
/processes/callers:
parameters:
- name: bpmn_process_identifier
in: path
in: query
required: true
description: the modified process model id
description: the bpmn process identifier/id (not the name with spaces and not the process model identifier)
schema:
type: string
get:
operationId: spiffworkflow_backend.routes.process_api_blueprint.process_caller_lists
operationId: spiffworkflow_backend.routes.process_api_blueprint.process_caller_list
summary:
Return a list of information about all processes that call the provided process id
tags:

View File

@ -78,7 +78,7 @@ def process_list() -> Any:
return SpecReferenceSchema(many=True).dump(references)
def process_caller_lists(bpmn_process_identifier: str) -> Any:
def process_caller_list(bpmn_process_identifier: str) -> Any:
callers = ProcessCallerService.callers(bpmn_process_identifier)
references = (
SpecReferenceCache.query.filter_by(type="process").filter(SpecReferenceCache.identifier.in_(callers)).all()

View File

@ -20,6 +20,7 @@ from flask import make_response
from flask import stream_with_context
from flask.wrappers import Response
from jinja2 import TemplateSyntaxError
from SpiffWorkflow.bpmn.workflow import BpmnWorkflow # type: ignore
from SpiffWorkflow.exceptions import WorkflowTaskException # type: ignore
from SpiffWorkflow.task import Task as SpiffTask # type: ignore
from SpiffWorkflow.task import TaskState
@ -385,47 +386,90 @@ def _render_instructions_for_end_user(task_model: TaskModel, extensions: Optiona
def _interstitial_stream(process_instance: ProcessInstanceModel) -> Generator[str, Optional[str], None]:
processor = ProcessInstanceProcessor(process_instance)
reported_ids = [] # bit of an issue with end tasks showing as getting completed twice.
spiff_task = processor.next_task()
task_model = TaskModel.query.filter_by(guid=str(spiff_task.id)).first()
last_task = None
while last_task != spiff_task:
task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task())
reported_ids = [] # A list of all the ids reported by this endpoint so far.
def get_reportable_tasks() -> Any:
return processor.bpmn_process_instance.get_tasks(
TaskState.WAITING | TaskState.STARTED | TaskState.READY | TaskState.ERROR
)
def render_instructions(spiff_task: SpiffTask) -> str:
task_model = TaskModel.query.filter_by(guid=str(spiff_task.id)).first()
extensions = TaskService.get_extensions_from_task_model(task_model)
instructions = _render_instructions_for_end_user(task_model, extensions)
if instructions and spiff_task.id not in reported_ids:
reported_ids.append(spiff_task.id)
task.properties = extensions
yield f"data: {current_app.json.dumps(task)} \n\n"
last_task = spiff_task
return _render_instructions_for_end_user(task_model, extensions)
tasks = get_reportable_tasks()
while True:
for spiff_task in tasks:
try:
instructions = render_instructions(spiff_task)
except Exception as e:
api_error = ApiError(
error_code="engine_steps_error",
message=f"Failed to complete an automated task. Error was: {str(e)}",
status_code=400,
)
yield f"data: {current_app.json.dumps(api_error)} \n\n"
raise e
if instructions and spiff_task.id not in reported_ids:
task = ProcessInstanceService.spiff_task_to_api_task(processor, spiff_task)
task.properties = {"instructionsForEndUser": instructions}
yield f"data: {current_app.json.dumps(task)} \n\n"
reported_ids.append(spiff_task.id)
if spiff_task.state == TaskState.READY:
try:
processor.do_engine_steps(execution_strategy_name="one_at_a_time")
processor.do_engine_steps(execution_strategy_name="run_until_user_message")
processor.save() # Fixme - maybe find a way not to do this on every loop?
except WorkflowTaskException as wfe:
api_error = ApiError.from_workflow_exception(
"engine_steps_error", "Failed complete an automated task.", exp=wfe
)
yield f"data: {current_app.json.dumps(api_error)} \n\n"
return
except Exception as e:
api_error = ApiError(
error_code="engine_steps_error",
message=f"Failed to complete an automated task. Error was: {str(e)}",
status_code=400,
)
yield f"data: {current_app.json.dumps(api_error)} \n\n"
return
processor.bpmn_process_instance.refresh_waiting_tasks()
ready_engine_task_count = get_ready_engine_step_count(processor.bpmn_process_instance)
tasks = get_reportable_tasks()
if ready_engine_task_count == 0:
break # No more tasks to report
spiff_task = processor.next_task()
task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task())
if task.id not in reported_ids:
try:
processor.do_engine_steps(execution_strategy_name="one_at_a_time")
processor.do_engine_steps(execution_strategy_name="run_until_user_message")
processor.save() # Fixme - maybe find a way not to do this on every loop?
except WorkflowTaskException as wfe:
api_error = ApiError.from_workflow_exception(
"engine_steps_error", "Failed complete an automated task.", exp=wfe
)
yield f"data: {current_app.json.dumps(api_error)} \n\n"
instructions = render_instructions(spiff_task)
except Exception as e:
api_error = ApiError(
error_code="engine_steps_error",
message=f"Failed complete an automated task. Error was: {str(e)}",
message=f"Failed to complete an automated task. Error was: {str(e)}",
status_code=400,
)
yield f"data: {current_app.json.dumps(api_error)} \n\n"
# Note, this has to be done in case someone leaves the page,
# which can otherwise cancel this function and leave completed tasks un-registered.
spiff_task = processor.next_task()
task_model = TaskModel.query.filter_by(guid=str(spiff_task.id)).first()
# Always provide some response, in the event no instructions were provided.
if len(reported_ids) == 0:
task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task())
raise e
task.properties = {"instructionsForEndUser": instructions}
yield f"data: {current_app.json.dumps(task)} \n\n"
def get_ready_engine_step_count(bpmn_process_instance: BpmnWorkflow) -> int:
return len(
list(
[
t
for t in bpmn_process_instance.get_tasks(TaskState.READY)
if bpmn_process_instance._is_engine_task(t.task_spec)
]
)
)
def _dequeued_interstitial_stream(process_instance_id: int) -> Generator[str, Optional[str], None]:
process_instance = _find_process_instance_by_id_or_raise(process_instance_id)
with ProcessInstanceQueueService.dequeued(process_instance):

View File

@ -572,6 +572,7 @@ class AuthorizationService:
permissions_to_assign: list[PermissionToAssign] = []
permissions_to_assign.append(PermissionToAssign(permission="read", target_uri="/process-instances/for-me"))
permissions_to_assign.append(PermissionToAssign(permission="read", target_uri="/processes"))
permissions_to_assign.append(PermissionToAssign(permission="read", target_uri="/processes/callers"))
permissions_to_assign.append(PermissionToAssign(permission="read", target_uri="/service-tasks"))
permissions_to_assign.append(PermissionToAssign(permission="read", target_uri="/user-groups/for-current-user"))
permissions_to_assign.append(PermissionToAssign(permission="create", target_uri="/users/exists/by-username"))

View File

@ -220,14 +220,15 @@ class TaskService:
if task_model.state == "COMPLETED":
event_type = ProcessInstanceEventType.task_completed.value
timestamp = task_model.end_in_seconds or task_model.start_in_seconds or time.time()
process_instance_event, _process_instance_error_detail = (
ProcessInstanceTmpService.add_event_to_process_instance(
self.process_instance,
event_type,
task_guid=task_model.guid,
timestamp=timestamp,
add_to_db_session=False,
)
(
process_instance_event,
_process_instance_error_detail,
) = ProcessInstanceTmpService.add_event_to_process_instance(
self.process_instance,
event_type,
task_guid=task_model.guid,
timestamp=timestamp,
add_to_db_session=False,
)
self.process_instance_events[task_model.guid] = process_instance_event
@ -454,7 +455,6 @@ class TaskService:
spiff_task,
self.bpmn_definition_to_task_definitions_mappings,
)
self.update_task_model(task_model, spiff_task)
self.task_models[task_model.guid] = task_model

View File

@ -299,28 +299,28 @@ class RunUntilServiceTaskExecutionStrategy(ExecutionStrategy):
class RunUntilUserTaskOrMessageExecutionStrategy(ExecutionStrategy):
"""When you want to run tasks until you hit something to report to the end user."""
"""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)
)
]
)
Note that this will run at least one engine step if possible,
but will stop if it hits instructions after the first task.
"""
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:
should_continue = True
bpmn_process_instance.refresh_waiting_tasks()
engine_steps = self.get_ready_engine_steps(bpmn_process_instance)
while engine_steps and should_continue:
for task in engine_steps:
if hasattr(task.task_spec, "extensions") and task.task_spec.extensions.get(
"instructionsForEndUser", None
):
should_continue = False
break
self.delegate.will_complete_task(task)
task.run()
self.delegate.did_complete_task(task)
engine_steps = self.get_engine_steps(bpmn_process_instance)
bpmn_process_instance.refresh_waiting_tasks()
engine_steps = self.get_ready_engine_steps(bpmn_process_instance)
self.delegate.after_engine_steps(bpmn_process_instance)

View File

@ -591,7 +591,7 @@ class TestProcessApi(BaseTest):
# get the results
response = client.get(
"/v1.0/processes/Level2/callers",
"/v1.0/processes/callers?bpmn_process_identifier=Level2",
headers=self.logged_in_headers(with_super_admin_user),
)
assert response.json is not None

View File

@ -296,6 +296,7 @@ class TestAuthorizationService(BaseTest):
("/process-instances/reports/*", "read"),
("/process-instances/reports/*", "update"),
("/processes", "read"),
("/processes/callers", "read"),
("/service-tasks", "read"),
("/tasks/*", "create"),
("/tasks/*", "delete"),

View File

@ -348,8 +348,13 @@ class TestProcessInstanceProcessor(BaseTest):
assert len(process_instance.human_tasks) == 1
spiff_manual_task = processor.bpmn_process_instance.get_task_from_id(UUID(human_task_one.task_id))
assert len(process_instance.active_human_tasks) == 1, "expected 1 active human task"
ProcessInstanceService.complete_form_task(processor, spiff_manual_task, {}, initiator_user, human_task_one)
assert len(process_instance.human_tasks) == 2, "expected 2 human tasks after first one is completed"
assert (
len(process_instance.active_human_tasks) == 1
), "expected 1 active human tasks after 1st one is completed"
# unnecessary lookup just in case on windows
process_instance = ProcessInstanceModel.query.filter_by(id=process_instance.id).first()
@ -359,8 +364,8 @@ class TestProcessInstanceProcessor(BaseTest):
ProcessInstanceService.complete_form_task(processor, spiff_manual_task, {}, initiator_user, human_task_one)
import pdb; pdb.set_trace()
assert (
len(process_instance.active_human_tasks) == 0
), "expected 0 active human tasks after 2nd one is completed"
len(process_instance.active_human_tasks) == 1
), "expected 1 active human tasks after 2nd one is completed, as we have looped back around."
processor.suspend()
@ -373,7 +378,7 @@ class TestProcessInstanceProcessor(BaseTest):
assert len(all_task_models_matching_top_level_subprocess_script) == 1
task_model_to_reset_to = all_task_models_matching_top_level_subprocess_script[0]
assert task_model_to_reset_to is not None
assert len(process_instance.human_tasks) == 2, "expected 2 human tasks before reset"
assert len(process_instance.human_tasks) == 3, "expected 3 human tasks before reset"
ProcessInstanceProcessor.reset_process(process_instance, task_model_to_reset_to.guid)
assert len(process_instance.human_tasks) == 2, "still expected 2 human tasks after reset"
@ -381,7 +386,7 @@ class TestProcessInstanceProcessor(BaseTest):
db.session.expire_all()
assert (
len(process_instance.human_tasks) == 2
), "still expected 2 human tasks after reset and session expire_all"
), "still expected 3 human tasks after reset and session expire_all"
process_instance = ProcessInstanceModel.query.filter_by(id=process_instance.id).first()
processor = ProcessInstanceProcessor(process_instance)

View File

@ -38,7 +38,7 @@
"bootstrap": "^5.2.0",
"bpmn-js": "^9.3.2",
"bpmn-js-properties-panel": "^1.10.0",
"bpmn-js-spiffworkflow": "sartography/bpmn-js-spiffworkflow#main",
"bpmn-js-spiffworkflow": "github:sartography/bpmn-js-spiffworkflow#main",
"cookie": "^0.5.0",
"craco": "^0.0.3",
"cypress-slow-down": "^1.2.1",
@ -8343,7 +8343,7 @@
},
"node_modules/bpmn-js-spiffworkflow": {
"version": "0.0.8",
"resolved": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#5f2cb3d50b021dffce61fd6b2929ef5620dcdb2e",
"resolved": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#313969da1067fce0a51b152626a609a122697693",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.4",
@ -38215,8 +38215,8 @@
}
},
"bpmn-js-spiffworkflow": {
"version": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#5f2cb3d50b021dffce61fd6b2929ef5620dcdb2e",
"from": "bpmn-js-spiffworkflow@sartography/bpmn-js-spiffworkflow#main",
"version": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#313969da1067fce0a51b152626a609a122697693",
"from": "bpmn-js-spiffworkflow@github:sartography/bpmn-js-spiffworkflow#main",
"requires": {
"inherits": "^2.0.4",
"inherits-browser": "^0.0.1",

View File

@ -33,7 +33,7 @@
"bootstrap": "^5.2.0",
"bpmn-js": "^9.3.2",
"bpmn-js-properties-panel": "^1.10.0",
"bpmn-js-spiffworkflow": "sartography/bpmn-js-spiffworkflow#main",
"bpmn-js-spiffworkflow": "github:sartography/bpmn-js-spiffworkflow#main",
"cookie": "^0.5.0",
"craco": "^0.0.3",
"cypress-slow-down": "^1.2.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -2,12 +2,19 @@ import React from 'react';
// @ts-ignore
import MDEditor from '@uiw/react-md-editor';
export default function InstructionsForEndUser({ task }: any) {
type OwnProps = {
task: any;
defaultMessage?: string;
};
export default function InstructionsForEndUser({
task,
defaultMessage = '',
}: OwnProps) {
if (!task) {
return null;
}
let instructions =
'There is no additional instructions or information for this task.';
let instructions = defaultMessage;
let { properties } = task;
if (!properties) {
properties = task.extensions;

View File

@ -1551,6 +1551,7 @@ export default function ProcessInstanceListTable({
);
};
// eslint-disable-next-line sonarjs/cognitive-complexity
const buildTable = () => {
const headerLabels: Record<string, string> = {
id: 'Id',
@ -1592,11 +1593,11 @@ export default function ProcessInstanceListTable({
buttonElement = (
<Button
kind={
hasAccessToCompleteTask && row.task_id ? 'secondary' : 'tertiary'
hasAccessToCompleteTask && row.task_id ? 'secondary' : 'ghost'
}
href={interstitialUrl}
>
Go
{hasAccessToCompleteTask && row.task_id ? 'Go' : 'View'}
</Button>
);
currentRow.push(<td>{buttonElement}</td>);

View File

@ -29,6 +29,10 @@
border-color: none;
}
.cds--loading__stroke {
stroke: gray;
}
/* make this a little less prominent so the actual human beings completing tasks stand out */
.system-user-log-entry {
color: #B0B0B0;
@ -424,3 +428,24 @@ svg.notification-icon {
margin-bottom: 1em;
font-weight: bold;
}
.user_instructions_0 {
filter: opacity(1);
}
.user_instructions_1 {
filter: opacity(60%);
}
.user_instructions_2 {
filter: opacity(40%);
}
.user_instructions_3 {
filter: opacity(20%);
}
.user_instructions_4 {
filter: opacity(10%);
}

View File

@ -2,7 +2,7 @@ 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 { Loading, Grid, Column, Button } from '@carbon/react';
import { BACKEND_BASE_URL } from '../config';
import { getBasicHeaders } from '../services/HttpService';
@ -35,7 +35,7 @@ export default function ProcessInterstitial() {
if ('error_code' in retValue) {
addError(retValue);
} else {
setData((prevData) => [...prevData, retValue]);
setData((prevData) => [retValue, ...prevData]);
setLastTask(retValue);
}
},
@ -100,6 +100,33 @@ export default function ProcessInterstitial() {
}
};
const getReturnHomeButton = (index: number) => {
if (
index === 0 &&
state !== 'REDIRECTING' &&
['WAITING', 'ERROR', 'LOCKED', 'COMPLETED', 'READY'].includes(getStatus())
)
return (
<div style={{ padding: '10px 0 0 0' }}>
<Button kind="secondary" onClick={() => navigate(`/tasks`)}>
Return to Home
</Button>
</div>
);
return '';
};
const getHr = (index: number) => {
if (index === 0) {
return (
<div style={{ padding: '10px 0 50px 0' }}>
<hr />
</div>
);
}
return '';
};
function capitalize(str: string): string {
if (str && str.length > 0) {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
@ -109,7 +136,15 @@ export default function ProcessInterstitial() {
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>;
return (
<>
<h4 className="heading-compact-01">Waiting on Someone Else</h4>
<p>
This next task is assigned to a different person or team. There is
no action for you take at this time.
</p>
</>
);
}
if (shouldRedirect(myTask)) {
return <div>Redirecting you to the next task now ...</div>;
@ -120,7 +155,10 @@ export default function ProcessInterstitial() {
return (
<div>
<InstructionsForEndUser task={myTask} />
<InstructionsForEndUser
task={myTask}
defaultMessage="There are no additional instructions or information for this task."
/>
</div>
);
};
@ -147,7 +185,7 @@ export default function ProcessInterstitial() {
],
]}
/>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{getStatusImage()}
<div>
<h1 style={{ marginBottom: '0em' }}>
@ -159,13 +197,20 @@ export default function ProcessInterstitial() {
</div>
<br />
<br />
{data.map((d) => (
{data.map((d, index) => (
<Grid fullWidth style={{ marginBottom: '1em' }}>
<Column md={2} lg={4} sm={2}>
Task: <em>{d.title}</em>
</Column>
<Column md={6} lg={6} sm={4}>
{userMessage(d)}
<Column md={6} lg={8} sm={4}>
<div
className={
index < 4
? `user_instructions_${index}`
: `user_instructions_4`
}
>
{userMessage(d)}
</div>
{getReturnHomeButton(index)}
{getHr(index)}
</Column>
</Grid>
))}

View File

@ -168,7 +168,7 @@ export default function ProcessModelEditDiagram() {
useEffect(() => {
if (processModel !== null) {
HttpService.makeCallToBackend({
path: `/processes/${processModel.primary_process_id}/callers`,
path: `/processes/callers?bpmn_process_identifier=${processModel.primary_process_id}`,
successCallback: setCallers,
});
}