diff --git a/SpiffWorkflow/bpmn/parser/BpmnParser.py b/SpiffWorkflow/bpmn/parser/BpmnParser.py index 7b5cc082..605b0270 100644 --- a/SpiffWorkflow/bpmn/parser/BpmnParser.py +++ b/SpiffWorkflow/bpmn/parser/BpmnParser.py @@ -33,6 +33,7 @@ CallActivity, SubWorkflowTask, TransactionSubprocess, + AdHocSubprocess, InclusiveGateway, ExclusiveGateway, ParallelGateway, @@ -49,7 +50,7 @@ from SpiffWorkflow.bpmn.specs.mixins.subworkflow_task import SubWorkflowTask as SubWorkflowTaskMixin from .ValidationException import ValidationException -from .ProcessParser import ProcessParser +from .ProcessParser import ProcessParser, AdHocParser from .node_parser import DEFAULT_NSMAP from .spec_description import SPEC_DESCRIPTIONS from .util import full_tag, xpath_eval, first @@ -60,6 +61,7 @@ CallActivityParser, ScriptTaskParser, SubWorkflowParser, + AdHocSubprocessParser, ) from .event_parsers import ( EventBasedGatewayParser, @@ -132,6 +134,7 @@ class BpmnParser: full_tag('inclusiveGateway'): (ConditionalGatewayParser, InclusiveGateway), full_tag('callActivity'): (CallActivityParser, CallActivity), full_tag('transaction'): (SubWorkflowParser, TransactionSubprocess), + full_tag('adHocSubProcess'): (AdHocSubprocessParser, AdHocSubprocess), full_tag('scriptTask'): (ScriptTaskParser, ScriptTask), full_tag('serviceTask'): (TaskParser, ServiceTask), full_tag('intermediateCatchEvent'): (IntermediateCatchEventParser, IntermediateCatchEvent), @@ -145,6 +148,7 @@ class BpmnParser: OVERRIDE_PARSER_CLASSES = {} PROCESS_PARSER_CLASS = ProcessParser + AD_HOC_PARSER_CLASS = AdHocParser DATA_STORE_CLASSES = {} @@ -402,6 +406,12 @@ def create_parser(self, node, filename=None, lane=None): raise ValidationException(f'Duplicate process ID: {parser.bpmn_id}', node=node, file_name=filename) self.process_parsers[parser.bpmn_id] = parser + def create_ad_hoc_parser(self, node, filename=None, lane=None): + parser = self.AD_HOC_PARSER_CLASS(self, node, self.namespaces, self.data_stores, filename=filename, lane=lane) + if parser.bpmn_id in self.process_parsers: + raise ValidationException(f'Duplicate process ID: {parser.bpmn_id}', node=node, file_name=filename) + self.process_parsers[parser.bpmn_id] = parser + def get_process_dependencies(self): return self.process_dependencies diff --git a/SpiffWorkflow/bpmn/parser/ProcessParser.py b/SpiffWorkflow/bpmn/parser/ProcessParser.py index f5e72e30..57750fe6 100644 --- a/SpiffWorkflow/bpmn/parser/ProcessParser.py +++ b/SpiffWorkflow/bpmn/parser/ProcessParser.py @@ -18,11 +18,11 @@ # 02110-1301 USA from .ValidationException import ValidationException -from ..specs.bpmn_process_spec import BpmnProcessSpec +from ..specs.bpmn_process_spec import BpmnProcessSpec, AdHocSubprocessSpec from ..specs.data_spec import DataObject from ..specs.control import StartEventJoin, StartEventSplit from .node_parser import NodeParser -from .util import first +from .util import first, full_tag class ProcessParser(NodeParser): @@ -156,14 +156,8 @@ def parse_node(self, node): return task_spec def _parse(self): - # here we only look in the top level, We will have another - # bpmn:startEvent if we have a subworkflow task - start_node_list = self.xpath('./bpmn:startEvent') - if not start_node_list and self.process_executable: - raise ValidationException("No start event found", node=self.node, file_name=self.filename) - if not self.process_executable: - raise ValidationException(f"Process {self.bpmn_id} is not executable.", node=self.node, file_name=self.filename) - self.spec = BpmnProcessSpec(name=self.bpmn_id, description=self.get_name(), filename=self.filename) + + self.spec = self.create_spec() # Get the data objects for obj in self.xpath('./bpmn:dataObject'): @@ -175,9 +169,20 @@ def _parse(self): if io_spec is not None: self.spec.io_specification = self.parse_io_spec() - # set the data stores on the process spec so they can survive - # serialization self.spec.data_stores = self.data_stores + self.add_tasks() + + def create_spec(self): + if not self.process_executable: + raise ValidationException(f"Process {self.bpmn_id} is not executable.", node=self.node, file_name=self.filename) + return BpmnProcessSpec(name=self.bpmn_id, description=self.get_name(), filename=self.filename) + + def add_tasks(self): + + start_node_list = self.xpath('./bpmn:startEvent') + if not start_node_list and self.process_executable: + raise ValidationException("No start event found", node=self.node, file_name=self.filename) + for node in start_node_list: self.parse_node(node) @@ -207,3 +212,44 @@ def get_spec(self): if self.spec is None: self._parse() return self.spec + +class AdHocParser(ProcessParser): + + def _parse(self): + super()._parse() + self.spec.create_paths() + + def create_spec(self): + if self.attribute('ordering') == 'sequential': + raise ValidationException( + 'Sequential ordering for ad hoc subprocesses not supported', + node=self.node, + file_name=self.filename + ) + cancel_remaining = self.attribute('cancelRemainingInstances') in [None, 'true'] + condition = self.xpath('./bpmn:completionCondition') + condition = condition[0].text if len(condition) > 0 else None + return AdHocSubprocessSpec( + completion_condition=condition, + cancel_remaining=cancel_remaining, + name=self.bpmn_id, + description=self.get_name(), + filename=self.filename, + ) + + def add_tasks(self): + start_node_list = [] + for node in self.node.getchildren(): + if node.tag not in self.parser.PARSER_CLASSES: + continue + elif node.tag in [full_tag('startEvent'), full_tag('endEvent')]: + raise ValidationException( + 'Ad hoc subprocesses may not contain start or end events', + node=self.node, + file_name=self.filename, + ) + elif len(node.xpath('./bpmn:incoming', namespaces=self.nsmap)) == 0: + start_node_list.append(node) + + for node in start_node_list: + self.parse_node(node) diff --git a/SpiffWorkflow/bpmn/parser/task_parsers.py b/SpiffWorkflow/bpmn/parser/task_parsers.py index 2f252e0a..7b978b11 100644 --- a/SpiffWorkflow/bpmn/parser/task_parsers.py +++ b/SpiffWorkflow/bpmn/parser/task_parsers.py @@ -89,6 +89,19 @@ def get_call_activity_spec(task_parser): file_name=task_parser.filename) return called_element + @staticmethod + def get_ad_hoc_spec(task_parser): + task_parser.process_parser.parser.create_ad_hoc_parser( + task_parser.node, + filename=task_parser.filename, + lane=task_parser.lane + ) + spec_id = task_parser.node.get('id') + # This parser makes me want to cry + spec_parser = task_parser.process_parser.parser.process_parsers[spec_id] + spec_parser.parent = task_parser.process_parser + return spec_id + class SubWorkflowParser(TaskParser): @@ -109,6 +122,13 @@ def create_task(self): return self.spec_class(self.spec, self.bpmn_id, subworkflow_spec=subworkflow_spec, **self.bpmn_attributes) +class AdHocSubprocessParser(TaskParser): + + def create_task(self): + subworkflow_spec = SubprocessParser.get_ad_hoc_spec(self) + return self.spec_class(self.spec, self.bpmn_id, subworkflow_spec=subworkflow_spec, **self.bpmn_attributes) + + class ScriptTaskParser(TaskParser): """Parses a script task""" diff --git a/SpiffWorkflow/bpmn/serializer/config.py b/SpiffWorkflow/bpmn/serializer/config.py index 19807d3c..93926cbb 100644 --- a/SpiffWorkflow/bpmn/serializer/config.py +++ b/SpiffWorkflow/bpmn/serializer/config.py @@ -21,7 +21,7 @@ from SpiffWorkflow.bpmn import BpmnWorkflow, BpmnEvent from SpiffWorkflow.bpmn.util.subworkflow import BpmnSubWorkflow -from SpiffWorkflow.bpmn.specs import BpmnProcessSpec +from SpiffWorkflow.bpmn.specs import BpmnProcessSpec, AdHocSubprocessSpec from SpiffWorkflow.bpmn.specs.defaults import ( ManualTask, NoneTask, @@ -38,6 +38,7 @@ CallActivity, TransactionSubprocess, EventSubprocess, + AdHocSubprocess, StartEvent, EndEvent, IntermediateCatchEvent, @@ -83,7 +84,7 @@ BpmnEventConverter, ) from .helpers import BpmnDataSpecificationConverter, EventDefinitionConverter -from .default import BpmnProcessSpecConverter +from .default import BpmnProcessSpecConverter, AdHocSubprocessSpecConverter from .default.task_spec import ( BpmnTaskSpecConverter, ScriptTaskConverter, @@ -116,6 +117,7 @@ TaskDataReference: BpmnDataSpecificationConverter, BpmnIoSpecification: IOSpecificationConverter, BpmnProcessSpec: BpmnProcessSpecConverter, + AdHocSubprocessSpec: AdHocSubprocessSpecConverter, SimpleBpmnTask: BpmnTaskSpecConverter, BpmnStartTask: BpmnStartTaskConverter, _EndJoin: BpmnTaskSpecConverter, @@ -130,6 +132,7 @@ CallActivity: SubWorkflowConverter, TransactionSubprocess: SubWorkflowConverter, EventSubprocess: SubWorkflowConverter, + AdHocSubprocess: SubWorkflowConverter, BoundaryEventSplit: BpmnTaskSpecConverter, BoundaryEventJoin: EventJoinConverter, ExclusiveGateway: ExclusiveGatewayConverter, diff --git a/SpiffWorkflow/bpmn/serializer/default/__init__.py b/SpiffWorkflow/bpmn/serializer/default/__init__.py index 60421f11..addd67e5 100644 --- a/SpiffWorkflow/bpmn/serializer/default/__init__.py +++ b/SpiffWorkflow/bpmn/serializer/default/__init__.py @@ -18,7 +18,7 @@ # 02110-1301 USA from .workflow import BpmnWorkflowConverter, BpmnSubWorkflowConverter, BpmnEventConverter -from .process_spec import BpmnProcessSpecConverter +from .process_spec import BpmnProcessSpecConverter, AdHocSubprocessSpecConverter from .task_spec import ( BpmnTaskSpecConverter, ScriptTaskConverter, @@ -36,4 +36,4 @@ ErrorEscalationEventDefinitionConverter, MessageEventDefinitionConverter, MultipleEventDefinitionConverter, -) \ No newline at end of file +) diff --git a/SpiffWorkflow/bpmn/serializer/default/process_spec.py b/SpiffWorkflow/bpmn/serializer/default/process_spec.py index 2c705972..2d6f7577 100644 --- a/SpiffWorkflow/bpmn/serializer/default/process_spec.py +++ b/SpiffWorkflow/bpmn/serializer/default/process_spec.py @@ -86,3 +86,34 @@ def from_dict(self, dct): spec.bpmn_start_events = [spec.task_specs.get(name) for name in dct.get('bpmn_start_events', [])] return spec + +class AdHocSubprocessSpecConverter(BpmnProcessSpecConverter): + + def to_dict(self, spec): + dct = super().to_dict(spec) + dct['completion_condition'] = spec.completion_condition + dct['parallel'] = True + dct['cancel_remaining'] = True + dct['conditional_paths'] = [sp.name for sp in spec.conditional_paths] + return dct + + def from_dict(self, dct): + + completion_condition = dct.pop('completion_condition') + parallel = dct.pop('parallel') + cancel_remaining = dct.pop('cancel_remaining') + conditional = dct.pop('conditional_paths') + + spec = super().from_dict(dct) + spec.completion_condition = completion_condition + spec.parallel = parallel + spec.cancel_remaining = cancel_remaining + + for name in conditional: + spec.conditional_paths.append(spec.task_specs.get(name)) + + for task_spec in spec.task_specs.values(): + if spec.end.name in task_spec._outputs: + task_spec.completed_event.connect(spec.path_complete) + + return spec diff --git a/SpiffWorkflow/bpmn/specs/__init__.py b/SpiffWorkflow/bpmn/specs/__init__.py index fdef33d1..253a4377 100644 --- a/SpiffWorkflow/bpmn/specs/__init__.py +++ b/SpiffWorkflow/bpmn/specs/__init__.py @@ -19,4 +19,4 @@ from .bpmn_task_spec import BpmnTaskSpec from .data_spec import BpmnDataSpecification, BpmnDataStoreSpecification -from .bpmn_process_spec import BpmnProcessSpec \ No newline at end of file +from .bpmn_process_spec import BpmnProcessSpec, AdHocSubprocessSpec diff --git a/SpiffWorkflow/bpmn/specs/bpmn_process_spec.py b/SpiffWorkflow/bpmn/specs/bpmn_process_spec.py index bc7afc38..6c071e5e 100644 --- a/SpiffWorkflow/bpmn/specs/bpmn_process_spec.py +++ b/SpiffWorkflow/bpmn/specs/bpmn_process_spec.py @@ -17,9 +17,12 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA # 02110-1301 USA -from SpiffWorkflow.specs.WorkflowSpec import WorkflowSpec +from SpiffWorkflow import TaskState +from SpiffWorkflow.specs import WorkflowSpec, MultiChoice +from SpiffWorkflow.util.deep_merge import DeepMerge from SpiffWorkflow.bpmn.specs.control import _EndJoin, BpmnStartTask, SimpleBpmnTask from SpiffWorkflow.bpmn.specs.mixins.events.start_event import StartEvent +from SpiffWorkflow.bpmn.specs.mixins.multiinstance_task import StandardLoopTask, MultiInstanceTask class BpmnProcessSpec(WorkflowSpec): @@ -53,3 +56,104 @@ def _add_notify(self, task_spec): if isinstance(task_spec, (StartEvent, )): self.bpmn_start_events.append(task_spec) + +class AdHocSubprocessSpec(BpmnProcessSpec): + """ + This class represents an Ad Hoc Subprocess. Everything about it is questionable. I + think even the BPMN authors were skeptical about it. + + But there is a demand for this, so I'm taking a stab at implementation. + + If a task has no inputs, it will be connected to the start spec and run at least once. + A task can be run more than once if it is of a type that is conditionally executed. + Whenever a task completes, it can be reexecuted if it completed and the condition is + not met. + + Execution is pretty clearly determined by the state of the data, which is more or less + wholly incompatible with reliance on task data, since tasks can't be connected to each + other in any meaningful way. Therefore, I'm relying on workflow data to maintain the + state. + """ + + def __init__(self, completion_condition=None, parallel=True, cancel_remaining=True, **kwargs): + super().__init__(**kwargs) + self.completion_condition = completion_condition + self.parallel = parallel + self.cancel_remaining = cancel_remaining + self.conditional_paths = [] + + def create_paths(self): + # This forces the conditions to be checked at the start of the workflow + self.start.completed_event.connect(self.path_complete) + for task_spec in self.task_specs.values(): + if isinstance(task_spec, (BpmnStartTask, _EndJoin, SimpleBpmnTask)): + continue + if len(task_spec.inputs) == 0: + if self.conditional_path(task_spec): + self.conditional_paths.append(task_spec) + else: + self.start.connect(task_spec) + if len(task_spec.outputs) == 0: + task_spec.connect(self.end) + task_spec.completed_event.connect(self.path_complete) + + def path_complete(self, workflow, task): + + workflow.data.update(**task.data) + if workflow.script_engine.environment.evaluate(self.completion_condition, workflow.data): + if self.cancel_remaining: + for active_task in workflow.get_tasks(state=TaskState.NOT_FINISHED_MASK): + if active_task.task_spec.name not in [self.end.name, 'End']: + active_task.cancel() + else: + active_branches = [] + for child in workflow.task_tree.children: + # Updating loop/multiinstance data mid-execution is likely to lead to very bad results + # For other tasks, I think we can safely update it (though it might not have an effect in some cases) + active_task = workflow.get_next_task(child, state=TaskState.NOT_FINISHED_MASK) + if active_task is None or active_task.task_spec.name == self.end.name: + continue + if not isinstance(active_task.task_spec, (MultiChoice, StandardLoopTask, MultiInstanceTask)): + DeepMerge.merge(active_task.data, workflow.data) + active_task.data.pop('data_objects') + if child.task_spec in self.conditional_paths: + active_branches.append(child.task_spec) + + for task_spec in self.conditional_paths: + # Start any conditional branches that are not already running. + if task_spec not in active_branches: + self.add_path(workflow, task_spec) + + def add_path(self, workflow, task_spec): + + trigger = False + if isinstance(task_spec, (MultiChoice, )): + for cond, output in task_spec.cond_task_specs: + if cond is None or self.check_condition(cond.args[0], workflow): + trigger = True + break + elif isinstance(task_spec, (StandardLoopTask, )): + # Loops run while the condition is true, so if the condition is true, I'll restart the task + trigger = task_spec.condition is not None and self.check_condition(task_spec.condition, workflow) + elif isinstance(task_spec, (MultiInstanceTask, )): + # Multiinstance tasks run until the condition is true, so I'll restart if it *isn't*. + # I really don't understand why the spec authors couldn't have made the semantics consistent here. + trigger = task_spec.condition is not None and not self.check_condition(task_spec.condition, workflow) + + if trigger: + if self.start not in task_spec.inputs: + self.start.connect(task_spec) + child = workflow.task_tree._add_child(task_spec, TaskState.READY) + child.triggered = True + task_spec._predict(child, mask=TaskState.NOT_FINISHED_MASK) + DeepMerge.merge(child.data, workflow.data) + child.data.pop('data_objects') + + def conditional_path(self, task_spec): + return ( + isinstance(task_spec, (MultiChoice, )) or + (isinstance(task_spec, (StandardLoopTask, MultiInstanceTask)) and task_spec.condition is not None) + ) + + def check_condition(self, cond, workflow): + return workflow.script_engine.environment.evaluate(cond, workflow.data, external_context=workflow.data_objects) diff --git a/SpiffWorkflow/bpmn/specs/defaults.py b/SpiffWorkflow/bpmn/specs/defaults.py index af39fd09..17d8cb21 100644 --- a/SpiffWorkflow/bpmn/specs/defaults.py +++ b/SpiffWorkflow/bpmn/specs/defaults.py @@ -34,6 +34,7 @@ CallActivityMixin, TransactionSubprocessMixin, EventSubprocessMixin, + AdHocSubprocessMixin, StartEventMixin, EndEventMixin, IntermediateCatchEventMixin, @@ -92,6 +93,9 @@ class TransactionSubprocess(TransactionSubprocessMixin, BpmnSpecMixin): class EventSubprocess(EventSubprocessMixin, BpmnSpecMixin): pass +class AdHocSubprocess(AdHocSubprocessMixin, BpmnSpecMixin): + pass + class StartEvent(StartEventMixin, BpmnSpecMixin): pass diff --git a/SpiffWorkflow/bpmn/specs/mixins/__init__.py b/SpiffWorkflow/bpmn/specs/mixins/__init__.py index 26bbdf58..c92097b0 100644 --- a/SpiffWorkflow/bpmn/specs/mixins/__init__.py +++ b/SpiffWorkflow/bpmn/specs/mixins/__init__.py @@ -36,6 +36,7 @@ CallActivity as CallActivityMixin, TransactionSubprocess as TransactionSubprocessMixin, EventSubprocess as EventSubprocessMixin, + AdHocSubprocess as AdHocSubprocessMixin, ) from .events.start_event import StartEvent as StartEventMixin diff --git a/SpiffWorkflow/bpmn/specs/mixins/subworkflow_task.py b/SpiffWorkflow/bpmn/specs/mixins/subworkflow_task.py index 19b44349..5367704b 100644 --- a/SpiffWorkflow/bpmn/specs/mixins/subworkflow_task.py +++ b/SpiffWorkflow/bpmn/specs/mixins/subworkflow_task.py @@ -135,3 +135,6 @@ def __init__(self, wf_spec, bpmn_id, subworkflow_spec, **kwargs): class EventSubprocess(SubWorkflowTask): pass +class AdHocSubprocess(SubWorkflowTask): + pass + diff --git a/docs/bpmn/supported.rst b/docs/bpmn/supported.rst index 6f05b7ca..34c4c2a8 100644 --- a/docs/bpmn/supported.rst +++ b/docs/bpmn/supported.rst @@ -30,6 +30,45 @@ Subrocesses and Call Activities * Call Activity * Transaction Subprocess * Event Subprocess +* Ad Hoc Subprocess (experimental) + +.. note:: + + Ad Hoc Subprocesses are neither particularly well defined in the BPMN spec, nor + well-suited to the execution model of this library. + + The BPMN spec authors have this to say: + + "It is a challenge for a BPM engine to monitor the status of Ad-Hoc Sub-Processes, + usually these kind of Processes are handled through groupware applications (such as e-mail), + but BPMN allows modeling of Processes that are not necessarily executable, although there + are some process engines that can follow an Ad-Hoc Sub-Process." + + This library organizes tasks in a tree, with children inheriting data from their parents, + and needless to say this is difficult when tasks aren't connected in a meaningful way. + + The general operation of this subprocess type is as follows: + + * Data is maintained at the workflow level + * Whenever a fragment completes, this data is updated + * If any tasks or gateways have conditions associated with them, they will be triggered if the + condition is met when evaluated against the current state of the data + * Tasks with no conditions are automatically started when the workflow begins and are only + completed once + * Once a task starts, its data generally will not be updated; updates are only made when + triggering tasks. If the data of a multi-instance or loop tasks changes mid-execution, + you will likely be in a world of pain. + + Data management is going to be quite difficult, as it is very easy to overwrite updated + data with older data. For example, suppose you have two independent tasks modifying + different parts of the data. Whichever one completes later will overwrite the data + set by the first task. So you will have to be *very* careful to remove un-needed data + from individual tasks. Using data objects may alleviate the problem (they cause copying + values into and out of a task when the task runs), but there is still the potential of two + tasks reading/writing to the same data object. Such are the pitfalls of a global state. + + Use this task type with caution! + Events ------ diff --git a/tests/SpiffWorkflow/bpmn/AdHocSubprocessTest.py b/tests/SpiffWorkflow/bpmn/AdHocSubprocessTest.py new file mode 100644 index 00000000..3e5bec8a --- /dev/null +++ b/tests/SpiffWorkflow/bpmn/AdHocSubprocessTest.py @@ -0,0 +1,135 @@ +from SpiffWorkflow import TaskState +from SpiffWorkflow.bpmn import BpmnWorkflow + +from .BpmnWorkflowTestCase import BpmnWorkflowTestCase + +class AdHocSubprocessTest(BpmnWorkflowTestCase): + + def setUp(self): + spec, subprocesses = self.load_workflow_spec('ad_hoc_subprocess.bpmn', 'main') + self.workflow = BpmnWorkflow(spec, subprocesses) + + def testAdHocSubprocess(self): + self.actual_test() + + def testAdHocSubprocessSaveRestore(self): + self.actual_test(True) + + def actual_test(self, save_restore=False): + self.workflow.task_tree.data = { + 'text': '', + 'edit_requested': False, + 'revision_requested': False, + 'graphics_needed': [], + 'graphics': [], + 'editing_done': False, + 'done': False, + } + self.workflow.get_next_task(spec_name='Start').run() + self.workflow.get_next_task(spec_name='StartEvent_1').run() + + # This is the only task without a condition + ready_tasks = self.workflow.get_tasks(state=TaskState.READY) + self.assertEqual(len(ready_tasks), 1) + self.assertEqual(ready_tasks[0].task_spec.name, 'research') + ready_tasks[0].data['graphics_needed'] = ['fig 1', 'fig 2'] + ready_tasks[0].run() + + # I can't use the base method here because it breaks with the delta serializer + # Frankly, I'm amazed that all the other tests pass, as the root cause seems to be + # based on the order in which values are retrieved from the task data. + # The workflow will complete after deserialization, so that's going to have to + # suffice. + if save_restore: + dct = self.serializer.to_dict(self.workflow) + self.workflow = self.serializer.from_dict(dct) + + first_draft = self.workflow.get_next_task(spec_name='first_draft', state=TaskState.READY) + first_draft.data['text'] = 'first draft' + first_draft.data['edit_requested'] = True + first_draft.run() + + organize_refs = self.workflow.get_next_task(spec_name='organize_references', state=TaskState.READY) + organize_refs.run() + + # At this point, graphics and editing are needed + self.assertEqual(len(self.workflow.get_tasks(state=TaskState.READY)), 2) + + edit = self.workflow.get_next_task(spec_name='edit', state=TaskState.READY) + edit.data['revisions'] = ['rev 1', 'rev 2'] + edit.data['revision_requested'] = True + edit.data['edit_requested'] = False + edit.run() + + make_graphics = self.workflow.get_next_task(spec_name='make_graphics', state=TaskState.READY) + + # Now revise whould be active instead of edit + self.assertEqual(len(self.workflow.get_tasks(state=TaskState.READY)), 2) + + # This should reactivate edit + make_graphics.data['graphics'] = list(make_graphics.data['graphics_needed']) + make_graphics.data['edit_requested'] = True + make_graphics.run() + + self.assertEqual(len(self.workflow.get_tasks(state=TaskState.READY)), 2) + + revise = self.workflow.get_next_task(spec_name='revise', state=TaskState.READY) + revise.data['text'] = 'second draft' + revise.data['edit_requested'] = True + revise.data['revision_requested'] = False + # Irrelevant variables will need to be removed so they don't overwrite updated ones + # This should be done for all the tasks, but I don't have the patience for that + # People will need to be *really* careful using this type of task spec + revise.data.pop('graphics', None) + revise.data.pop('graphics_needed', None) + revise.run() + + # Only edit should be ready + self.assertEqual(len(self.workflow.get_tasks(state=TaskState.READY)), 1) + edit = self.workflow.get_next_task(spec_name='edit', state=TaskState.READY) + edit.data['graphics_needed'].append('fig 3') + edit.data['revisions'] = ['rev 3'] + edit.data['revision_requested'] = True + edit.data['edit_requested'] = False + edit.run() + + # Graphics and revise should be active now + self.assertEqual(len(self.workflow.get_tasks(state=TaskState.READY)), 2) + + revise = self.workflow.get_next_task(spec_name='revise', state=TaskState.READY) + revise.data['text'] = 'third draft' + revise.data['edit_requested'] = True + revise.data['revision_requested'] = False + revise.run() + + # Graphics and edit should be active now + self.assertEqual(len(self.workflow.get_tasks(state=TaskState.READY)), 2) + edit = self.workflow.get_next_task(spec_name='edit', state=TaskState.READY) + edit.data['edit_requested'] = False + edit.run() + + make_graphics = self.workflow.get_next_task(spec_name='make_graphics', state=TaskState.READY) + make_graphics.data['graphics'] = list(make_graphics.data['graphics_needed']) + make_graphics.data['edit_requested'] = True + make_graphics.data.pop('text', None) + make_graphics.data.pop('revision_requested', None) + make_graphics.run() + + # Final edit + self.assertEqual(len(self.workflow.get_tasks(state=TaskState.READY)), 1) + edit = self.workflow.get_next_task(spec_name='edit', state=TaskState.READY) + edit.data['edit_requested'] = False + edit.data['editing_done'] = True + edit.run() + + gateway = self.workflow.get_next_task(spec_name='Gateway_1bp40us', state=TaskState.READY) + gateway.run() + + finalize = self.workflow.get_next_task(spec_name='finalize', state=TaskState.READY) + finalize.data['done'] = True + finalize.run() + + self.workflow.do_engine_steps() + self.assertTrue(self.workflow.completed) + self.assertEqual(self.workflow.data['text'], 'third draft') + self.assertEqual(len(self.workflow.data['graphics']), 3) diff --git a/tests/SpiffWorkflow/bpmn/data/ad_hoc_subprocess.bpmn b/tests/SpiffWorkflow/bpmn/data/ad_hoc_subprocess.bpmn new file mode 100644 index 00000000..fe2665ec --- /dev/null +++ b/tests/SpiffWorkflow/bpmn/data/ad_hoc_subprocess.bpmn @@ -0,0 +1,126 @@ + + + + + Flow_0vt1twq + + + Flow_0nampyr + + + + + Flow_0vt1twq + Flow_0nampyr + + Flow_1mjfkdy + + + + revision_requested + + + + + edit_requested + + + + Flow_1g7s726 + + + + graphics_needed + graphics + len(graphics) == len(graphics_needed) + + + + + + Flow_00dn0qk + + + Flow_00dn0qk + + + editing_done + + + + Flow_1mjfkdy + Flow_1g7s726 + + + done + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/SpiffWorkflow/bpmn/events/EventSubprocessTest.py b/tests/SpiffWorkflow/bpmn/events/EventSubprocessTest.py index 5a88ff5e..3e60f9c5 100644 --- a/tests/SpiffWorkflow/bpmn/events/EventSubprocessTest.py +++ b/tests/SpiffWorkflow/bpmn/events/EventSubprocessTest.py @@ -3,7 +3,7 @@ from ..BpmnWorkflowTestCase import BpmnWorkflowTestCase -class EventBasedGatewayTest(BpmnWorkflowTestCase): +class EventSubprocessTest(BpmnWorkflowTestCase): def setUp(self): self.spec, self.subprocesses = self.load_workflow_spec('event-subprocess.bpmn', 'main')