Basic scaffolding for onboarding (#400)

* Wedge between InProgressInstances for default view customization

* Rename to OnboardingView, stubbed out api in the backend

* Flip between InProgressInstances and MyTasks via the backend

* WIP

* WIP

* Basic human task handling

* FE lint

* Getting ./bin/pyl to pass

* Suppress any exceptions during onboarding request

* Script to skip onboarding if already started

* Getting ./bin/pyl to pass

* Better default location

* PR feedback

* PR feedback

* PR feedback - add new endpoint to basic permissions

* Fix basic permissions test

* Add integration tests

* Getting bin_pyl to pass
This commit is contained in:
jbirddog 2023-07-27 02:24:30 -04:00 committed by GitHub
parent 92b9ca3995
commit c416a5a05e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 251 additions and 11 deletions

View File

@ -176,6 +176,20 @@ paths:
schema:
$ref: "#/components/schemas/OkTrue"
/onboarding:
get:
operationId: spiffworkflow_backend.routes.onboarding_controller.get_onboarding
summary: Returns information about the next onboarding to show
tags:
- Status
responses:
"200":
description: Returns info about the next onboarding to show if it exists.
content:
application/json:
schema:
$ref: "#/components/schemas/OkTrue"
/active-users/updates/{last_visited_identifier}:
parameters:
- name: last_visited_identifier

View File

@ -0,0 +1,27 @@
"""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_backend.routes.process_instances_controller import _process_instance_start
def get_onboarding() -> Response:
result = {}
with suppress(Exception):
process_instance, processor = _process_instance_start("site-administration/onboarding")
if processor is not None:
if process_instance.status == "complete":
workflow_data = processor.bpmn_process_instance.data
result = workflow_data.get("onboarding", {})
elif process_instance.status == "user_input_required" and len(process_instance.active_human_tasks) > 0:
result = {
"type": "user_input_required",
"process_instance_id": process_instance.id,
"task_id": process_instance.active_human_tasks[0].task_id,
}
return make_response(result, 200)

View File

@ -55,11 +55,9 @@ from spiffworkflow_backend.services.task_service import TaskService
# )
def process_instance_create(
modified_process_model_identifier: str,
) -> flask.wrappers.Response:
process_model_identifier = _un_modify_modified_process_model_id(modified_process_model_identifier)
def _process_instance_create(
process_model_identifier: str,
) -> ProcessInstanceModel:
process_model = _get_process_model(process_model_identifier)
if process_model.primary_file_name is None:
raise ApiError(
@ -74,6 +72,15 @@ def process_instance_create(
process_instance = ProcessInstanceService.create_process_instance_from_process_model_identifier(
process_model_identifier, g.user
)
return process_instance
def process_instance_create(
modified_process_model_identifier: str,
) -> flask.wrappers.Response:
process_model_identifier = _un_modify_modified_process_model_id(modified_process_model_identifier)
process_instance = _process_instance_create(process_model_identifier)
return Response(
json.dumps(ProcessInstanceModelSchema().dump(process_instance)),
status=201,
@ -81,11 +88,9 @@ def process_instance_create(
)
def process_instance_run(
modified_process_model_identifier: str,
process_instance_id: int,
) -> flask.wrappers.Response:
process_instance = _find_process_instance_by_id_or_raise(process_instance_id)
def _process_instance_run(
process_instance: ProcessInstanceModel,
) -> ProcessInstanceProcessor | None:
if process_instance.status != "not_started":
raise ApiError(
error_code="process_instance_not_runnable",
@ -121,6 +126,16 @@ def process_instance_run(
if not current_app.config["SPIFFWORKFLOW_BACKEND_RUN_BACKGROUND_SCHEDULER"]:
MessageService.correlate_all_message_instances()
return processor
def process_instance_run(
modified_process_model_identifier: str,
process_instance_id: int,
) -> flask.wrappers.Response:
process_instance = _find_process_instance_by_id_or_raise(process_instance_id)
processor = _process_instance_run(process_instance)
# for mypy
if processor is not None:
process_instance_api = ProcessInstanceService.processor_to_process_instance_api(processor)
@ -134,6 +149,14 @@ def process_instance_run(
return make_response(jsonify(process_instance), 200)
def _process_instance_start(
process_model_identifier: str,
) -> tuple[ProcessInstanceModel, ProcessInstanceProcessor | None]:
process_instance = _process_instance_create(process_model_identifier)
processor = _process_instance_run(process_instance)
return process_instance, processor
def process_instance_terminate(
process_instance_id: int,
modified_process_model_identifier: str,

View File

@ -0,0 +1,22 @@
from typing import Any
from spiffworkflow_backend.models.script_attributes_context import ScriptAttributesContext
from spiffworkflow_backend.scripts.script import Script
from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService
class UserHasStartedInstance(Script):
@staticmethod
def requires_privileged_permissions() -> bool:
"""We have deemed this function safe to run without elevated permissions."""
return False
def get_description(self) -> str:
return """Returns boolean to indicate if the user has started an instance of the current process model."""
def run(self, script_attributes_context: ScriptAttributesContext, *_args: Any, **kwargs: Any) -> Any:
process_model_identifer = script_attributes_context.process_model_identifier
if process_model_identifer is not None:
return ProcessInstanceService.user_has_started_instance(process_model_identifer)
else:
return False

View File

@ -510,6 +510,7 @@ class AuthorizationService:
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="read", target_uri="/users/search"))
permissions_to_assign.append(PermissionToAssign(permission="read", target_uri="/onboarding"))
permissions_to_assign.append(
PermissionToAssign(permission="read", target_uri="/process-instances/report-metadata")

View File

@ -44,6 +44,18 @@ class ProcessInstanceService:
FILE_DATA_DIGEST_PREFIX = "spifffiledatadigest+"
TASK_STATE_LOCKED = "locked"
@staticmethod
def user_has_started_instance(process_model_identifier: str) -> bool:
started_instance = (
db.session.query(ProcessInstanceModel)
.filter(
ProcessInstanceModel.process_model_identifier == process_model_identifier,
ProcessInstanceModel.status != "not_started",
)
.first()
)
return started_instance is not None
@staticmethod
def next_start_event_configuration(process_instance_model: ProcessInstanceModel) -> StartConfiguration:
try:

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="Process_109on5e" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_0025g6c</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:endEvent id="Event_1w4e3lr">
<bpmn:incoming>Flow_01ol0nb</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_01ol0nb" sourceRef="Activity_0907t1z" targetRef="Event_1w4e3lr" />
<bpmn:scriptTask id="Activity_0907t1z" name="change default view">
<bpmn:incoming>Flow_0025g6c</bpmn:incoming>
<bpmn:outgoing>Flow_01ol0nb</bpmn:outgoing>
<bpmn:script>onboarding = {
"type": "default_view",
"value": "my_tasks",
}</bpmn:script>
</bpmn:scriptTask>
<bpmn:sequenceFlow id="Flow_0025g6c" sourceRef="StartEvent_1" targetRef="Activity_0907t1z" />
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_109on5e">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="112" y="-88" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_1w4e3lr_di" bpmnElement="Event_1w4e3lr">
<dc:Bounds x="482" y="-88" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0lidiyr_di" bpmnElement="Activity_0907t1z">
<dc:Bounds x="260" y="-110" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_0025g6c_di" bpmnElement="Flow_0025g6c">
<di:waypoint x="148" y="-70" />
<di:waypoint x="260" y="-70" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_01ol0nb_di" bpmnElement="Flow_01ol0nb">
<di:waypoint x="360" y="-70" />
<di:waypoint x="482" y="-70" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,48 @@
from flask import Flask
from flask.testing import FlaskClient
from spiffworkflow_backend.models.user import UserModel
from tests.spiffworkflow_backend.helpers.base_test import BaseTest
class TestOnboarding(BaseTest):
def test_returns_nothing_if_no_onboarding_model(
self,
app: Flask,
client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,
with_super_admin_user: UserModel,
) -> None:
results = client.get(
"/v1.0/onboarding",
headers=self.logged_in_headers(with_super_admin_user),
)
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:
process_group_id = "site-administration"
process_model_id = "onboarding"
bpmn_file_location = "onboarding"
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,
)
results = client.get(
"/v1.0/onboarding",
headers=self.logged_in_headers(with_super_admin_user),
)
assert results.status_code == 200
assert results.json == {"type": "default_view", "value": "my_tasks"}

View File

@ -280,6 +280,7 @@ class TestAuthorizationService(BaseTest):
("/active-users/*", "create"),
("/connector-proxy/typeahead/*", "read"),
("/debug/version-info", "read"),
("/onboarding", "read"),
("/process-groups", "read"),
("/process-instances/find-by-id/*", "read"),
("/process-instances/for-me", "create"),

View File

@ -10,6 +10,13 @@ export interface Secret {
creator_user_id: string;
}
export interface Onboarding {
type?: string;
value?: string;
process_instance_id?: string;
task_id?: string;
}
export interface ProcessData {
process_data_identifier: string;
process_data_value: any;

View File

@ -6,6 +6,7 @@ import MyTasks from './MyTasks';
import CompletedInstances from './CompletedInstances';
import CreateNewInstance from './CreateNewInstance';
import InProgressInstances from './InProgressInstances';
import OnboardingView from './OnboardingView';
export default function HomePageRoutes() {
const location = useLocation();
@ -50,7 +51,7 @@ export default function HomePageRoutes() {
<div className="fixed-width-container">
{renderTabs()}
<Routes>
<Route path="/" element={<InProgressInstances />} />
<Route path="/" element={<OnboardingView />} />
<Route path="my-tasks" element={<MyTasks />} />
<Route path=":process_instance_id/:task_id" element={<TaskShow />} />
<Route path="grouped" element={<InProgressInstances />} />

View File

@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import HttpService from '../services/HttpService';
import InProgressInstances from './InProgressInstances';
import { Onboarding } from '../interfaces';
import MyTasks from './MyTasks';
export default function OnboardingView() {
const [onboarding, setOnboarding] = useState<Onboarding | null>(null);
const navigate = useNavigate();
useEffect(() => {
HttpService.makeCallToBackend({
path: `/onboarding`,
successCallback: setOnboarding,
});
}, [setOnboarding]);
const onboardingElement = () => {
if (onboarding) {
if (onboarding.type === 'default_view') {
if (onboarding.value === 'my_tasks') {
return <MyTasks />;
}
} else if (
onboarding.type === 'user_input_required' &&
onboarding.process_instance_id &&
onboarding.task_id
) {
navigate(
`/tasks/${onboarding.process_instance_id}/${onboarding.task_id}`
);
}
}
return <InProgressInstances />;
};
return onboardingElement();
}