Merge pull request #27 from sartography/feature/manual_task_with_docs

Feature/manual task with docs
This commit is contained in:
Dan Funk 2020-04-17 13:58:15 -04:00 committed by GitHub
commit 233a8b4ff9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 206 additions and 40 deletions

View File

@ -133,7 +133,7 @@ def update_task(workflow_id, task_id, body):
processor = WorkflowProcessor(workflow_model)
task_id = uuid.UUID(task_id)
task = processor.bpmn_workflow.get_task(task_id)
task.data = body
task.update_data(body)
processor.complete_task(task)
processor.do_engine_steps()
workflow_model.last_completed_task_id = task.id

View File

@ -74,6 +74,7 @@ class FileModel(db.Model):
content_type = db.Column(db.String)
is_reference = db.Column(db.Boolean, nullable=False, default=False) # A global reference file.
primary = db.Column(db.Boolean, nullable=False, default=False) # Is this the primary BPMN in a workflow?
primary_process_id = db.Column(db.String, nullable=True) # An id in the xml of BPMN documents, critical for primary BPMN.
workflow_spec_id = db.Column(db.String, db.ForeignKey('workflow_spec.id'), nullable=True)
workflow_id = db.Column(db.Integer, db.ForeignKey('workflow.id'), nullable=True)
study_id = db.Column(db.Integer, db.ForeignKey('study.id'), nullable=True)

View File

@ -29,7 +29,6 @@ class WorkflowSpecModel(db.Model):
display_name = db.Column(db.String)
display_order = db.Column(db.Integer, nullable=True)
description = db.Column(db.Text)
primary_process_id = db.Column(db.String)
category_id = db.Column(db.Integer, db.ForeignKey('workflow_spec_category.id'), nullable=True)
category = db.relationship("WorkflowSpecCategoryModel")
is_master_spec = db.Column(db.Boolean, default=False)

View File

@ -26,15 +26,12 @@ class FileService(object):
workflow_spec_id=workflow_spec.id,
name=name,
primary=primary,
is_status=is_status
is_status=is_status,
)
if primary:
bpmn: ElementTree.Element = ElementTree.fromstring(binary_data)
workflow_spec.primary_process_id = WorkflowProcessor.get_process_id(bpmn)
print("Locating Process Id for " + name + " " + workflow_spec.primary_process_id)
return FileService.update_file(file_model, binary_data, content_type)
@staticmethod
def add_form_field_file(study_id, workflow_id, task_id, form_field_key, name, content_type, binary_data):
"""Create a new file and associate it with a user task form field within a workflow.
@ -141,6 +138,11 @@ class FileService(object):
else:
version = file_data_model.version + 1
# If this is a BPMN, extract the process id.
if file_model.type == FileType.bpmn:
bpmn: ElementTree.Element = ElementTree.fromstring(binary_data)
file_model.primary_process_id = WorkflowProcessor.get_process_id(bpmn)
file_model.latest_version = version
file_data_model = FileDataModel(data=binary_data, file_model=file_model, version=version,
md5_hash=md5_checksum, last_updated=datetime.now())
@ -184,6 +186,7 @@ class FileService(object):
.filter(FileDataModel.version == file_model.latest_version) \
.first()
@staticmethod
def get_reference_file_data(file_name):
file_model = session.query(FileModel). \
@ -222,8 +225,8 @@ class FileService(object):
# then we can look it up. As there is the potential for sub-workflows, we
# may need to travel up to locate the primary process.
spec = workflow.spec
workflow_model = session.query(WorkflowSpecModel). \
filter(WorkflowSpecModel.primary_process_id == spec.name).first()
workflow_model = session.query(WorkflowSpecModel).join(FileModel). \
filter(FileModel.primary_process_id == spec.name).first()
if workflow_model is None and workflow != workflow.outer_workflow:
return FileService.__find_spec_model_in_db(workflow.outer_workflow)

View File

@ -276,6 +276,8 @@ class WorkflowProcessor(object):
def populate_form_with_random_data(task):
"""populates a task with random data - useful for testing a spec."""
if not hasattr(task.task_spec, 'form'): return
form_data = {}
for field in task.task_spec.form.fields:
if field.type == "enum":

View File

@ -45,14 +45,13 @@ class WorkflowService(object):
@staticmethod
def spiff_task_to_api_task(spiff_task):
documentation = spiff_task.task_spec.documentation if hasattr(spiff_task.task_spec, "documentation") else ""
task = Task(spiff_task.id,
spiff_task.task_spec.name,
spiff_task.task_spec.description,
spiff_task.task_spec.__class__.__name__,
spiff_task.get_state_name(),
None,
documentation,
"",
spiff_task.data)
# Only process the form and documentation if this is something that is ready or completed.
@ -62,21 +61,33 @@ class WorkflowService(object):
for field in task.form.fields:
WorkflowService._process_options(spiff_task, field)
if documentation != "" and documentation is not None:
WorkflowService._process_documentation(task, documentation)
task.documentation = WorkflowService._process_documentation(spiff_task)
return task
@staticmethod
def _process_documentation(task, documentation):
def _process_documentation(spiff_task):
"""Runs the given documentation string through the Jinja2 processor to inject data
create loops, etc..."""
create loops, etc... - If a markdown file exists with the same name as the task id,
it will use that file instead of the documentation. """
documentation = spiff_task.task_spec.documentation if hasattr(spiff_task.task_spec, "documentation") else ""
try:
template = Template(documentation)
task.documentation = template.render(**task.data)
doc_file_name = spiff_task.task_spec.name + ".md"
data_model = FileService.get_workflow_file_data(spiff_task.workflow, doc_file_name)
raw_doc = data_model.data.decode("utf-8")
except ApiError:
raw_doc = documentation
if not raw_doc:
return ""
try:
template = Template(raw_doc)
return template.render(**spiff_task.data)
except jinja2.exceptions.TemplateError as ue:
raise ApiError(code="template_error", message="Error processing template for task %s: %s" %
(task.name, str(ue)), status_code=500)
(spiff_task.task_spec.name, str(ue)), status_code=500)
# TODO: Catch additional errors and report back.
@staticmethod

