diff --git a/crc/models/workflow.py b/crc/models/workflow.py index c3d5b377..92e74137 100644 --- a/crc/models/workflow.py +++ b/crc/models/workflow.py @@ -33,4 +33,5 @@ class WorkflowModel(db.Model): status = db.Column(db.Enum(WorkflowStatus)) study_id = db.Column(db.Integer, db.ForeignKey('study.id')) workflow_spec_id = db.Column(db.String, db.ForeignKey('workflow_spec.id')) + spec_version = db.Column(db.String) diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index e2348973..8b154cb1 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -11,7 +11,7 @@ from SpiffWorkflow.camunda.parser.CamundaParser import CamundaParser from SpiffWorkflow.dmn.parser.BpmnDmnParser import BpmnDmnParser from SpiffWorkflow.operators import Operator -from crc import session +from crc import session, db from crc.api.common import ApiError from crc.models.file import FileDataModel, FileModel, FileType from crc.models.workflow import WorkflowStatus, WorkflowModel @@ -90,34 +90,77 @@ class WorkflowProcessor(object): _serializer = BpmnSerializer() WORKFLOW_ID_KEY = "workflow_id" STUDY_ID_KEY = "study_id" - SPEC_VERSION_KEY = "spec_version" - def __init__(self, workflow_model: WorkflowModel): - latest_spec = self.get_spec(workflow_model.workflow_spec_id) + def __init__(self, workflow_model: WorkflowModel, soft_reset=False, hard_reset=False): + """Create a Worflow Processor based on the serialized information available in the workflow model. + If soft_reset is set to true, it will try to use the latest version of the workflow specification. + If hard_reset is set to true, it will create a new Workflow, but embed the data from the last + completed task in the previous workflow. + If neither flag is set, it will use the same version of the specification that was used to originally + create the workflow model. """ + if soft_reset: + spec = self.get_spec(workflow_model.workflow_spec_id) + workflow_model.spec_version = spec.description + else: + spec = self.get_spec(workflow_model.workflow_spec_id, workflow_model.spec_version) + self.workflow_spec_id = workflow_model.workflow_spec_id - self.bpmn_workflow = self._serializer.deserialize_workflow(workflow_model.bpmn_workflow_json, - workflow_spec=latest_spec) + self.bpmn_workflow = self._serializer.deserialize_workflow(workflow_model.bpmn_workflow_json, workflow_spec=spec) self.bpmn_workflow.script_engine = self._script_engine + if hard_reset: + # Now that the spec is loaded, get the data and rebuild the bpmn with the new details + workflow_model.spec_version = self.hard_reset() + @staticmethod def get_parser(): parser = MyCustomParser() return parser @staticmethod - def get_spec(workflow_spec_id): - """Returns the latest version of the specification.""" - parser = WorkflowProcessor.get_parser() - major_version = 0 # The version of the primary file. - minor_version = [] # The versions of the minor files if any. - file_ids = [] - process_id = None - file_data_models = session.query(FileDataModel) \ + def __get_file_models_for_version(workflow_spec_id, version): + """Version is in the format v[VERSION] [FILE_ID_LIST] + For example, a single bpmn file with only one version would be + v1 [12] Where 12 is the id of the file that is used to create the + specification. If multiple files exist, they are added on in + dot notation to both the version number and the file list. So + a Spec that includes a BPMN, DMN, an a Word file all on the first + version would be v1.1.1 [12,45,21]""" + file_id_strings = re.findall('\((.*)\)', version)[0].split(".") + file_ids = [int(i) for i in file_id_strings] + files = session.query(FileDataModel)\ + .join(FileModel) \ + .filter(FileModel.workflow_spec_id == workflow_spec_id)\ + .filter(FileDataModel.id.in_(file_ids)).all() + if len(files) != len(file_ids): + raise ApiError("invalid_version", + "The version '%s' of workflow specification '%s' is invalid. Unable to locate the correct files to recreate it." % + (version, workflow_spec_id)) + return files + + @staticmethod + def __get_latest_file_models(workflow_spec_id): + """Returns all the latest files related to a workflow specification""" + return session.query(FileDataModel) \ .join(FileModel) \ .filter(FileModel.workflow_spec_id == workflow_spec_id)\ .filter(FileDataModel.version == FileModel.latest_version)\ .order_by(FileModel.id)\ .all() + + @staticmethod + def get_spec(workflow_spec_id, version=None): + """Returns the requested version of the specification, + or the lastest version if none is specified.""" + parser = WorkflowProcessor.get_parser() + major_version = 0 # The version of the primary file. + minor_version = [] # The versions of the minor files if any. + file_ids = [] + process_id = None + if version is None: + file_data_models = WorkflowProcessor.__get_latest_file_models(workflow_spec_id) + else: + file_data_models = WorkflowProcessor.__get_file_models_for_version(workflow_spec_id, version) for file_data in file_data_models: file_ids.append(file_data.id) if file_data.file_model.type == FileType.bpmn: @@ -138,7 +181,7 @@ class WorkflowProcessor(object): spec = parser.get_spec(process_id) version = ".".join(str(x) for x in minor_version) files = ".".join(str(x) for x in file_ids) - spec.description = "v%s (%s) " % (version, files) + spec.description = "v%s (%s)" % (version, files) return spec @staticmethod @@ -155,17 +198,17 @@ class WorkflowProcessor(object): def create(cls, study_id, workflow_spec_id): spec = WorkflowProcessor.get_spec(workflow_spec_id) bpmn_workflow = BpmnWorkflow(spec, script_engine=cls._script_engine) + bpmn_workflow.data[WorkflowProcessor.STUDY_ID_KEY] = study_id bpmn_workflow.do_engine_steps() workflow_model = WorkflowModel(status=WorkflowProcessor.status_of(bpmn_workflow), study_id=study_id, - workflow_spec_id=workflow_spec_id) + workflow_spec_id=workflow_spec_id, + spec_version=spec.description) session.add(workflow_model) session.commit() # Need to commit twice, first to get a unique id for the workflow model, and # a second time to store the serilaization so we can maintain this link within # the spiff-workflow process. - bpmn_workflow.data[WorkflowProcessor.STUDY_ID_KEY] = study_id - bpmn_workflow.data[WorkflowProcessor.SPEC_VERSION_KEY] = spec.description bpmn_workflow.data[WorkflowProcessor.WORKFLOW_ID_KEY] = workflow_model.id workflow_model.bpmn_workflow_json = WorkflowProcessor._serializer.serialize_workflow(bpmn_workflow) @@ -174,10 +217,13 @@ class WorkflowProcessor(object): processor = cls(workflow_model) return processor - def restart_with_current_task_data(self): + def hard_reset(self): """Recreate this workflow, but keep the data from the last completed task and add it back into the first task. This may be useful when a workflow specification changes, and users need to review all the - prior steps, but don't need to reenter all the previous data. """ + prior steps, but don't need to reenter all the previous data. + + Returns the new version. + """ spec = WorkflowProcessor.get_spec(self.workflow_spec_id) bpmn_workflow = BpmnWorkflow(spec, script_engine=self._script_engine) bpmn_workflow.data = self.bpmn_workflow.data @@ -185,6 +231,7 @@ class WorkflowProcessor(object): task.data = self.bpmn_workflow.last_task.data bpmn_workflow.do_engine_steps() self.bpmn_workflow = bpmn_workflow + return spec.description def get_status(self): return self.status_of(self.bpmn_workflow) diff --git a/migrations/versions/301c4fd103d2_.py b/migrations/versions/301c4fd103d2_.py new file mode 100644 index 00000000..eaddfb22 --- /dev/null +++ b/migrations/versions/301c4fd103d2_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 301c4fd103d2 +Revises: 0ae9742a2b14 +Create Date: 2020-03-05 15:36:06.750422 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '301c4fd103d2' +down_revision = '0ae9742a2b14' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('workflow', sa.Column('spec_version', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('workflow', 'spec_version') + # ### end Alembic commands ### diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index b27eab1a..f326639e 100644 --- a/tests/test_tasks_api.py +++ b/tests/test_tasks_api.py @@ -211,9 +211,11 @@ class TestTasksApi(BaseTest): workflow_api = self.get_workflow_api(workflow) self.complete_form(workflow, workflow_api.user_tasks[0], {"color": "blue"}) - # Modify the specification, with a major change that alters the flow and can't be serialized effectively. + # Modify the specification, with a major change that alters the flow and can't be deserialized + # effectively, if it uses the latest spec files. file_path = os.path.join(app.root_path, '..', 'tests', 'data', 'two_forms', 'mods', 'two_forms_struc_mod.bpmn') self.replace_file("two_forms.bpmn", file_path) - with self.assertRaises(KeyError): - workflow_api = self.get_workflow_api(workflow) + workflow_api = self.get_workflow_api(workflow) + self.assertTrue(workflow_api.spec_version.startswith("v1 ")) + self.assertTrue(workflow_api.latest_spec_version) diff --git a/tests/test_workflow_processor.py b/tests/test_workflow_processor.py index 47086079..95369f96 100644 --- a/tests/test_workflow_processor.py +++ b/tests/test_workflow_processor.py @@ -248,14 +248,14 @@ class TestWorkflowProcessor(BaseTest): task.data = {"key": "Value"} processor.complete_task(task) task_before_restart = processor.next_task() - processor.restart_with_current_task_data() + processor.hard_reset() task_after_restart = processor.next_task() self.assertNotEqual(task.get_name(), task_before_restart.get_name()) self.assertEqual(task.get_name(), task_after_restart.get_name()) self.assertEqual(task.data, task_after_restart.data) - def test_modify_spec_with_text_change_with_running_workflow(self): + def test_soft_reset(self): self.load_example_data() # Start the two_forms workflow, and enter some data in the first form. @@ -272,14 +272,22 @@ class TestWorkflowProcessor(BaseTest): file_path = os.path.join(app.root_path, '..', 'tests', 'data', 'two_forms', 'mods', 'two_forms_text_mod.bpmn') self.replace_file("two_forms.bpmn", file_path) + # Setting up another processor should not error out, but doesn't pick up the update. workflow_model.bpmn_workflow_json = processor.serialize() processor2 = WorkflowProcessor(workflow_model) self.assertEquals("Step 1", processor2.bpmn_workflow.last_task.task_spec.description) - self.assertEquals("# This is some documentation I wanted to add.", + self.assertNotEquals("# This is some documentation I wanted to add.", processor2.bpmn_workflow.last_task.task_spec.documentation) + # You can do a soft update and get the right response. + processor3 = WorkflowProcessor(workflow_model, soft_reset=True) + self.assertEquals("Step 1", processor3.bpmn_workflow.last_task.task_spec.description) + self.assertEquals("# This is some documentation I wanted to add.", + processor3.bpmn_workflow.last_task.task_spec.documentation) - def test_modify_spec_with_structural_change_with_running_workflow(self): + + + def test_hard_reset(self): self.load_example_data() # Start the two_forms workflow, and enter some data in the first form. @@ -298,15 +306,17 @@ class TestWorkflowProcessor(BaseTest): file_path = os.path.join(app.root_path, '..', 'tests', 'data', 'two_forms', 'mods', 'two_forms_struc_mod.bpmn') self.replace_file("two_forms.bpmn", file_path) - with self.assertRaises(KeyError): - workflow_model.bpmn_workflow_json = processor.serialize() - processor2 = WorkflowProcessor(workflow_model) + # Assure that creating a new processor doesn't cause any issues, and maintains the spec version. + workflow_model.bpmn_workflow_json = processor.serialize() + processor2 = WorkflowProcessor(workflow_model) + self.assertTrue(processor2.get_spec_version().startswith("v1 ")) # Still at version 1. - # Restart the workflow, and the error should go away - processor.restart_with_current_task_data() - self.assertEquals("Step 1", processor.next_task().task_spec.description) - processor.complete_task(processor.next_task()) - self.assertEquals("New Step", processor.next_task().task_spec.description) - self.assertEquals({"color": "blue"}, processor.next_task().data) + # Do a hard reset, which should bring us back to the beginning, but retain the data. + processor3 = WorkflowProcessor(workflow_model, hard_reset=True) + self.assertEquals("Step 1", processor3.next_task().task_spec.description) + self.assertEquals({"color": "blue"}, processor3.next_task().data) + processor3.complete_task(processor3.next_task()) + self.assertEquals("New Step", processor3.next_task().task_spec.description) + self.assertEquals({"color": "blue"}, processor3.next_task().data)