diff --git a/Pipfile.lock b/Pipfile.lock index 90465cc6..58285c07 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -783,31 +783,40 @@ "spiffworkflow": { "editable": true, "git": "https://github.com/sartography/SpiffWorkflow.git", - "ref": "bb83497f5128f6221113b090d8de84401b0f108f" + "ref": "661902387021f7130ae27fa35434eb3b2c138610" }, "sqlalchemy": { "hashes": [ - "sha256:083e383a1dca8384d0ea6378bd182d83c600ed4ff4ec8247d3b2442cf70db1ad", - "sha256:0a690a6486658d03cc6a73536d46e796b6570ac1f8a7ec133f9e28c448b69828", - "sha256:114b6ace30001f056e944cebd46daef38fdb41ebb98f5e5940241a03ed6cad43", - "sha256:128f6179325f7597a46403dde0bf148478f868df44841348dfc8d158e00db1f9", - "sha256:13d48cd8b925b6893a4e59b2dfb3e59a5204fd8c98289aad353af78bd214db49", - "sha256:211a1ce7e825f7142121144bac76f53ac28b12172716a710f4bf3eab477e730b", - "sha256:2dc57ee80b76813759cccd1a7affedf9c4dbe5b065a91fb6092c9d8151d66078", - "sha256:3e625e283eecc15aee5b1ef77203bfb542563fa4a9aa622c7643c7b55438ff49", - "sha256:43078c7ec0457387c79b8d52fff90a7ad352ca4c7aa841c366238c3e2cf52fdf", - "sha256:5b1bf3c2c2dca738235ce08079783ef04f1a7fc5b21cf24adaae77f2da4e73c3", - "sha256:6056b671aeda3fc451382e52ab8a753c0d5f66ef2a5ccc8fa5ba7abd20988b4d", - "sha256:68d78cf4a9dfade2e6cf57c4be19f7b82ed66e67dacf93b32bb390c9bed12749", - "sha256:7025c639ce7e170db845e94006cf5f404e243e6fc00d6c86fa19e8ad8d411880", - "sha256:7224e126c00b8178dfd227bc337ba5e754b197a3867d33b9f30dc0208f773d70", - "sha256:7d98e0785c4cd7ae30b4a451416db71f5724a1839025544b4edbd92e00b91f0f", - "sha256:8d8c21e9d4efef01351bf28513648ceb988031be4159745a7ad1b3e28c8ff68a", - "sha256:bbb545da054e6297242a1bb1ba88e7a8ffb679f518258d66798ec712b82e4e07", - "sha256:d00b393f05dbd4ecd65c989b7f5a81110eae4baea7a6a4cdd94c20a908d1456e", - "sha256:e18752cecaef61031252ca72031d4d6247b3212ebb84748fc5d1a0d2029c23ea" + "sha256:128bc917ed20d78143a45024455ff0aed7d3b96772eba13d5dbaf9cc57e5c41b", + "sha256:156a27548ba4e1fed944ff9fcdc150633e61d350d673ae7baaf6c25c04ac1f71", + "sha256:27e2efc8f77661c9af2681755974205e7462f1ae126f498f4fe12a8b24761d15", + "sha256:2a12f8be25b9ea3d1d5b165202181f2b7da4b3395289000284e5bb86154ce87c", + "sha256:31c043d5211aa0e0773821fcc318eb5cbe2ec916dfbc4c6eea0c5188971988eb", + "sha256:65eb3b03229f684af0cf0ad3bcc771970c1260a82a791a8d07bffb63d8c95bcc", + "sha256:6cd157ce74a911325e164441ff2d9b4e244659a25b3146310518d83202f15f7a", + "sha256:703c002277f0fbc3c04d0ae4989a174753a7554b2963c584ce2ec0cddcf2bc53", + "sha256:869bbb637de58ab0a912b7f20e9192132f9fbc47fc6b5111cd1e0f6cdf5cf9b0", + "sha256:8a0e0cd21da047ea10267c37caf12add400a92f0620c8bc09e4a6531a765d6d7", + "sha256:8d01e949a5d22e5c4800d59b50617c56125fc187fbeb8fa423e99858546de616", + "sha256:925b4fe5e7c03ed76912b75a9a41dfd682d59c0be43bce88d3b27f7f5ba028fb", + "sha256:9cb1819008f0225a7c066cac8bb0cf90847b2c4a6eb9ebb7431dbd00c56c06c5", + "sha256:a87d496884f40c94c85a647c385f4fd5887941d2609f71043e2b73f2436d9c65", + "sha256:a9030cd30caf848a13a192c5e45367e3c6f363726569a56e75dc1151ee26d859", + "sha256:a9e75e49a0f1583eee0ce93270232b8e7bb4b1edc89cc70b07600d525aef4f43", + "sha256:b50f45d0e82b4562f59f0e0ca511f65e412f2a97d790eea5f60e34e5f1aabc9a", + "sha256:b7878e59ec31f12d54b3797689402ee3b5cfcb5598f2ebf26491732758751908", + "sha256:ce1ddaadee913543ff0154021d31b134551f63428065168e756d90bdc4c686f5", + "sha256:ce2646e4c0807f3461be0653502bb48c6e91a5171d6e450367082c79e12868bf", + "sha256:ce6c3d18b2a8ce364013d47b9cad71db815df31d55918403f8db7d890c9d07ae", + "sha256:e4e2664232005bd306f878b0f167a31f944a07c4de0152c444f8c61bbe3cfb38", + "sha256:e8aa395482728de8bdcca9cc0faf3765ab483e81e01923aaa736b42f0294f570", + "sha256:eb4fcf7105bf071c71068c6eee47499ab8d4b8f5a11fc35147c934f0faa60f23", + "sha256:ed375a79f06cad285166e5be74745df1ed6845c5624aafadec4b7a29c25866ef", + "sha256:f35248f7e0d63b234a109dd72fbfb4b5cb6cb6840b221d0df0ecbf54ab087654", + "sha256:f502ef245c492b391e0e23e94cba030ab91722dcc56963c85bfd7f3441ea2bbe", + "sha256:fe01bac7226499aedf472c62fa3b85b2c619365f3f14dd222ffe4f3aa91e5f98" ], - "version": "==1.3.16" + "version": "==1.3.17" }, "swagger-ui-bundle": { "hashes": [ diff --git a/crc/api.yml b/crc/api.yml index 081bf676..52abbde4 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -939,12 +939,10 @@ components: status: type: enum enum: ['new','user_input_required','waiting','complete'] - user_tasks: + navigation: type: array items: - $ref: "#/components/schemas/Task" - last_task: - $ref: "#/components/schemas/Task" + $ref: "#/components/schemas/NavigationItem" next_task: $ref: "#/components/schemas/Task" workflow_spec_id: @@ -990,6 +988,19 @@ components: $ref: "#/components/schemas/Form" documentation: type: string + data: + type: object + multi_instance_type: + type: enum + enum: ['none', 'looping', 'parallel', 'sequential'] + multi_instance_count: + type: number + multi_instance_index: + type: number + process_name: + type: string + properties: + type: object example: id: study_identification name: Study Identification @@ -1160,5 +1171,45 @@ components: example: "Chuck Norris" data: type: any + NavigationItem: + properties: + id: + type: number + format: integer + example: 5 + task_id: + type: string + format: uuid + example: "1234123uuid1234" + name: + type: string + example: "Task_Has_bananas" + description: + type: string + example: "Has Bananas?" + backtracks: + type: boolean + example: false + level: + type: integer + example: 1 + indent: + type: integer + example: 2 + child_count: + type: integer + example: 4 + state: + type: enum + enum: ['FUTURE', 'WAITING', 'READY', 'CANCELLED', 'COMPLETED','LIKELY','MAYBE'] + readOnly: true + is_decision: + type: boolean + example: False + readOnly: true + task: + $ref: "#/components/schemas/Task" + + diff --git a/crc/api/workflow.py b/crc/api/workflow.py index de67c87f..8f5b751c 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -2,7 +2,7 @@ import uuid from crc import session from crc.api.common import ApiError, ApiErrorSchema -from crc.models.api_models import WorkflowApi, WorkflowApiSchema +from crc.models.api_models import WorkflowApi, WorkflowApiSchema, NavigationItem, NavigationItemSchema from crc.models.file import FileModel, LookupDataSchema from crc.models.workflow import WorkflowModel, WorkflowSpecModelSchema, WorkflowSpecModel, WorkflowSpecCategoryModel, \ WorkflowSpecCategoryModelSchema @@ -83,16 +83,33 @@ def delete_workflow_specification(spec_id): session.commit() -def __get_workflow_api_model(processor: WorkflowProcessor): - spiff_tasks = processor.get_all_user_tasks() - user_tasks = [WorkflowService.spiff_task_to_api_task(t, add_docs_and_forms=True) for t in spiff_tasks] +def __get_workflow_api_model(processor: WorkflowProcessor, next_task = None): + """Returns an API model representing the state of the current workflow, if requested, and + possible, next_task is set to the current_task.""" + + nav_dict = processor.bpmn_workflow.get_nav_list() + navigation = [] + for nav_item in nav_dict: + spiff_task = processor.bpmn_workflow.get_task(nav_item['task_id']) + if 'description' in nav_item: + nav_item['title'] = nav_item.pop('description') + else: + nav_item['title'] = "" + if spiff_task: + nav_item['task'] = WorkflowService.spiff_task_to_api_task(spiff_task, add_docs_and_forms=False) + nav_item['title'] = nav_item['task'].title # Prefer the task title. + else: + nav_item['task'] = None + if not 'is_decision' in nav_item: + nav_item['is_decision'] = False + + navigation.append(NavigationItem(**nav_item)) + NavigationItemSchema().dump(nav_item) workflow_api = WorkflowApi( id=processor.get_workflow_id(), status=processor.get_status(), - last_task=WorkflowService.spiff_task_to_api_task(processor.bpmn_workflow.last_task), next_task=None, - previous_task=processor.previous_task(), - user_tasks=user_tasks, + navigation=navigation, workflow_spec_id=processor.workflow_spec_id, spec_version=processor.get_spec_version(), is_latest_spec=processor.get_spec_version() == processor.get_latest_version_string(processor.workflow_spec_id), @@ -100,7 +117,9 @@ def __get_workflow_api_model(processor: WorkflowProcessor): completed_tasks=processor.workflow_model.completed_tasks, last_updated=processor.workflow_model.last_updated ) - next_task = processor.next_task() + if not next_task: # The Next Task can be requested to be a certain task, useful for parallel tasks. + # This may or may not work, sometimes there is no next task to complete. + next_task = processor.next_task() if next_task: workflow_api.next_task = WorkflowService.spiff_task_to_api_task(next_task, add_docs_and_forms=True) @@ -117,19 +136,20 @@ def get_workflow(workflow_id, soft_reset=False, hard_reset=False): def delete_workflow(workflow_id): StudyService.delete_workflow(workflow_id) + def set_current_task(workflow_id, task_id): workflow_model = session.query(WorkflowModel).filter_by(id=workflow_id).first() processor = WorkflowProcessor(workflow_model) task_id = uuid.UUID(task_id) task = processor.bpmn_workflow.get_task(task_id) - if task.state != task.COMPLETED: + if task.state != task.COMPLETED and task.state != task.READY: raise ApiError("invalid_state", "You may not move the token to a task who's state is not " - "currently set to COMPLETE.") + "currently set to COMPLETE or READY.") task.reset_token(reset_data=False) # we could optionally clear the previous data. processor.save() WorkflowService.log_task_action(processor, task, WorkflowService.TASK_ACTION_TOKEN_RESET) - workflow_api_model = __get_workflow_api_model(processor) + workflow_api_model = __get_workflow_api_model(processor, task) return WorkflowApiSchema().dump(workflow_api_model) diff --git a/crc/models/api_models.py b/crc/models/api_models.py index 020a1c45..4e7d4304 100644 --- a/crc/models/api_models.py +++ b/crc/models/api_models.py @@ -15,6 +15,20 @@ class MultiInstanceType(enum.Enum): sequential = "sequential" +class NavigationItem(object): + def __init__(self, id, task_id, name, title, backtracks, level, indent, child_count, state, is_decision, task=None): + self.id = id + self.task_id = task_id + self.name = name, + self.title = title + self.backtracks = backtracks + self.level = level + self.indent = indent + self.child_count = child_count + self.state = state + self.is_decision = is_decision + self.task = task + class Task(object): ENUM_OPTIONS_FILE_PROP = "enum.options.file" @@ -22,9 +36,8 @@ class Task(object): EMUM_OPTIONS_LABEL_COL_PROP = "enum.options.label.column" EMUM_OPTIONS_AS_LOOKUP = "enum.options.lookup" - def __init__(self, id, name, title, type, state, form, documentation, data, - mi_type, mi_count, mi_index, process_name, properties): + multi_instance_type, multi_instance_count, multi_instance_index, process_name, properties): self.id = id self.name = name self.title = title @@ -33,9 +46,9 @@ class Task(object): self.form = form self.documentation = documentation self.data = data - self.mi_type = mi_type # Some tasks have a repeat behavior. - self.mi_count = mi_count # This is the number of times the task could repeat. - self.mi_index = mi_index # And the index of the currently repeating task. + self.multi_instance_type = multi_instance_type # Some tasks have a repeat behavior. + self.multi_instance_count = multi_instance_count # This is the number of times the task could repeat. + self.multi_instance_index = multi_instance_index # And the index of the currently repeating task. self.process_name = process_name self.properties = properties # Arbitrary extension properties from BPMN editor. @@ -50,10 +63,11 @@ class ValidationSchema(ma.Schema): fields = ["name", "config"] -class PropertiesSchema(ma.Schema): +class FormFieldPropertySchema(ma.Schema): class Meta: - fields = ["id", "value"] - + fields = [ + "id", "value" + ] class FormFieldSchema(ma.Schema): class Meta: @@ -64,7 +78,7 @@ class FormFieldSchema(ma.Schema): default_value = marshmallow.fields.String(required=False, allow_none=True) options = marshmallow.fields.List(marshmallow.fields.Nested(OptionSchema)) validation = marshmallow.fields.List(marshmallow.fields.Nested(ValidationSchema)) - properties = marshmallow.fields.List(marshmallow.fields.Nested(PropertiesSchema)) + properties = marshmallow.fields.List(marshmallow.fields.Nested(FormFieldPropertySchema)) class FormSchema(ma.Schema): @@ -74,14 +88,13 @@ class FormSchema(ma.Schema): class TaskSchema(ma.Schema): class Meta: - fields = ["id", "name", "title", "type", "state", "form", "documentation", "data", "mi_type", - "mi_count", "mi_index", "process_name", "properties"] + fields = ["id", "name", "title", "type", "state", "form", "documentation", "data", "multi_instance_type", + "multi_instance_count", "multi_instance_index", "process_name", "properties"] - mi_type = EnumField(MultiInstanceType) + multi_instance_type = EnumField(MultiInstanceType) documentation = marshmallow.fields.String(required=False, allow_none=True) form = marshmallow.fields.Nested(FormSchema, required=False, allow_none=True) title = marshmallow.fields.String(required=False, allow_none=True) - properties = marshmallow.fields.List(marshmallow.fields.Nested(PropertiesSchema)) process_name = marshmallow.fields.String(required=False, allow_none=True) @marshmallow.post_load @@ -89,15 +102,24 @@ class TaskSchema(ma.Schema): return Task(**data) +class NavigationItemSchema(ma.Schema): + class Meta: + fields = ["id", "task_id", "name", "title", "backtracks", "level", "indent", "child_count", "state", + "is_decision", "task"] + unknown = INCLUDE + task = marshmallow.fields.Nested(TaskSchema, dump_only=True, required=False, allow_none=True) + backtracks = marshmallow.fields.String(required=False, allow_none=True) + title = marshmallow.fields.String(required=False, allow_none=True) + task_id = marshmallow.fields.String(required=False, allow_none=True) + + class WorkflowApi(object): - def __init__(self, id, status, user_tasks, last_task, next_task, previous_task, + def __init__(self, id, status, next_task, navigation, spec_version, is_latest_spec, workflow_spec_id, total_tasks, completed_tasks, last_updated): self.id = id self.status = status - self.user_tasks = user_tasks - self.last_task = last_task # The last task that was completed, may be different than previous. self.next_task = next_task # The next task that requires user input. - self.previous_task = previous_task # The opposite of next task. + self.navigation = navigation self.workflow_spec_id = workflow_spec_id self.spec_version = spec_version self.is_latest_spec = is_latest_spec @@ -108,21 +130,20 @@ class WorkflowApi(object): class WorkflowApiSchema(ma.Schema): class Meta: model = WorkflowApi - fields = ["id", "status", "user_tasks", "last_task", "next_task", "previous_task", + fields = ["id", "status", "next_task", "navigation", "workflow_spec_id", "spec_version", "is_latest_spec", "total_tasks", "completed_tasks", "last_updated"] unknown = INCLUDE status = EnumField(WorkflowStatus) - user_tasks = marshmallow.fields.List(marshmallow.fields.Nested(TaskSchema, dump_only=True)) - last_task = marshmallow.fields.Nested(TaskSchema, dump_only=True, required=False) next_task = marshmallow.fields.Nested(TaskSchema, dump_only=True, required=False) - previous_task = marshmallow.fields.Nested(TaskSchema, dump_only=True, required=False) + navigation = marshmallow.fields.List(marshmallow.fields.Nested(NavigationItemSchema, dump_only=True)) @marshmallow.post_load def make_workflow(self, data, **kwargs): - keys = ['id', 'status', 'user_tasks', 'last_task', 'next_task', 'previous_task', + keys = ['id', 'status', 'next_task', 'navigation', 'workflow_spec_id', 'spec_version', 'is_latest_spec', "total_tasks", "completed_tasks", "last_updated"] filtered_fields = {key: data[key] for key in keys} + filtered_fields['next_task'] = TaskSchema().make_task(data['next_task']) return WorkflowApi(**filtered_fields) diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index ce85b43d..7f06c47b 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -392,6 +392,17 @@ class WorkflowProcessor(object): def get_ready_user_tasks(self): return self.bpmn_workflow.get_ready_user_tasks() + def get_current_user_tasks(self): + """Return a list of all user tasks that are READY or + COMPLETE and are parallel to the READY Task.""" + ready_tasks = self.bpmn_workflow.get_ready_user_tasks() + additional_tasks = [] + if len(ready_tasks) > 0: + for child in ready_tasks[0].parent.children: + if child.state == SpiffTask.COMPLETED: + additional_tasks.append(child) + return ready_tasks + additional_tasks + def get_all_user_tasks(self): all_tasks = self.bpmn_workflow.get_tasks(SpiffTask.ANY_MASK) return [t for t in all_tasks if not self.bpmn_workflow._is_engine_task(t.task_spec)] @@ -425,5 +436,8 @@ class WorkflowProcessor(object): return process_elements[0].attrib['id'] - + def get_nav_item(self, task): + for nav_item in self.bpmn_workflow.get_nav_list(): + if nav_item['task_id'] == task.id: + return nav_item diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index f9fe308e..faee089a 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -10,7 +10,7 @@ from flask import g from pandas import ExcelFile from sqlalchemy import func -from crc import db +from crc import db, app from crc.api.common import ApiError from crc.models.api_models import Task, MultiInstanceType import jinja2 @@ -24,7 +24,6 @@ from SpiffWorkflow import Task as SpiffTask, WorkflowException class WorkflowService(object): - TASK_ACTION_COMPLETE = "Complete" TASK_ACTION_TOKEN_RESET = "Backwards Move" TASK_ACTION_HARD_RESET = "Restart (Hard)" @@ -54,7 +53,8 @@ class WorkflowService(object): tasks = bpmn_workflow.get_tasks(SpiffTask.READY) for task in tasks: task_api = WorkflowService.spiff_task_to_api_task( - task, add_docs_and_forms=True) # Assure we try to process the documenation, and raise those errors. + task, + add_docs_and_forms=True) # Assure we try to process the documenation, and raise those errors. WorkflowProcessor.populate_form_with_random_data(task, task_api) task.complete() except WorkflowException as we: @@ -90,10 +90,10 @@ class WorkflowService(object): else: mi_type = MultiInstanceType.none - props = [] + props = {} if hasattr(spiff_task.task_spec, 'extensions'): for id, val in spiff_task.task_spec.extensions.items(): - props.append({"id": id, "value": val}) + props[id] = val task = Task(spiff_task.id, spiff_task.task_spec.name, @@ -102,25 +102,52 @@ class WorkflowService(object): spiff_task.get_state_name(), None, "", - spiff_task.data, + {}, mi_type, info["mi_count"], info["mi_index"], process_name=spiff_task.task_spec._wf_spec.description, - properties=props) + properties=props + ) # Only process the form and documentation if requested. # The task should be in a completed or a ready state, and should # not be a previously completed MI Task. if add_docs_and_forms: + task.data = spiff_task.data if hasattr(spiff_task.task_spec, "form"): task.form = spiff_task.task_spec.form for field in task.form.fields: WorkflowService.process_options(spiff_task, field) - task.documentation = WorkflowService._process_documentation(spiff_task) + + # All ready tasks should have a valid name, and this can be computed for + # some tasks, particularly multi-instance tasks that all have the same spec + # but need different labels. + if spiff_task.state == SpiffTask.READY: + task.properties = WorkflowService._process_properties(spiff_task, props) + + # Replace the title with the display name if it is set in the task properties, + # otherwise strip off the first word of the task, as that should be following + # a BPMN standard, and should not be included in the display. + if task.properties and "display_name" in task.properties: + task.title = task.properties['display_name'] + elif task.title and ' ' in task.title: + task.title = task.title.partition(' ')[2] + return task + @staticmethod + def _process_properties(spiff_task, props): + """Runs all the property values through the Jinja2 processor to inject data.""" + for k, v in props.items(): + try: + template = Template(v) + props[k] = template.render(**spiff_task.data) + except jinja2.exceptions.TemplateError as ue: + app.logger.error("Failed to process task property %s " % str(ue)) + return props + @staticmethod def _process_documentation(spiff_task): """Runs the given documentation string through the Jinja2 processor to inject data @@ -144,7 +171,7 @@ class WorkflowService(object): return template.render(**spiff_task.data) except jinja2.exceptions.TemplateError as ue: -# return "Error processing template. %s" % ue.message + # return "Error processing template. %s" % ue.message raise ApiError(code="template_error", message="Error processing template for task %s: %s" % (spiff_task.task_spec.name, str(ue)), status_code=500) # TODO: Catch additional errors and report back. @@ -261,14 +288,11 @@ class WorkflowService(object): task_title=task.title, task_type=str(task.type), task_state=task.state, - mi_type=task.mi_type.value, # Some tasks have a repeat behavior. - mi_count=task.mi_count, # This is the number of times the task could repeat. - mi_index=task.mi_index, # And the index of the currently repeating task. + mi_type=task.multi_instance_type.value, # Some tasks have a repeat behavior. + mi_count=task.multi_instance_count, # This is the number of times the task could repeat. + mi_index=task.multi_instance_index, # And the index of the currently repeating task. process_name=task.process_name, date=datetime.now(), ) db.session.add(task_event) db.session.commit() - - - diff --git a/crc/static/bpmn/data_security_plan/data_security_plan.bpmn b/crc/static/bpmn/data_security_plan/data_security_plan.bpmn index b24e6ea5..fc6704fa 100644 --- a/crc/static/bpmn/data_security_plan/data_security_plan.bpmn +++ b/crc/static/bpmn/data_security_plan/data_security_plan.bpmn @@ -1,5 +1,5 @@ - + SequenceFlow_100w7co @@ -372,7 +372,7 @@ SequenceFlow_0nc6lcs SequenceFlow_0gp2pjm - + @@ -389,7 +389,7 @@ Indicate all the possible formats in which you will transmit your data outside o - SequenceFlow_0gp2pjm + Flow_0cpwkms SequenceFlow_0mgwas4 @@ -623,222 +623,339 @@ Indicate all the possible formats in which you will collect or receive your orig SequenceFlow_0blyor8 SequenceFlow_1oq4w2h + + SequenceFlow_0gp2pjm + Flow_0cpwkms + + + + + > Instructions +o Hippa Instructions +o Hippa Indentifiers +o Vuew Definitions and Instructions +o Paper Documents +o Emailed to UVA Personnel +o EMC (EPIC) +o UVA Approvled eCRF +o UVA Servers +o Web or Cloud Server +o Individual Use Devices +o Device Details +0 Outside of UVA + +o Outside of UVA? +     o Yes  +           o Email Methods +           o Data Management +           o Transmission Method +           o Generate DSP  +    o No +           o Generate DSP + + + + *  Instructions +* Hippa Instructions +* Hippa Indentifiers +o Vuew Definitions and Instructions +>> Paper Documents +> Emailed to UVA Personnel +> EMC (EPIC) +> UVA Approvled eCRF +> UVA Servers +> Web or Cloud Server +o Individual Use Devices +o Device Details +o Outside of UVA + +o Outside of UVA? +     o Yes  +           o Email Methods +           o Data Management +           o Transmission Method +           o Generate DSP  +    o No +           o Generate DSP + + + + * Instructions +* Hippa Instructions +* Hippa Indentifiers +* View Definitions and Instructions + + +* Paper Documents (Parallel creates spaces) +* Emailed to UVA Personnel +* EMC (EPIC) +* UVA Approvled eCRF +* UVA Servers +* Web or Cloud Server +* Individual Use Devices + +o Device Details (MultiInstance Indents, Parallel creates spaces)) + > Desktop + >> Laptop + > Cell Phone + > Other + +o Outside of UVA + +o Outside of UVA? +     o Yes  +           o Email Methods +           o Data Management +           o Transmission Method +           o Generate DSP  +    o No +           o Generate DSP + + + + + + + + + + + - - + + - - - - + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - - + + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - + + - - + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + + + + + - + - + - + - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example_data.py b/example_data.py index 0240dd9a..22e6f95b 100644 --- a/example_data.py +++ b/example_data.py @@ -1,16 +1,11 @@ -import datetime +import glob import glob import os -import xml.etree.ElementTree as ElementTree from crc import app, db, session -from crc.models.file import FileType, FileModel, FileDataModel, CONTENT_TYPES -from crc.models.study import StudyModel -from crc.models.user import UserModel +from crc.models.file import CONTENT_TYPES from crc.models.workflow import WorkflowSpecModel, WorkflowSpecCategoryModel from crc.services.file_service import FileService -from crc.services.workflow_processor import WorkflowProcessor -from crc.models.protocol_builder import ProtocolBuilderStatus class ExampleDataLoader: diff --git a/tests/data/exclusive_gateway/exclusive_gateway.bpmn b/tests/data/exclusive_gateway/exclusive_gateway.bpmn index 26ba0e7b..1c7e55fe 100644 --- a/tests/data/exclusive_gateway/exclusive_gateway.bpmn +++ b/tests/data/exclusive_gateway/exclusive_gateway.bpmn @@ -1,11 +1,11 @@ - + SequenceFlow_1pnq3kg - + @@ -15,7 +15,7 @@ SequenceFlow_1lmkn99 - + SequenceFlow_1lmkn99 SequenceFlow_Yes_Bananas SequenceFlow_No_Bananas @@ -55,29 +55,13 @@ - - - - - - + + + - - - - - - - - - - - - - - - - + + + @@ -87,6 +71,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -96,17 +107,9 @@ - - - - - - - - diff --git a/tests/data/multi_instance/multi_instance.bpmn b/tests/data/multi_instance/multi_instance.bpmn index df932364..d53f7b17 100644 --- a/tests/data/multi_instance/multi_instance.bpmn +++ b/tests/data/multi_instance/multi_instance.bpmn @@ -1,5 +1,5 @@ - + Flow_0t6p1sb @@ -17,6 +17,9 @@ + + + SequenceFlow_1p568pp Flow_0ugjw69 @@ -31,33 +34,33 @@ + + + + + + + + + + + + - - - - - - - - - - - - diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index af994275..5edfa686 100644 --- a/tests/test_tasks_api.py +++ b/tests/test_tasks_api.py @@ -66,9 +66,14 @@ class TestTasksApi(BaseTest): self.assertEquals(task_in.title, event.task_title) self.assertEquals(task_in.type, event.task_type) self.assertEquals("COMPLETED", event.task_state) - self.assertEquals(task_in.mi_type.value, event.mi_type) - self.assertEquals(task_in.mi_count, event.mi_count) - self.assertEquals(task_in.mi_index, event.mi_index) + # Not sure what vodoo is happening inside of marshmallow to get me in this state. + if isinstance(task_in.multi_instance_type, MultiInstanceType): + self.assertEquals(task_in.multi_instance_type.value, event.mi_type) + else: + self.assertEquals(task_in.multi_instance_type, event.mi_type) + + self.assertEquals(task_in.multi_instance_count, event.mi_count) + self.assertEquals(task_in.multi_instance_index, event.mi_index) self.assertEquals(task_in.process_name, event.process_name) self.assertIsNotNone(event.date) @@ -82,9 +87,9 @@ class TestTasksApi(BaseTest): workflow = self.create_workflow('random_fact') workflow = self.get_workflow_api(workflow) task = workflow.next_task - self.assertEqual("Task_User_Select_Type", task['name']) - self.assertEqual(3, len(task['form']["fields"][0]["options"])) - self.assertIsNotNone(task['documentation']) + self.assertEqual("Task_User_Select_Type", task.name) + self.assertEqual(3, len(task.form["fields"][0]["options"])) + self.assertIsNotNone(task.documentation) expected_docs = """# h1 Heading 8-) ## h2 Heading ### h3 Heading @@ -92,7 +97,7 @@ class TestTasksApi(BaseTest): ##### h5 Heading ###### h6 Heading """ - self.assertTrue(str.startswith(task['documentation'], expected_docs)) + self.assertTrue(str.startswith(task.documentation, expected_docs)) def test_two_forms_task(self): # Set up a new workflow @@ -101,67 +106,61 @@ class TestTasksApi(BaseTest): # get the first form in the two form workflow. workflow_api = self.get_workflow_api(workflow) self.assertEqual('two_forms', workflow_api.workflow_spec_id) - self.assertEqual(2, len(workflow_api.user_tasks)) - self.assertIsNotNone(workflow_api.next_task['form']) - self.assertEqual("UserTask", workflow_api.next_task['type']) - self.assertEqual("StepOne", workflow_api.next_task['name']) - self.assertEqual(1, len(workflow_api.next_task['form']['fields'])) + self.assertEqual(2, len(workflow_api.navigation)) + self.assertIsNotNone(workflow_api.next_task.form) + self.assertEqual("UserTask", workflow_api.next_task.type) + self.assertEqual("StepOne", workflow_api.next_task.name) + self.assertEqual(1, len(workflow_api.next_task.form['fields'])) # Complete the form for Step one and post it. - self.complete_form(workflow, workflow_api.user_tasks[0], {"color": "blue"}) + self.complete_form(workflow, workflow_api.next_task, {"color": "blue"}) # Get the next Task workflow_api = self.get_workflow_api(workflow) - self.assertEqual("StepTwo", workflow_api.next_task['name']) + self.assertEqual("StepTwo", workflow_api.next_task.name) # Get all user Tasks and check that the data have been saved - for task in workflow_api.user_tasks: - self.assertIsNotNone(task.data) - for val in task.data.values(): - self.assertIsNotNone(val) + task = workflow_api.next_task + self.assertIsNotNone(task.data) + for val in task.data.values(): + self.assertIsNotNone(val) def test_error_message_on_bad_gateway_expression(self): self.load_example_data() workflow = self.create_workflow('exclusive_gateway') # get the first form in the two form workflow. - tasks = self.get_workflow_api(workflow).user_tasks - self.complete_form(workflow, tasks[0], {"has_bananas": True}) + task = self.get_workflow_api(workflow).next_task + self.complete_form(workflow, task, {"has_bananas": True}) def test_workflow_with_parallel_forms(self): self.load_example_data() workflow = self.create_workflow('exclusive_gateway') # get the first form in the two form workflow. - tasks = self.get_workflow_api(workflow).user_tasks - self.complete_form(workflow, tasks[0], {"has_bananas": True}) + task = self.get_workflow_api(workflow).next_task + self.complete_form(workflow, task, {"has_bananas": True}) # Get the next Task workflow_api = self.get_workflow_api(workflow) - self.assertEqual("Task_Num_Bananas", workflow_api.next_task['name']) + self.assertEqual("Task_Num_Bananas", workflow_api.next_task.name) - def test_get_workflow_contains_details_about_last_task_data(self): + def test_navigation_with_parallel_forms(self): self.load_example_data() workflow = self.create_workflow('exclusive_gateway') # 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], {"has_bananas": True}) - - self.assertIsNotNone(workflow_api.last_task) - self.assertEqual({"has_bananas": True}, workflow_api.last_task['data']) - - def test_get_workflow_contains_reference_to_last_task_and_next_task(self): - self.load_example_data() - workflow = self.create_workflow('exclusive_gateway') - - # get the first form in the two form workflow. - tasks = self.get_workflow_api(workflow).user_tasks - self.complete_form(workflow, tasks[0], {"has_bananas": True}) - workflow_api = self.get_workflow_api(workflow) - self.assertIsNotNone(workflow_api.last_task) - self.assertIsNotNone(workflow_api.next_task) + + self.assertIsNotNone(workflow_api.navigation) + nav = workflow_api.navigation + self.assertEquals(5, len(nav)) + self.assertEquals("Do You Have Bananas", nav[0]['title']) + self.assertEquals("READY", nav[0]['state']) + self.assertEquals("Bananas?", nav[1]['title']) + self.assertEquals("FUTURE", nav[1]['state']) + self.assertEquals("yes", nav[2]['title']) + self.assertEquals("NOOP", nav[2]['state']) def test_document_added_to_workflow_shows_up_in_file_list(self): @@ -169,7 +168,7 @@ class TestTasksApi(BaseTest): self.create_reference_document() workflow = self.create_workflow('docx') # get the first form in the two form workflow. - tasks = self.get_workflow_api(workflow).user_tasks + task = self.get_workflow_api(workflow).next_task data = { "full_name": "Buck of the Wild", "date": "5/1/2020", @@ -177,9 +176,9 @@ class TestTasksApi(BaseTest): "company": "In the company of wolves", "last_name": "Mr. Wolf" } - workflow_api = self.complete_form(workflow, tasks[0], data) + workflow_api = self.complete_form(workflow, task, data) self.assertIsNotNone(workflow_api.next_task) - self.assertEqual("EndEvent_0evb22x", workflow_api.next_task['name']) + self.assertEqual("EndEvent_0evb22x", workflow_api.next_task.name) self.assertTrue(workflow_api.status == WorkflowStatus.complete) rv = self.app.get('/v1.0/file?workflow_id=%i' % workflow.id, headers=self.logged_in_headers()) self.assert_success(rv) @@ -197,14 +196,14 @@ class TestTasksApi(BaseTest): workflow = self.create_workflow('random_fact') workflow_api = self.get_workflow_api(workflow) task = workflow_api.next_task - self.assertEqual("Task_User_Select_Type", task['name']) - self.assertEqual(3, len(task['form']["fields"][0]["options"])) - self.assertIsNotNone(task['documentation']) - self.complete_form(workflow, workflow_api.user_tasks[0], {"type": "norris"}) + self.assertEqual("Task_User_Select_Type", task.name) + self.assertEqual(3, len(task.form["fields"][0]["options"])) + self.assertIsNotNone(task.documentation) + self.complete_form(workflow, workflow_api.next_task, {"type": "norris"}) workflow_api = self.get_workflow_api(workflow) - self.assertEqual("EndEvent_0u1cgrf", workflow_api.next_task['name']) - self.assertIsNotNone(workflow_api.next_task['documentation']) - self.assertTrue("norris" in workflow_api.next_task['documentation']) + self.assertEqual("EndEvent_0u1cgrf", workflow_api.next_task.name) + self.assertIsNotNone(workflow_api.next_task.documentation) + self.assertTrue("norris" in workflow_api.next_task.documentation) def test_load_workflow_from_outdated_spec(self): @@ -212,7 +211,7 @@ class TestTasksApi(BaseTest): self.load_example_data() workflow = self.create_workflow('two_forms') workflow_api = self.get_workflow_api(workflow) - self.complete_form(workflow, workflow_api.user_tasks[0], {"color": "blue"}) + self.complete_form(workflow, workflow_api.next_task, {"color": "blue"}) self.assertTrue(workflow_api.is_latest_spec) # Modify the specification, with a major change that alters the flow and can't be deserialized @@ -239,7 +238,7 @@ class TestTasksApi(BaseTest): self.load_example_data() workflow = self.create_workflow('two_forms') workflow_api = self.get_workflow_api(workflow) - self.complete_form(workflow, workflow_api.user_tasks[0], {"color": "blue"}) + self.complete_form(workflow, workflow_api.next_task, {"color": "blue"}) self.assertTrue(workflow_api.is_latest_spec) # Modify the specification, with a major change that alters the flow and can't be deserialized @@ -265,23 +264,22 @@ class TestTasksApi(BaseTest): 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"}) + task = self.get_workflow_api(workflow).next_task + workflow_api = self.complete_form(workflow, task, {"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']) + 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) def test_bpmn_extension_properties_are_populated(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 - self.assertEquals("JustAKey", tasks[0].properties[0]['id']) - self.assertEquals("JustAValue", tasks[0].properties[0]['value']) + task = self.get_workflow_api(workflow).next_task + self.assertEquals("JustAValue", task.properties['JustAKey']) @patch('crc.services.protocol_builder.requests.get') @@ -294,11 +292,15 @@ class TestTasksApi(BaseTest): workflow = self.create_workflow('multi_instance') # get the first form in the two form workflow. - tasks = self.get_workflow_api(workflow).user_tasks - self.assertEquals(1, len(tasks)) - self.assertEquals("UserTask", tasks[0].type) - self.assertEquals(MultiInstanceType.sequential, tasks[0].mi_type) - self.assertEquals(9, tasks[0].mi_count) + workflow = self.get_workflow_api(workflow) + navigation = self.get_workflow_api(workflow).navigation + self.assertEquals(4, len(navigation)) # Start task, form_task, multi_task, end task + self.assertEquals("UserTask", workflow.next_task.type) + self.assertEquals(MultiInstanceType.sequential.value, workflow.next_task.multi_instance_type) + self.assertEquals(9, workflow.next_task.multi_instance_count) + + # Assure that the names for each task are properly updated, so they aren't all the same. + self.assertEquals("Primary Investigator", workflow.next_task.properties['display_name']) def test_lookup_endpoint_for_task_field_enumerations(self): @@ -307,9 +309,9 @@ class TestTasksApi(BaseTest): # get the first form in the two form workflow. workflow = self.get_workflow_api(workflow) task = workflow.next_task - field_id = task['form']['fields'][0]['id'] + field_id = task.form['fields'][0]['id'] rv = self.app.get('/v1.0/workflow/%i/task/%s/lookup/%s?query=%s&limit=5' % - (workflow.id, task['id'], field_id, 'c'), # All records with a word that starts with 'c' + (workflow.id, task.id, field_id, 'c'), # All records with a word that starts with 'c' headers=self.logged_in_headers(), content_type="application/json") self.assert_success(rv) @@ -321,13 +323,16 @@ class TestTasksApi(BaseTest): self.load_example_data() workflow = self.create_workflow('subprocess') - tasks = self.get_workflow_api(workflow).user_tasks - self.assertEquals(2, len(tasks)) - self.assertEquals("UserTask", tasks[0].type) - self.assertEquals("Activity_A", tasks[0].name) - self.assertEquals("My Sub Process", tasks[0].process_name) - workflow_api = self.complete_form(workflow, tasks[0], {"name": "Dan"}) - task = TaskSchema().load(workflow_api.next_task) + workflow_api = self.get_workflow_api(workflow) + navigation = workflow_api.navigation + task = workflow_api.next_task + + self.assertEquals(2, len(navigation)) + self.assertEquals("UserTask", task.type) + self.assertEquals("Activity_A", task.name) + self.assertEquals("My Sub Process", task.process_name) + workflow_api = self.complete_form(workflow, task, {"name": "Dan"}) + task = workflow_api.next_task self.assertIsNotNone(task) self.assertEquals("Activity_B", task.name) @@ -340,46 +345,42 @@ class TestTasksApi(BaseTest): workflow = self.create_workflow('exclusive_gateway') # Start the workflow. - tasks = self.get_workflow_api(workflow).user_tasks - self.complete_form(workflow, tasks[0], {"has_bananas": True}) + first_task = self.get_workflow_api(workflow).next_task + self.complete_form(workflow, first_task, {"has_bananas": True}) workflow = self.get_workflow_api(workflow) - self.assertEquals('Task_Num_Bananas', workflow.next_task['name']) + self.assertEquals('Task_Num_Bananas', workflow.next_task.name) # Trying to re-submit the initial task, and answer differently, should result in an error. - self.complete_form(workflow, tasks[0], {"has_bananas": False}, error_code="invalid_state") + self.complete_form(workflow, first_task, {"has_bananas": False}, error_code="invalid_state") # Go ahead and set the number of bananas. workflow = self.get_workflow_api(workflow) - task = TaskSchema().load(workflow.next_task) + task = workflow.next_task self.complete_form(workflow, task, {"num_bananas": 4}) # We are now at the end of the workflow. # Make the old task the current task. - rv = self.app.put('/v1.0/workflow/%i/task/%s/set_token' % (workflow.id, tasks[0].id), + rv = self.app.put('/v1.0/workflow/%i/task/%s/set_token' % (workflow.id, first_task.id), headers=self.logged_in_headers(), content_type="application/json") self.assert_success(rv) json_data = json.loads(rv.get_data(as_text=True)) workflow = WorkflowApiSchema().load(json_data) - # Assure the last task is the task we were on before the reset, - # and the Next Task is the one we just reset the token to be on. - self.assertEquals("Task_Has_Bananas", workflow.next_task['name']) - self.assertEquals("End", workflow.last_task['name']) + # Assure the Next Task is the one we just reset the token to be on. + self.assertEquals("Task_Has_Bananas", workflow.next_task.name) # Go ahead and get that workflow one more time, it should still be right. workflow = self.get_workflow_api(workflow) - # Assure the last task is the task we were on before the reset, - # and the Next Task is the one we just reset the token to be on. - self.assertEquals("Task_Has_Bananas", workflow.next_task['name']) - self.assertEquals("End", workflow.last_task['name']) + # Assure the Next Task is the one we just reset the token to be on. + self.assertEquals("Task_Has_Bananas", workflow.next_task.name) # The next task should be a different value. - self.complete_form(workflow, tasks[0], {"has_bananas": False}) + self.complete_form(workflow, workflow.next_task, {"has_bananas": False}) workflow = self.get_workflow_api(workflow) - self.assertEquals('Task_Why_No_Bananas', workflow.next_task['name']) + self.assertEquals('Task_Why_No_Bananas', workflow.next_task.name) @patch('crc.services.protocol_builder.requests.get') def test_parallel_multi_instance(self, mock_get): @@ -392,27 +393,20 @@ class TestTasksApi(BaseTest): self.load_example_data() workflow = self.create_workflow('multi_instance_parallel') - tasks = self.get_workflow_api(workflow).user_tasks - self.assertEquals(9, len(tasks)) - self.assertEquals("UserTask", tasks[0].type) - self.assertEquals("MutiInstanceTask", tasks[0].name) - self.assertEquals("Gather more information", tasks[0].title) + workflow_api = self.get_workflow_api(workflow) + self.assertEquals(12, len(workflow_api.navigation)) + ready_items = [nav for nav in workflow_api.navigation if nav['state'] == "READY"] + self.assertEquals(9, len(ready_items)) + + self.assertEquals("UserTask", workflow_api.next_task.type) + self.assertEquals("MutiInstanceTask",workflow_api.next_task.name) + self.assertEquals("more information", workflow_api.next_task.title) for i in random.sample(range(9), 9): - self.complete_form(workflow, tasks[i], {"investigator":{"email": "dhf8r@virginia.edu"}}) - tasks = self.get_workflow_api(workflow).user_tasks + task = TaskSchema().load(ready_items[i]['task']) + self.complete_form(workflow, task, {"investigator":{"email": "dhf8r@virginia.edu"}}) + #tasks = self.get_workflow_api(workflow).user_tasks workflow = self.get_workflow_api(workflow) self.assertEquals(WorkflowStatus.complete, workflow.status) - # def test_parent_task_set_on_tasks(self): - # self.load_example_data() - # workflow = self.create_workflow('exclusive_gateway') - # - # # Start the workflow. - # workflow = self.get_workflow_api(workflow) - # self.assertEquals(None, workflow.previous_task) - # self.complete_form(workflow, workflow.next_task, {"has_bananas": True}) - # workflow = self.get_workflow_api(workflow) - # self.assertEquals('Task_Num_Bananas', workflow.next_task['name']) - # self.assertEquals('has_bananas', workflow.previous_task['name']) diff --git a/tests/test_workflow_processor_multi_instance.py b/tests/test_workflow_processor_multi_instance.py index f2252129..21fc3b43 100644 --- a/tests/test_workflow_processor_multi_instance.py +++ b/tests/test_workflow_processor_multi_instance.py @@ -61,9 +61,9 @@ class TestWorkflowProcessorMultiInstance(BaseTest): self.assertEqual("MutiInstanceTask", task.get_name()) api_task = WorkflowService.spiff_task_to_api_task(task) - self.assertEquals(MultiInstanceType.sequential, api_task.mi_type) - self.assertEquals(3, api_task.mi_count) - self.assertEquals(1, api_task.mi_index) + self.assertEquals(MultiInstanceType.sequential, api_task.multi_instance_type) + self.assertEquals(3, api_task.multi_instance_count) + self.assertEquals(1, api_task.multi_instance_index) task.update_data({"investigator":{"email":"asd3v@virginia.edu"}}) processor.complete_task(task) processor.do_engine_steps() @@ -72,8 +72,8 @@ class TestWorkflowProcessorMultiInstance(BaseTest): api_task = WorkflowService.spiff_task_to_api_task(task) self.assertEqual("MutiInstanceTask", api_task.name) task.update_data({"investigator":{"email":"asdf32@virginia.edu"}}) - self.assertEquals(3, api_task.mi_count) - self.assertEquals(2, api_task.mi_index) + self.assertEquals(3, api_task.multi_instance_count) + self.assertEquals(2, api_task.multi_instance_index) processor.complete_task(task) processor.do_engine_steps() @@ -81,8 +81,8 @@ class TestWorkflowProcessorMultiInstance(BaseTest): api_task = WorkflowService.spiff_task_to_api_task(task) self.assertEqual("MutiInstanceTask", task.get_name()) task.update_data({"investigator":{"email":"dhf8r@virginia.edu"}}) - self.assertEquals(3, api_task.mi_count) - self.assertEquals(3, api_task.mi_index) + self.assertEquals(3, api_task.multi_instance_count) + self.assertEquals(3, api_task.multi_instance_index) processor.complete_task(task) processor.do_engine_steps() task = processor.bpmn_workflow.last_task @@ -120,7 +120,7 @@ class TestWorkflowProcessorMultiInstance(BaseTest): self.assertEquals("asd3v", task.data["investigator"]["user_id"]) # The last of the tasks api_task = WorkflowService.spiff_task_to_api_task(task) - self.assertEquals(MultiInstanceType.parallel, api_task.mi_type) + self.assertEquals(MultiInstanceType.parallel, api_task.multi_instance_type) task.update_data({"investigator":{"email":"dhf8r@virginia.edu"}}) processor.complete_task(task) processor.do_engine_steps()