mirror of
https://github.com/sartography/spiff-arena.git
synced 2025-02-24 15:18:27 +00:00
Feature/onboarding ephemeral (#442)
* The onboarding controller should not save the process model to the database. It creates a pile of pointless noise. So just cleaning that up. * run_pyl * assure we can handle user tasks if they happen during on-boarding, while keeping the list of processes clean. Lots of weird stuff getting run_pyl going. * pyl --------- Co-authored-by: burnettk <burnettk@users.noreply.github.com>
This commit is contained in:
parent
314e5b660d
commit
a0202c6a5b
1018
poetry.lock
generated
1018
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,39 +1,47 @@
|
||||
"""APIs for dealing with process groups, process models, and process instances."""
|
||||
from contextlib import suppress
|
||||
|
||||
from flask import make_response
|
||||
from flask.wrappers import Response
|
||||
from SpiffWorkflow.exceptions import WorkflowException # type: ignore
|
||||
|
||||
from spiffworkflow_backend import db
|
||||
from spiffworkflow_backend.models.task import TaskModel
|
||||
from spiffworkflow_backend.exceptions.api_error import ApiError
|
||||
from spiffworkflow_backend.routes.process_instances_controller import _process_instance_start
|
||||
from spiffworkflow_backend.services.jinja_service import JinjaService
|
||||
|
||||
|
||||
def get_onboarding() -> Response:
|
||||
result = {}
|
||||
result: dict = {}
|
||||
|
||||
with suppress(Exception):
|
||||
try:
|
||||
process_instance, processor = _process_instance_start("site-administration/onboarding")
|
||||
|
||||
except ApiError:
|
||||
# The process doesn't exist, so bail out without an error
|
||||
return make_response(result, 200)
|
||||
try:
|
||||
processor.do_engine_steps(save=True, execution_strategy_name="greedy") # type: ignore
|
||||
if processor is not None:
|
||||
if process_instance.status == "complete":
|
||||
workflow_data = processor.bpmn_process_instance.data
|
||||
bpmn_process = processor.bpmn_process_instance
|
||||
if bpmn_process.is_completed():
|
||||
workflow_data = bpmn_process.data
|
||||
result = workflow_data.get("onboarding", {})
|
||||
elif process_instance.status == "user_input_required" and len(process_instance.active_human_tasks) > 0:
|
||||
# Delete the process instance, we don't need to keep this around if no users tasks were created.
|
||||
db.session.delete(process_instance)
|
||||
db.session.flush() # Clear it out BEFORE returning.
|
||||
elif len(bpmn_process.get_ready_user_tasks()) > 0:
|
||||
process_instance.persistence_level = "full"
|
||||
processor.save()
|
||||
result = {
|
||||
"type": "user_input_required",
|
||||
"process_instance_id": process_instance.id,
|
||||
}
|
||||
|
||||
task = processor.next_task()
|
||||
db.session.flush()
|
||||
if task:
|
||||
task_model: TaskModel | None = TaskModel.query.filter_by(
|
||||
guid=str(task.id), process_instance_id=process_instance.id
|
||||
).first()
|
||||
if task_model is not None:
|
||||
result["task_id"] = task_model.guid
|
||||
result["instructions"] = JinjaService.render_instructions_for_end_user(task_model)
|
||||
result["task_id"] = task.id
|
||||
result["instructions"] = JinjaService.render_instructions_for_end_user(task)
|
||||
except WorkflowException as e:
|
||||
raise ApiError.from_workflow_exception("onboard_failed", "Error building onboarding message", e) from e
|
||||
except Exception as e:
|
||||
raise ApiError("onboard_failed", "Error building onboarding message") from e
|
||||
|
||||
return make_response(result, 200)
|
||||
|
@ -3,6 +3,8 @@ from sys import exc_info
|
||||
|
||||
import jinja2
|
||||
from jinja2 import TemplateSyntaxError
|
||||
from SpiffWorkflow.bpmn.exceptions import WorkflowTaskException # type: ignore
|
||||
from SpiffWorkflow.task import Task as SpiffTask # type: ignore
|
||||
from spiffworkflow_backend.exceptions.api_error import ApiError
|
||||
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
|
||||
from spiffworkflow_backend.services.task_service import TaskModelError
|
||||
@ -35,14 +37,17 @@ class JinjaHelpers:
|
||||
|
||||
class JinjaService:
|
||||
@classmethod
|
||||
def render_instructions_for_end_user(cls, task_model: TaskModel, extensions: dict | None = None) -> str:
|
||||
def render_instructions_for_end_user(cls, task: TaskModel | SpiffTask, extensions: dict | None = None) -> str:
|
||||
"""Assure any instructions for end user are processed for jinja syntax."""
|
||||
if extensions is None:
|
||||
extensions = TaskService.get_extensions_from_task_model(task_model)
|
||||
if isinstance(task, TaskModel):
|
||||
extensions = TaskService.get_extensions_from_task_model(task)
|
||||
else:
|
||||
extensions = task.task_spec.extensions
|
||||
if extensions and "instructionsForEndUser" in extensions:
|
||||
if extensions["instructionsForEndUser"]:
|
||||
try:
|
||||
instructions = cls.render_jinja_template(extensions["instructionsForEndUser"], task_model)
|
||||
instructions = cls.render_jinja_template(extensions["instructionsForEndUser"], task)
|
||||
extensions["instructionsForEndUser"] = instructions
|
||||
return instructions
|
||||
except TaskModelError as wfe:
|
||||
@ -51,14 +56,21 @@ class JinjaService:
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def render_jinja_template(cls, unprocessed_template: str, task_model: TaskModel) -> str:
|
||||
def render_jinja_template(cls, unprocessed_template: str, task: TaskModel | SpiffTask) -> str:
|
||||
jinja_environment = jinja2.Environment(autoescape=True, lstrip_blocks=True, trim_blocks=True)
|
||||
jinja_environment.filters.update(JinjaHelpers.get_helper_mapping())
|
||||
try:
|
||||
template = jinja_environment.from_string(unprocessed_template)
|
||||
return template.render(**(task_model.get_data()), **JinjaHelpers.get_helper_mapping())
|
||||
if isinstance(task, TaskModel):
|
||||
data = task.get_data()
|
||||
else:
|
||||
data = task.data
|
||||
return template.render(**data, **JinjaHelpers.get_helper_mapping())
|
||||
except jinja2.exceptions.TemplateError as template_error:
|
||||
wfe = TaskModelError(str(template_error), task_model=task_model, exception=template_error)
|
||||
if isinstance(task, TaskModel):
|
||||
wfe = TaskModelError(str(template_error), task_model=task, exception=template_error)
|
||||
else:
|
||||
wfe = WorkflowTaskException(str(template_error), task=task, exception=template_error)
|
||||
if isinstance(template_error, TemplateSyntaxError):
|
||||
wfe.line_number = template_error.lineno
|
||||
wfe.error_line = template_error.source.split("\n")[template_error.lineno - 1]
|
||||
@ -66,7 +78,10 @@ class JinjaService:
|
||||
raise wfe from template_error
|
||||
except Exception as error:
|
||||
_type, _value, tb = exc_info()
|
||||
wfe = TaskModelError(str(error), task_model=task_model, exception=error)
|
||||
if isinstance(task, TaskModel):
|
||||
wfe = TaskModelError(str(error), task_model=task, exception=error)
|
||||
else:
|
||||
wfe = WorkflowTaskException(str(error), task=task, exception=error)
|
||||
while tb:
|
||||
if tb.tb_frame.f_code.co_filename == "<template>":
|
||||
wfe.line_number = tb.tb_lineno
|
||||
|
@ -0,0 +1,95 @@
|
||||
<?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:process id="Process_WithForm" name="Process With Form" isExecutable="true">
|
||||
<bpmn:startEvent id="StartEvent_1">
|
||||
<bpmn:outgoing>Flow_0smvjir</bpmn:outgoing>
|
||||
</bpmn:startEvent>
|
||||
<bpmn:sequenceFlow id="Flow_0smvjir" sourceRef="StartEvent_1" targetRef="Activity_SimpleForm" />
|
||||
<bpmn:endEvent id="Event_00xci7j">
|
||||
<bpmn:incoming>Flow_1scft9v</bpmn:incoming>
|
||||
</bpmn:endEvent>
|
||||
<bpmn:manualTask id="Activity_1cscoeg" name="DisplayInfo">
|
||||
<bpmn:extensionElements>
|
||||
<spiffworkflow:instructionsForEndUser>Hello {{ name }}
|
||||
Department: {{ department }}
|
||||
</spiffworkflow:instructionsForEndUser>
|
||||
<spiffworkflow:postScript>user_completing_task = get_last_user_completing_task("Process_WithForm", "Activity_SimpleForm")</spiffworkflow:postScript>
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:incoming>Flow_028o7v5</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_18ytjgo</bpmn:outgoing>
|
||||
</bpmn:manualTask>
|
||||
<bpmn:userTask id="Activity_SimpleForm" name="Simple Form">
|
||||
<bpmn:extensionElements>
|
||||
<spiffworkflow:properties>
|
||||
<spiffworkflow:property name="formJsonSchemaFilename" value="simple_form.json" />
|
||||
<spiffworkflow:property name="formUiSchemaFilename" value="simple_form_ui.json" />
|
||||
</spiffworkflow:properties>
|
||||
<spiffworkflow:postScript>process_initiator_user = get_process_initiator_user()</spiffworkflow:postScript>
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:incoming>Flow_0smvjir</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_163ufsx</bpmn:outgoing>
|
||||
</bpmn:userTask>
|
||||
<bpmn:intermediateThrowEvent id="completed_form" name="Completed Form">
|
||||
<bpmn:incoming>Flow_163ufsx</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_028o7v5</bpmn:outgoing>
|
||||
</bpmn:intermediateThrowEvent>
|
||||
<bpmn:sequenceFlow id="Flow_163ufsx" sourceRef="Activity_SimpleForm" targetRef="completed_form" />
|
||||
<bpmn:intermediateThrowEvent id="completed_manual_task" name="Completed Manual Task">
|
||||
<bpmn:incoming>Flow_18ytjgo</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_1scft9v</bpmn:outgoing>
|
||||
</bpmn:intermediateThrowEvent>
|
||||
<bpmn:sequenceFlow id="Flow_18ytjgo" sourceRef="Activity_1cscoeg" targetRef="completed_manual_task" />
|
||||
<bpmn:sequenceFlow id="Flow_028o7v5" sourceRef="completed_form" targetRef="Activity_1cscoeg" />
|
||||
<bpmn:sequenceFlow id="Flow_1scft9v" sourceRef="completed_manual_task" targetRef="Event_00xci7j" />
|
||||
</bpmn:process>
|
||||
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
||||
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_WithForm">
|
||||
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
|
||||
<dc:Bounds x="179" y="159" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Activity_0x5k4l1_di" bpmnElement="Activity_SimpleForm">
|
||||
<dc:Bounds x="270" y="137" width="100" height="80" />
|
||||
<bpmndi:BPMNLabel />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Activity_00g930h_di" bpmnElement="Activity_1cscoeg">
|
||||
<dc:Bounds x="510" y="137" width="100" height="80" />
|
||||
<bpmndi:BPMNLabel />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Event_00xci7j_di" bpmnElement="Event_00xci7j">
|
||||
<dc:Bounds x="722" y="159" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Event_0pi2wuv_di" bpmnElement="completed_form">
|
||||
<dc:Bounds x="432" y="159" width="36" height="36" />
|
||||
<bpmndi:BPMNLabel>
|
||||
<dc:Bounds x="409" y="202" width="82" height="14" />
|
||||
</bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Event_071q0rf_di" bpmnElement="completed_manual_task">
|
||||
<dc:Bounds x="662" y="159" width="36" height="36" />
|
||||
<bpmndi:BPMNLabel>
|
||||
<dc:Bounds x="649" y="202" width="63" height="27" />
|
||||
</bpmndi:BPMNLabel>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="Flow_0smvjir_di" bpmnElement="Flow_0smvjir">
|
||||
<di:waypoint x="215" y="177" />
|
||||
<di:waypoint x="270" y="177" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_163ufsx_di" bpmnElement="Flow_163ufsx">
|
||||
<di:waypoint x="370" y="177" />
|
||||
<di:waypoint x="432" y="177" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_18ytjgo_di" bpmnElement="Flow_18ytjgo">
|
||||
<di:waypoint x="610" y="177" />
|
||||
<di:waypoint x="662" y="177" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_028o7v5_di" bpmnElement="Flow_028o7v5">
|
||||
<di:waypoint x="468" y="177" />
|
||||
<di:waypoint x="510" y="177" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_1scft9v_di" bpmnElement="Flow_1scft9v">
|
||||
<di:waypoint x="698" y="177" />
|
||||
<di:waypoint x="722" y="177" />
|
||||
</bpmndi:BPMNEdge>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
</bpmn:definitions>
|
@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Simple form",
|
||||
"description": "A simple form example.",
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name",
|
||||
"default": "World"
|
||||
},
|
||||
"department": {
|
||||
"type": "string",
|
||||
"title": "Department",
|
||||
"enum": ["Finance", "HR", "IT"]
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": {
|
||||
"ui:title": "Name",
|
||||
"ui:description": "(Your name)"
|
||||
},
|
||||
"department": {
|
||||
"ui:title": "Department",
|
||||
"ui:description": "(Your department)"
|
||||
},
|
||||
"ui:order": ["name", "department"]
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskClient
|
||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
|
||||
from spiffworkflow_backend.models.user import UserModel
|
||||
|
||||
from tests.spiffworkflow_backend.helpers.base_test import BaseTest
|
||||
@ -21,16 +22,10 @@ class TestOnboarding(BaseTest):
|
||||
assert results.status_code == 200
|
||||
assert results.json == {}
|
||||
|
||||
def test_returns_onboarding_if_onboarding_model(
|
||||
self,
|
||||
app: Flask,
|
||||
client: FlaskClient,
|
||||
with_db_and_bpmn_file_cleanup: None,
|
||||
with_super_admin_user: UserModel,
|
||||
) -> None:
|
||||
def set_up_onboarding(self, client: FlaskClient, with_super_admin_user: UserModel, file_location: str) -> None:
|
||||
process_group_id = "site-administration"
|
||||
process_model_id = "onboarding"
|
||||
bpmn_file_location = "onboarding"
|
||||
bpmn_file_location = file_location
|
||||
self.create_group_and_model_with_bpmn(
|
||||
client,
|
||||
with_super_admin_user,
|
||||
@ -39,6 +34,15 @@ class TestOnboarding(BaseTest):
|
||||
bpmn_file_location=bpmn_file_location,
|
||||
)
|
||||
|
||||
def test_returns_onboarding_if_onboarding_model(
|
||||
self,
|
||||
app: Flask,
|
||||
client: FlaskClient,
|
||||
with_db_and_bpmn_file_cleanup: None,
|
||||
with_super_admin_user: UserModel,
|
||||
) -> None:
|
||||
self.set_up_onboarding(client, with_super_admin_user, "onboarding")
|
||||
|
||||
results = client.get(
|
||||
"/v1.0/onboarding",
|
||||
headers=self.logged_in_headers(with_super_admin_user),
|
||||
@ -50,3 +54,27 @@ class TestOnboarding(BaseTest):
|
||||
assert results.json["value"] == "my_tasks"
|
||||
assert results.json["instructions"] == ""
|
||||
assert results.json["task_id"] is not None
|
||||
|
||||
# Assure no residual process model is left behind if it executes and completes without additinal user tasks
|
||||
assert len(ProcessInstanceModel.query.all()) == 0
|
||||
|
||||
def test_persists_if_user_task_encountered(
|
||||
self,
|
||||
app: Flask,
|
||||
client: FlaskClient,
|
||||
with_db_and_bpmn_file_cleanup: None,
|
||||
with_super_admin_user: UserModel,
|
||||
) -> None:
|
||||
self.set_up_onboarding(client, with_super_admin_user, "onboarding_with_user_task")
|
||||
results = client.get(
|
||||
"/v1.0/onboarding",
|
||||
headers=self.logged_in_headers(with_super_admin_user),
|
||||
)
|
||||
assert results.status_code == 200
|
||||
assert len(results.json.keys()) == 4
|
||||
assert results.json["type"] == "user_input_required"
|
||||
assert results.json["process_instance_id"] is not None
|
||||
instance = ProcessInstanceModel.query.filter(
|
||||
ProcessInstanceModel.id == results.json["process_instance_id"]
|
||||
).first()
|
||||
assert instance is not None
|
||||
|
@ -42,3 +42,21 @@ class TestJinjaService(BaseTest):
|
||||
"from_script_task": "Sanitized \\| from \\| script \\| task",
|
||||
}
|
||||
assert task_model.get_data() == expected_task_data
|
||||
|
||||
def test_can_render_directly_from_spiff_task(self, app: Flask, with_db_and_bpmn_file_cleanup: None) -> None:
|
||||
process_model = load_test_spec(
|
||||
process_model_id="test_group/manual-task-with-sanitized-markdown",
|
||||
process_model_source_directory="manual-task-with-sanitized-markdown",
|
||||
)
|
||||
process_instance = self.create_process_instance_from_process_model(process_model=process_model)
|
||||
processor = ProcessInstanceProcessor(process_instance)
|
||||
processor.do_engine_steps(save=True)
|
||||
|
||||
JinjaService.render_instructions_for_end_user(processor.get_ready_user_tasks()[0])
|
||||
"\n".join(
|
||||
[
|
||||
r"* From Filter: Sanitized \| from \| filter",
|
||||
r"* From Method Call: Sanitized \| from \| method \| call",
|
||||
r"* From ScriptTask: Sanitized \| from \| script \| task",
|
||||
]
|
||||
)
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import HttpService from '../services/HttpService';
|
||||
import { Onboarding } from '../interfaces';
|
||||
import { objectIsEmpty } from '../helpers';
|
||||
@ -8,8 +8,7 @@ import { objectIsEmpty } from '../helpers';
|
||||
export default function OnboardingView() {
|
||||
const [onboarding, setOnboarding] = useState<Onboarding | null>(null);
|
||||
const location = useLocation();
|
||||
|
||||
// const navigate = useNavigate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
HttpService.makeCallToBackend({
|
||||
@ -24,6 +23,15 @@ export default function OnboardingView() {
|
||||
}
|
||||
|
||||
if (
|
||||
onboarding &&
|
||||
onboarding.type === 'user_input_required' &&
|
||||
onboarding.process_instance_id &&
|
||||
onboarding.task_id
|
||||
) {
|
||||
navigate(
|
||||
`/tasks/${onboarding.process_instance_id}/${onboarding.task_id}`
|
||||
);
|
||||
} else if (
|
||||
onboarding &&
|
||||
!objectIsEmpty(onboarding) &&
|
||||
onboarding.instructions.length > 0
|
||||
@ -37,25 +45,6 @@ export default function OnboardingView() {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
/*
|
||||
if (onboarding.type === 'default_view') {
|
||||
if (onboarding.value === 'my_tasks') {
|
||||
return <MyTasks />;
|
||||
}
|
||||
} else if (
|
||||
onboarding.type === 'user_input_required'
|
||||
) {
|
||||
console.log("onboarding");
|
||||
} else if (
|
||||
onboarding.type === 'user_input_required' &&
|
||||
onboarding.process_instance_id &&
|
||||
onboarding.task_id
|
||||
) {
|
||||
navigate(
|
||||
`/tasks/${onboarding.process_instance_id}/${onboarding.task_id}`
|
||||
);
|
||||
}
|
||||
*/
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user