diff --git a/crc/api/tools.py b/crc/api/tools.py index 7904dfe4..b78d9636 100644 --- a/crc/api/tools.py +++ b/crc/api/tools.py @@ -87,10 +87,7 @@ def evaluate_python_expression(body): result = script_engine.eval(body['expression'], body['data']) return {"result": result, "expression": body['expression'], "data": body['data']} except Exception as e: - raise ApiError("expression_error", f"Failed to evaluate the expression '%s'. %s" % - (body['expression'], str(e)), - task_data = body["data"]) - + return {"result": False, "expression": body['expression'], "data": body['data'], "error": str(e)} def send_test_email(subject, address, message, data=None): rendered, wrapped = EmailService().get_rendered_content(message, data) diff --git a/crc/scripts/reset_workflow.py b/crc/scripts/reset_workflow.py new file mode 100644 index 00000000..2b3275ec --- /dev/null +++ b/crc/scripts/reset_workflow.py @@ -0,0 +1,41 @@ +from crc import session +from crc.api.common import ApiError +from crc.models.workflow import WorkflowModel, WorkflowSpecModel +from crc.scripts.script import Script +from crc.services.workflow_processor import WorkflowProcessor + + +class ResetWorkflow(Script): + + def get_description(self): + return """Reset a workflow. Run by master workflow. + Designed for completed workflows where we need to force rerunning the workflow. + I.e., a new PI""" + + def do_task_validate_only(self, task, study_id, workflow_id, *args, **kwargs): + return hasattr(kwargs, 'workflow_name') + + def do_task(self, task, study_id, workflow_id, *args, **kwargs): + + if 'workflow_name' in kwargs.keys(): + workflow_name = kwargs['workflow_name'] + workflow_spec: WorkflowSpecModel = session.query(WorkflowSpecModel).filter_by(name=workflow_name).first() + if workflow_spec: + workflow_model: WorkflowModel = session.query(WorkflowModel).filter_by( + workflow_spec_id=workflow_spec.id, + study_id=study_id).first() + if workflow_model: + workflow_processor = WorkflowProcessor.reset(workflow_model, clear_data=False, delete_files=False) + return workflow_processor + else: + raise ApiError(code='missing_workflow_model', + message=f'No WorkflowModel returned. \ + workflow_spec_id: {workflow_spec.id} \ + study_id: {study_id}') + else: + raise ApiError(code='missing_workflow_spec', + message=f'No WorkflowSpecModel returned. \ + name: {workflow_name}') + else: + raise ApiError(code='missing_workflow_name', + message='Reset workflow requires a workflow name') diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 1b01ffe8..647f50a8 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -7,6 +7,7 @@ from typing import List import jinja2 from SpiffWorkflow import Task as SpiffTask, WorkflowException, NavItem +from SpiffWorkflow.bpmn.PythonScriptEngine import Box from SpiffWorkflow.bpmn.specs.EndEvent import EndEvent from SpiffWorkflow.bpmn.specs.ManualTask import ManualTask from SpiffWorkflow.bpmn.specs.MultiInstanceTask import MultiInstanceTask @@ -81,11 +82,12 @@ class WorkflowService(object): return workflow_model @staticmethod - def delete_test_data(): + def delete_test_data(workflow: WorkflowModel): + db.session.delete(workflow) + # Also, delete any test study or user models that may have been created. for study in db.session.query(StudyModel).filter(StudyModel.user_uid == "test"): StudyService.delete_study(study.id) db.session.commit() - user = db.session.query(UserModel).filter_by(uid="test").first() if user: db.session.delete(user) @@ -103,60 +105,56 @@ class WorkflowService(object): """ workflow_model = WorkflowService.make_test_workflow(spec_id, validate_study_id) - try: processor = WorkflowProcessor(workflow_model, validate_only=True) - except WorkflowException as we: + count = 0 + + while not processor.bpmn_workflow.is_completed(): + if count < 100: # check for infinite loop + try: + processor.bpmn_workflow.get_deep_nav_list() # Assure no errors with navigation. + exit_task = processor.bpmn_workflow.do_engine_steps(exit_at=test_until) + if (exit_task != None): + WorkflowService.delete_test_data() + raise ApiError.from_task("validation_break", + f"The validation has been exited early on task '{exit_task.task_spec.name}' and was parented by ", + exit_task.parent) + tasks = processor.bpmn_workflow.get_tasks(SpiffTask.READY) + for task in tasks: + if task.task_spec.lane is not None and task.task_spec.lane not in task.data: + raise ApiError.from_task("invalid_role", + f"This task is in a lane called '{task.task_spec.lane}', The " + f" current task data must have information mapping this role to " + f" a unique user id.", task) + task_api = WorkflowService.spiff_task_to_api_task( + task, + add_docs_and_forms=True) # Assure we try to process the documentation, and raise those errors. + # make sure forms have a form key + if hasattr(task_api, 'form') and task_api.form is not None and task_api.form.key == '': + raise ApiError(code='missing_form_key', + message='Forms must include a Form Key.', + task_id=task.id, + task_name=task.get_name()) + WorkflowService.populate_form_with_random_data(task, task_api, required_only) + processor.complete_task(task) + if test_until == task.task_spec.name: + WorkflowService.delete_test_data() + raise ApiError.from_task("validation_break", + f"The validation has been exited early on task '{task.task_spec.name}' and was parented by ", + task.parent) + count += 1 + except WorkflowException as we: + WorkflowService.delete_test_data() + raise ApiError.from_workflow_exception("workflow_validation_exception", str(we), we) + else: + raise ApiError.from_task(code='validation_loop', + message=f'There appears to be an infinite loop in the validation. Task is {task.task_spec.description}', + task=task) + WorkflowService.delete_test_data() - raise ApiError.from_workflow_exception("workflow_validation_exception", str(we), we) - - count = 0 - escaped = False - - while not processor.bpmn_workflow.is_completed() and not escaped: - if count < 100: # check for infinite loop - try: - processor.bpmn_workflow.get_deep_nav_list() # Assure no errors with navigation. - exit_task = processor.bpmn_workflow.do_engine_steps(exit_at=test_until) - if (exit_task != None): - WorkflowService.delete_test_data() - raise ApiError.from_task("validation_break", - f"The validation has been exited early on task '{exit_task.task_spec.name}' and was parented by ", - exit_task.parent) - tasks = processor.bpmn_workflow.get_tasks(SpiffTask.READY) - for task in tasks: - if task.task_spec.lane is not None and task.task_spec.lane not in task.data: - raise ApiError.from_task("invalid_role", - f"This task is in a lane called '{task.task_spec.lane}', The " - f" current task data must have information mapping this role to " - f" a unique user id.", task) - task_api = WorkflowService.spiff_task_to_api_task( - task, - add_docs_and_forms=True) # Assure we try to process the documentation, and raise those errors. - # make sure forms have a form key - if hasattr(task_api, 'form') and task_api.form is not None and task_api.form.key == '': - raise ApiError(code='missing_form_key', - message='Forms must include a Form Key.', - task_id=task.id, - task_name=task.get_name()) - WorkflowService.populate_form_with_random_data(task, task_api, required_only) - processor.complete_task(task) - if test_until == task.task_spec.name: - WorkflowService.delete_test_data() - raise ApiError.from_task("validation_break", - f"The validation has been exited early on task '{task.task_spec.name}' and was parented by ", - task.parent) - count += 1 - except WorkflowException as we: - WorkflowService.delete_test_data() - raise ApiError.from_workflow_exception("workflow_validation_exception", str(we), we) - else: - raise ApiError.from_task(code='validation_loop', - message=f'There appears to be an infinite loop in the validation. Task is {task.task_spec.description}', - task=task) - - WorkflowService.delete_test_data() - WorkflowService._process_documentation(processor.bpmn_workflow.last_task.parent.parent) + WorkflowService._process_documentation(processor.bpmn_workflow.last_task.parent.parent) + finally: + WorkflowService.delete_test_data(workflow_model) return processor.bpmn_workflow.last_task.data @staticmethod @@ -305,8 +303,14 @@ class WorkflowService(object): @staticmethod def evaluate_property(property_name, field, task): expression = field.get_property(property_name) + data = task.data + if field.has_property(Task.FIELD_PROP_REPEAT): + # Then you must evaluate the expression based on the data within the group only. + group = field.get_property(Task.FIELD_PROP_REPEAT) + if group in task.data: + data = task.data[group][0] try: - return task.workflow.script_engine.evaluate_expression(task, expression) + return task.workflow.script_engine.eval(expression, data) except Exception as e: message = f"The field {field.id} contains an invalid expression. {e}" raise ApiError.from_task(f'invalid_{property_name}', message, task=task) @@ -387,7 +391,7 @@ class WorkflowService(object): if len(field.options) > 0: random_choice = random.choice(field.options) if isinstance(random_choice, dict): - return {'value': random_choice['id'], 'label': random_choice['name']} + return {'value': random_choice['id'], 'label': random_choice['name'], 'data': random_choice['data']} else: # fixme: why it is sometimes an EnumFormFieldOption, and other times not? return {'value': random_choice.id, 'label': random_choice.name} @@ -708,7 +712,7 @@ class WorkflowService(object): raise ApiError.from_task("invalid_enum", f"The label column '{label_column}' does not exist for item {item}", task=spiff_task) - options.append({"id": item[value_column], "name": item[label_column], "data": item}) + options.append(Box({"id": item[value_column], "name": item[label_column], "data": item})) return options @staticmethod diff --git a/tests/data/reset_workflow/reset_workflow.bpmn b/tests/data/reset_workflow/reset_workflow.bpmn new file mode 100644 index 00000000..1f2eaccf --- /dev/null +++ b/tests/data/reset_workflow/reset_workflow.bpmn @@ -0,0 +1,82 @@ + + + + Use this process to reset a workflow for the current study. You must enter the name of the workflow. I.e., lower case with underscores. + + SequenceFlow_0i872g2 + + + + + + SequenceFlow_0yy50p2 + + + + + + + + + + + + + SequenceFlow_0i872g2 + SequenceFlow_1q2ton3 + + + SequenceFlow_1q2ton3 + SequenceFlow_0x127gc + value = reset_workflow(workflow_name=workflow_name) + + + # Reset Workflow +<div> +{% if value %} +<span>Workflow {{workflow_name}} was reset.</span> +{% else %} +<span>There was a problem resetting workflow {{workflow_name}}.</span> +{% endif %} +</div> + + SequenceFlow_0x127gc + SequenceFlow_0yy50p2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/two_user_tasks/two_user_tasks.bpmn b/tests/data/two_user_tasks/two_user_tasks.bpmn new file mode 100644 index 00000000..0a2fe9a9 --- /dev/null +++ b/tests/data/two_user_tasks/two_user_tasks.bpmn @@ -0,0 +1,82 @@ + + + + + SequenceFlow_1oykjju + + + + + + + + + + + + + + + SequenceFlow_1oykjju + SequenceFlow_0z8c3ob + + + + + + + + + + + + SequenceFlow_0z8c3ob + SequenceFlow_1jfrd7w + + + # Data +{{name}} is {{age}} years old. + SequenceFlow_1jfrd7w + SequenceFlow_0yjk26l + + + SequenceFlow_0yjk26l + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/workflow/test_workflow_reset.py b/tests/workflow/test_workflow_reset.py new file mode 100644 index 00000000..d8b5bef3 --- /dev/null +++ b/tests/workflow/test_workflow_reset.py @@ -0,0 +1,45 @@ +from tests.base_test import BaseTest +from crc.scripts.reset_workflow import ResetWorkflow +from crc.api.common import ApiError + + +class TestWorkflowReset(BaseTest): + + def test_workflow_reset_validation(self): + self.load_example_data() + spec_model = self.load_test_spec('reset_workflow') + rv = self.app.get('/v1.0/workflow-specification/%s/validate' % spec_model.id, headers=self.logged_in_headers()) + self.assertEqual([], rv.json) + + def test_workflow_reset(self): + workflow = self.create_workflow('two_user_tasks') + workflow_api = self.get_workflow_api(workflow) + first_task = workflow_api.next_task + self.assertEqual('Task_GetName', first_task.name) + + self.complete_form(workflow, first_task, {'name': 'Mona'}) + workflow_api = self.get_workflow_api(workflow) + second_task = workflow_api.next_task + self.assertEqual('Task_GetAge', second_task.name) + + ResetWorkflow().do_task(second_task, workflow.study_id, workflow.id, workflow_name='two_user_tasks') + + workflow_api = self.get_workflow_api(workflow) + task = workflow_api.next_task + self.assertEqual('Task_GetName', task.name) + + def test_workflow_reset_missing_name(self): + workflow = self.create_workflow('two_user_tasks') + workflow_api = self.get_workflow_api(workflow) + first_task = workflow_api.next_task + + with self.assertRaises(ApiError): + ResetWorkflow().do_task(first_task, workflow.study_id, workflow.id) + + def test_workflow_reset_bad_name(self): + workflow = self.create_workflow('two_user_tasks') + workflow_api = self.get_workflow_api(workflow) + first_task = workflow_api.next_task + + with self.assertRaises(ApiError): + ResetWorkflow().do_task(first_task, workflow.study_id, workflow.id, workflow_name='bad_workflow_name') diff --git a/tests/workflow/test_workflow_spec_validation_api.py b/tests/workflow/test_workflow_spec_validation_api.py index 688823b7..b7cb5af3 100644 --- a/tests/workflow/test_workflow_spec_validation_api.py +++ b/tests/workflow/test_workflow_spec_validation_api.py @@ -2,12 +2,14 @@ import json import unittest from unittest.mock import patch +from sqlalchemy import func + from tests.base_test import BaseTest from crc import session, app from crc.api.common import ApiErrorSchema from crc.models.protocol_builder import ProtocolBuilderStudySchema -from crc.models.workflow import WorkflowSpecModel +from crc.models.workflow import WorkflowSpecModel, WorkflowModel from crc.services.workflow_service import WorkflowService @@ -15,8 +17,11 @@ class TestWorkflowSpecValidation(BaseTest): def validate_workflow(self, workflow_name): spec_model = self.load_test_spec(workflow_name) + total_workflows = session.query(WorkflowModel).count() rv = self.app.get('/v1.0/workflow-specification/%s/validate' % spec_model.id, headers=self.logged_in_headers()) self.assert_success(rv) + total_workflows_after = session.query(WorkflowModel).count() + self.assertEqual(total_workflows, total_workflows_after, "No rogue workflow exists after validation.") json_data = json.loads(rv.get_data(as_text=True)) return ApiErrorSchema(many=True).load(json_data) @@ -59,10 +64,7 @@ class TestWorkflowSpecValidation(BaseTest): workflows = session.query(WorkflowSpecModel).all() errors = [] for w in workflows: - rv = self.app.get('/v1.0/workflow-specification/%s/validate' % w.id, - headers=self.logged_in_headers()) - self.assert_success(rv) - json_data = json.loads(rv.get_data(as_text=True)) + json_data = self.validate_workflow(w.name) errors.extend(ApiErrorSchema(many=True).load(json_data)) self.assertEqual(0, len(errors), json.dumps(errors)) @@ -87,6 +89,7 @@ class TestWorkflowSpecValidation(BaseTest): self.assertEqual("StartEvent_1", errors[0]['task_id']) self.assertEqual("invalid_spec.bpmn", errors[0]['file_name']) + def test_invalid_script(self): self.load_example_data() errors = self.validate_workflow("invalid_script")