View File

@ -0,0 +1,30 @@
"""empty message
Revision ID: 476f8a4933ba
Revises: 7be7cecbeea8
Create Date: 2020-04-17 12:10:38.962672
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '476f8a4933ba'
down_revision = '7be7cecbeea8'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('file', sa.Column('primary_process_id', sa.String(), nullable=True))
op.drop_column('workflow_spec', 'primary_process_id')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('workflow_spec', sa.Column('primary_process_id', sa.VARCHAR(), autoincrement=False, nullable=True))
op.drop_column('file', 'primary_process_id')
# ### end Alembic commands ###

View File

@ -1 +0,0 @@
,dan,lilmaker,15.04.2020 11:05,file:///home/dan/.config/libreoffice/4;

View File

@ -0,0 +1,6 @@
# This is Markdown
* It should be processed.
* It will display some information from the Previous Form using Jinja Syntax.
You entered the following information on the previous page:
Your name is {{ name }}

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:camunda="http://camunda.org/schema/1.0/bpmn" id="Definitions_1v1rp1q" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.4.1">
<bpmn:process id="Process_1vu5nxl" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>SequenceFlow_0lvudp8</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="SequenceFlow_0lvudp8" sourceRef="StartEvent_1" targetRef="Task_Form" />
<bpmn:endEvent id="EndEvent_0q4qzl9">
<bpmn:incoming>SequenceFlow_02vev7n</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="SequenceFlow_02vev7n" sourceRef="Task_Manual_One" targetRef="EndEvent_0q4qzl9" />
<bpmn:manualTask id="Task_Manual_One" name="Manual Task with Documentation">
<bpmn:incoming>SequenceFlow_1n97kpy</bpmn:incoming>
<bpmn:outgoing>SequenceFlow_02vev7n</bpmn:outgoing>
</bpmn:manualTask>
<bpmn:sequenceFlow id="SequenceFlow_1n97kpy" sourceRef="Task_Form" targetRef="Task_Manual_One" />
<bpmn:userTask id="Task_Form" name="Name Form" camunda:formKey="my_form">
<bpmn:extensionElements>
<camunda:formData>
<camunda:formField id="name" label="Please Enter you Name:" type="string" />
</camunda:formData>
</bpmn:extensionElements>
<bpmn:incoming>SequenceFlow_0lvudp8</bpmn:incoming>
<bpmn:outgoing>SequenceFlow_1n97kpy</bpmn:outgoing>
</bpmn:userTask>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_1vu5nxl">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="99" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="SequenceFlow_0lvudp8_di" bpmnElement="SequenceFlow_0lvudp8">
<di:waypoint x="215" y="117" />
<di:waypoint x="240" y="117" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="EndEvent_0q4qzl9_di" bpmnElement="EndEvent_0q4qzl9">
<dc:Bounds x="532" y="99" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="SequenceFlow_02vev7n_di" bpmnElement="SequenceFlow_02vev7n">
<di:waypoint x="480" y="117" />
<di:waypoint x="532" y="117" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="ManualTask_0nc8sr9_di" bpmnElement="Task_Manual_One">
<dc:Bounds x="380" y="77" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="SequenceFlow_1n97kpy_di" bpmnElement="SequenceFlow_1n97kpy">
<di:waypoint x="340" y="117" />
<di:waypoint x="380" y="117" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="UserTask_1qhb92h_di" bpmnElement="Task_Form">
<dc:Bounds x="240" y="77" width="100" height="80" />
</bpmndi:BPMNShape>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -12,6 +12,11 @@ from tests.base_test import BaseTest
class TestFilesApi(BaseTest):
def minimal_bpmn(self, content):
"""Returns a bytesIO object of a well formed BPMN xml file with some string content of your choosing."""
minimal_dbpm = "<x><process id='1' isExecutable='false'><startEvent id='a'/></process>%s</x>"
return (minimal_dbpm % content).encode()
def test_list_files_for_workflow_spec(self):
self.load_example_data()
spec_id = 'core_info'
@ -145,13 +150,13 @@ class TestFilesApi(BaseTest):
self.load_example_data()
spec = session.query(WorkflowSpecModel).first()
data = {}
data['file'] = io.BytesIO(b"abcdef"), 'my_new_file.bpmn'
data['file'] = io.BytesIO(self.minimal_bpmn("abcdef")), 'my_new_file.bpmn'
rv = self.app.post('/v1.0/file?workflow_spec_id=%s' % spec.id, data=data, follow_redirects=True,
content_type='multipart/form-data', headers=self.logged_in_headers())
json_data = json.loads(rv.get_data(as_text=True))
file = FileModelSchema().load(json_data, session=session)
data['file'] = io.BytesIO(b"hijklim"), 'my_new_file.bpmn'
data['file'] = io.BytesIO(self.minimal_bpmn("efghijk")), 'my_new_file.bpmn'
rv = self.app.put('/v1.0/file/%i/data' % file.id, data=data, follow_redirects=True,
content_type='multipart/form-data', headers=self.logged_in_headers())
self.assert_success(rv)
@ -172,20 +177,20 @@ class TestFilesApi(BaseTest):
self.assert_success(rv)
data = rv.get_data()
self.assertIsNotNone(data)
self.assertEqual(b"hijklim", data)
self.assertEqual(self.minimal_bpmn("efghijk"), data)
def test_update_with_same_exact_data_does_not_increment_version(self):
self.load_example_data()
spec = session.query(WorkflowSpecModel).first()
data = {}
data['file'] = io.BytesIO(b"abcdef"), 'my_new_file.bpmn'
data['file'] = io.BytesIO(self.minimal_bpmn("abcdef")), 'my_new_file.bpmn'
rv = self.app.post('/v1.0/file?workflow_spec_id=%s' % spec.id, data=data, follow_redirects=True,
content_type='multipart/form-data', headers=self.logged_in_headers())
self.assertIsNotNone(rv.get_data())
json_data = json.loads(rv.get_data(as_text=True))
file = FileModelSchema().load(json_data, session=session)
self.assertEqual(1, file.latest_version)
data['file'] = io.BytesIO(b"abcdef"), 'my_new_file.bpmn'
data['file'] = io.BytesIO(self.minimal_bpmn("abcdef")), 'my_new_file.bpmn'
rv = self.app.put('/v1.0/file/%i/data' % file.id, data=data, follow_redirects=True,
content_type='multipart/form-data', headers=self.logged_in_headers())
self.assertIsNotNone(rv.get_data())
@ -213,3 +218,34 @@ class TestFilesApi(BaseTest):
rv = self.app.get('/v1.0/file/%i' % file.id, headers=self.logged_in_headers())
self.assertEqual(404, rv.status_code)
def test_change_primary_bpmn(self):
self.load_example_data()
spec = session.query(WorkflowSpecModel).first()
data = {}
data['file'] = io.BytesIO(self.minimal_bpmn("abcdef")), 'my_new_file.bpmn'
# Add a new BPMN file to the specification
rv = self.app.post('/v1.0/file?workflow_spec_id=%s' % spec.id, data=data, follow_redirects=True,
content_type='multipart/form-data', headers=self.logged_in_headers())
self.assert_success(rv)
self.assertIsNotNone(rv.get_data())
json_data = json.loads(rv.get_data(as_text=True))
file = FileModelSchema().load(json_data, session=session)
# Delete the primary BPMN file for the workflow.
orig_model = session.query(FileModel).\
filter(FileModel.primary == True).\
filter(FileModel.workflow_spec_id == spec.id).first()
rv = self.app.delete('/v1.0/file?file_id=%s' % orig_model.id, headers=self.logged_in_headers())
# Set that new file to be the primary BPMN, assure it has a primary_process_id
file.primary = True
rv = self.app.put('/v1.0/file/%i' % file.id,
content_type="application/json",
data=json.dumps(FileModelSchema().dump(file)), headers=self.logged_in_headers())
self.assert_success(rv)
json_data = json.loads(rv.get_data(as_text=True))
self.assertTrue(json_data['primary'])
self.assertIsNotNone(json_data['primary_process_id'])

View File

@ -243,3 +243,17 @@ class TestTasksApi(BaseTest):
self.assertGreater(db_stats_after.num_tasks_total, 0)
self.assertEqual(db_stats_after.num_tasks_total,
db_stats_after.num_tasks_complete + db_stats_after.num_tasks_incomplete)
def test_manual_task_with_external_documentation(self):
self.load_example_data()
workflow = self.create_workflow('manual_task_with_external_documentation')
# get the first form in the two form workflow.
tasks = self.get_workflow_api(workflow).user_tasks
workflow_api = self.complete_form(workflow, tasks[0], {"name": "Dan"})
workflow = self.get_workflow_api(workflow)
self.assertEquals('Task_Manual_One', workflow.next_task['name'])
self.assertEquals('ManualTask', workflow_api.next_task['type'])
self.assertTrue('Markdown' in workflow_api.next_task['documentation'])
self.assertTrue('Dan' in workflow_api.next_task['documentation'])

View File

@ -15,15 +15,20 @@ from tests.base_test import BaseTest
class TestWorkflowService(BaseTest):
def test_documentation_processing_handles_replacements(self):
self.load_example_data()
workflow = self.create_workflow('random_fact')
processor = WorkflowProcessor(workflow)
processor.do_engine_steps()
docs = "Some simple docs"
task = Task(1, "bill", "bill", "", "started", {}, docs, {})
WorkflowService._process_documentation(task, docs)
self.assertEqual(docs, task.documentation)
task = processor.next_task()
task.task_spec.documentation = "Some simple docs"
docs = WorkflowService._process_documentation(task)
self.assertEqual("Some simple docs", docs)
task.data = {"replace_me": "new_thing"}
WorkflowService._process_documentation(task, "{{replace_me}}")
self.assertEqual("new_thing", task.documentation)
task.task_spec.documentation = "{{replace_me}}"
docs = WorkflowService._process_documentation(task)
self.assertEqual("new_thing", docs)
documentation = """
# Bigger Test
@ -41,19 +46,25 @@ class TestWorkflowService(BaseTest):
# other stuff.
"""
WorkflowService._process_documentation(task,(documentation))
self.assertEqual(expected, task.documentation)
task.task_spec.documentation = documentation
result = WorkflowService._process_documentation(task)
self.assertEqual(expected, result)
def test_documentation_processing_handles_conditionals(self):
docs = "This test {% if works == 'yes' %}works{% endif %}"
task = Task(1, "bill", "bill", "", "started", {}, docs, {})
WorkflowService._process_documentation(task, docs)
self.assertEqual("This test ", task.documentation)
self.load_example_data()
workflow = self.create_workflow('random_fact')
processor = WorkflowProcessor(workflow)
processor.do_engine_steps()
task = processor.next_task()
task.task_spec.documentation = "This test {% if works == 'yes' %}works{% endif %}"
docs = WorkflowService._process_documentation(task)
self.assertEqual("This test ", docs)
task.data = {"works": 'yes'}
WorkflowService._process_documentation(task, docs)
self.assertEqual("This test works", task.documentation)
docs = WorkflowService._process_documentation(task)
self.assertEqual("This test works", docs)
def test_enum_options_from_file(self):
self.load_example_data()

View File

@ -1,7 +1,6 @@
import json
from crc import session
from crc.api.common import ApiErrorSchema
from crc.models.file import FileModel
from crc.models.workflow import WorkflowSpecModel, WorkflowSpecModelSchema, WorkflowModel, WorkflowSpecCategoryModel
from tests.base_test import BaseTest
@ -24,7 +23,7 @@ class TestWorkflowSpec(BaseTest):
self.assertEqual(spec.description, spec2.description)
def test_add_new_workflow_specification(self):
self.load_example_data();
self.load_example_data()
num_before = session.query(WorkflowSpecModel).count()
spec = WorkflowSpecModel(id='make_cookies', name='make_cookies', display_name='Cooooookies',
description='Om nom nom delicious cookies')