From 1db940116613001faeada34903243741b6c790f8 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Mon, 1 Jun 2020 17:42:28 -0400 Subject: [PATCH 001/101] Don't put all the data into Spiff Tasks on a reload or backtrack, just store the data that gets submitted each time in the task log, and use that. This should correct issues with parallel tasks and other complex areas - so we don't have tasks seeing data that isn't along their path. --- crc/api/workflow.py | 44 +++++++++++++++++++------- crc/models/api_models.py | 7 ++--- crc/models/stats.py | 1 + crc/services/workflow_processor.py | 2 +- crc/services/workflow_service.py | 9 +++--- migrations/versions/3876e130664e_.py | 28 +++++++++++++++++ tests/test_tasks_api.py | 4 ++- tests/test_workflow_processor.py | 47 ---------------------------- 8 files changed, 73 insertions(+), 69 deletions(-) create mode 100644 migrations/versions/3876e130664e_.py diff --git a/crc/api/workflow.py b/crc/api/workflow.py index 81252056..46befa20 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -1,5 +1,7 @@ import uuid +from SpiffWorkflow.util.deep_merge import DeepMerge + from crc import session from crc.api.common import ApiError, ApiErrorSchema from crc.models.api_models import WorkflowApi, WorkflowApiSchema, NavigationItem, NavigationItemSchema @@ -132,12 +134,19 @@ def __get_workflow_api_model(processor: WorkflowProcessor, next_task = None): total_tasks=processor.workflow_model.total_tasks, completed_tasks=processor.workflow_model.completed_tasks, last_updated=processor.workflow_model.last_updated, - title=spec.display_name ) 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: + latest_event = session.query(TaskEventModel) \ + .filter_by(workflow_id=processor.workflow_model.id) \ + .filter_by(task_name=next_task.task_spec.name) \ + .filter_by(action=WorkflowService.TASK_ACTION_COMPLETE) \ + .order_by(TaskEventModel.date.desc()).first() + if latest_event: + next_task.data = DeepMerge.merge(next_task.data, latest_event.task_data) + workflow_api.next_task = WorkflowService.spiff_task_to_api_task(next_task, add_docs_and_forms=True) return workflow_api @@ -158,17 +167,22 @@ 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 and task.state != task.READY: + spiff_task = processor.bpmn_workflow.get_task(task_id) + if spiff_task.state != spiff_task.COMPLETED and spiff_task.state != spiff_task.READY: raise ApiError("invalid_state", "You may not move the token to a task who's state is not " "currently set to COMPLETE or READY.") # Only reset the token if the task doesn't already have it. - if task.state == task.COMPLETED: - task.reset_token(reset_data=False) # we could optionally clear the previous data. + if spiff_task.state == spiff_task.COMPLETED: + spiff_task.reset_token(reset_data=True) # Don't try to copy the existing data back into this task. + processor.save() - WorkflowService.log_task_action(processor, task, WorkflowService.TASK_ACTION_TOKEN_RESET) - workflow_api_model = __get_workflow_api_model(processor, task) + task_api = WorkflowService.spiff_task_to_api_task(spiff_task, add_docs_and_forms=True) + WorkflowService.log_task_action(workflow_model, task_api, + WorkflowService.TASK_ACTION_TOKEN_RESET, + version = processor.get_version_string()) + + workflow_api_model = __get_workflow_api_model(processor, spiff_task) return WorkflowApiSchema().dump(workflow_api_model) @@ -176,15 +190,21 @@ def update_task(workflow_id, task_id, body): 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.READY: + spiff_task = processor.bpmn_workflow.get_task(task_id) + if spiff_task.state != spiff_task.READY: raise ApiError("invalid_state", "You may not update a task unless it is in the READY state. " "Consider calling a token reset to make this task Ready.") - task.update_data(body) - processor.complete_task(task) + spiff_task.update_data(body) + processor.complete_task(spiff_task) processor.do_engine_steps() processor.save() - WorkflowService.log_task_action(processor, task, WorkflowService.TASK_ACTION_COMPLETE) + + task_api = WorkflowService.spiff_task_to_api_task(spiff_task, add_docs_and_forms=True) + WorkflowService.log_task_action(workflow_model, + task_api, + WorkflowService.TASK_ACTION_COMPLETE, + version = processor.get_version_string(), + updated_data = spiff_task.data) workflow_api_model = __get_workflow_api_model(processor) return WorkflowApiSchema().dump(workflow_api_model) diff --git a/crc/models/api_models.py b/crc/models/api_models.py index b8b535a7..eee6d5f5 100644 --- a/crc/models/api_models.py +++ b/crc/models/api_models.py @@ -119,7 +119,7 @@ class NavigationItemSchema(ma.Schema): class WorkflowApi(object): def __init__(self, id, status, next_task, navigation, - spec_version, is_latest_spec, workflow_spec_id, total_tasks, completed_tasks, last_updated, title): + spec_version, is_latest_spec, workflow_spec_id, total_tasks, completed_tasks, last_updated): self.id = id self.status = status self.next_task = next_task # The next task that requires user input. @@ -130,14 +130,13 @@ class WorkflowApi(object): self.total_tasks = total_tasks self.completed_tasks = completed_tasks self.last_updated = last_updated - self.title = title class WorkflowApiSchema(ma.Schema): class Meta: model = WorkflowApi fields = ["id", "status", "next_task", "navigation", "workflow_spec_id", "spec_version", "is_latest_spec", "total_tasks", "completed_tasks", - "last_updated", "title"] + "last_updated"] unknown = INCLUDE status = EnumField(WorkflowStatus) @@ -148,7 +147,7 @@ class WorkflowApiSchema(ma.Schema): def make_workflow(self, data, **kwargs): keys = ['id', 'status', 'next_task', 'navigation', 'workflow_spec_id', 'spec_version', 'is_latest_spec', "total_tasks", "completed_tasks", - "last_updated", "title"] + "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/models/stats.py b/crc/models/stats.py index c72df7d4..8912b1d1 100644 --- a/crc/models/stats.py +++ b/crc/models/stats.py @@ -17,6 +17,7 @@ class TaskEventModel(db.Model): task_title = db.Column(db.String) task_type = db.Column(db.String) task_state = db.Column(db.String) + task_data = db.Column(db.JSON) mi_type = db.Column(db.String) mi_count = db.Column(db.Integer) mi_index = db.Column(db.Integer) diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index 93590d94..e5cbe0a3 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -315,7 +315,7 @@ class WorkflowProcessor(object): # Reset the current workflow to the beginning - which we will consider to be the first task after the root # element. This feels a little sketchy, but I think it is safe to assume root will have one child. first_task = self.bpmn_workflow.task_tree.children[0] - first_task.reset_token(reset_data=False) + first_task.reset_token(reset_data=True) # Clear out the data. for task in new_bpmn_workflow.get_tasks(SpiffTask.READY): task.data = first_task.data new_bpmn_workflow.do_engine_steps() diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 312dee3c..1b34bd56 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -9,6 +9,7 @@ from SpiffWorkflow.bpmn.specs.ScriptTask import ScriptTask from SpiffWorkflow.bpmn.specs.UserTask import UserTask from SpiffWorkflow.dmn.specs.BusinessRuleTask import BusinessRuleTask from SpiffWorkflow.specs import CancelTask, StartTask +from SpiffWorkflow.util.deep_merge import DeepMerge from flask import g from jinja2 import Template @@ -316,21 +317,21 @@ class WorkflowService(object): field.options.append({"id": d.value, "name": d.label}) @staticmethod - def log_task_action(processor, spiff_task, action): - task = WorkflowService.spiff_task_to_api_task(spiff_task) - workflow_model = processor.workflow_model + def log_task_action(workflow_model: WorkflowModel, task: Task, + action: string, version, updated_data=None): task_event = TaskEventModel( study_id=workflow_model.study_id, user_uid=g.user.uid, workflow_id=workflow_model.id, workflow_spec_id=workflow_model.workflow_spec_id, - spec_version=processor.get_version_string(), + spec_version=version, action=action, task_id=task.id, task_name=task.name, task_title=task.title, task_type=str(task.type), task_state=task.state, + task_data=updated_data, 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. diff --git a/migrations/versions/3876e130664e_.py b/migrations/versions/3876e130664e_.py new file mode 100644 index 00000000..31e7ce13 --- /dev/null +++ b/migrations/versions/3876e130664e_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 3876e130664e +Revises: 5064b72284b7 +Create Date: 2020-06-01 15:39:53.937591 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3876e130664e' +down_revision = '5064b72284b7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('task_event', sa.Column('task_data', sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('task_event', 'task_data') + # ### end Alembic commands ### diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index 67a644ef..41fd1a3b 100644 --- a/tests/test_tasks_api.py +++ b/tests/test_tasks_api.py @@ -10,7 +10,6 @@ from crc.models.api_models import WorkflowApiSchema, MultiInstanceType, TaskSche from crc.models.file import FileModelSchema from crc.models.stats import TaskEventModel from crc.models.workflow import WorkflowStatus -from crc.services.protocol_builder import ProtocolBuilderService from crc.services.workflow_service import WorkflowService @@ -79,6 +78,9 @@ class TestTasksApi(BaseTest): self.assertEquals(task_in.process_name, event.process_name) self.assertIsNotNone(event.date) + # Assure that the data provided occurs in the task data log. + for key in dict_data.keys(): + self.assertIn(key, event.task_data) workflow = WorkflowApiSchema().load(json_data) return workflow diff --git a/tests/test_workflow_processor.py b/tests/test_workflow_processor.py index b3f6c374..1f8beebf 100644 --- a/tests/test_workflow_processor.py +++ b/tests/test_workflow_processor.py @@ -270,53 +270,6 @@ class TestWorkflowProcessor(BaseTest): processor = self.get_processor(study, workflow_spec_model) self.assertTrue(processor.get_version_string().startswith('v2.1.1')) - def test_restart_workflow(self): - self.load_example_data() - study = session.query(StudyModel).first() - workflow_spec_model = self.load_test_spec("two_forms") - processor = self.get_processor(study, workflow_spec_model) - self.assertEqual(processor.workflow_model.workflow_spec_id, workflow_spec_model.id) - task = processor.next_task() - task.data = {"key": "Value"} - processor.complete_task(task) - task_before_restart = processor.next_task() - 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_soft_reset(self): - self.load_example_data() - - # Start the two_forms workflow, and enter some data in the first form. - study = session.query(StudyModel).first() - workflow_spec_model = self.load_test_spec("two_forms") - processor = self.get_processor(study, workflow_spec_model) - self.assertEqual(processor.workflow_model.workflow_spec_id, workflow_spec_model.id) - task = processor.next_task() - task.data = {"color": "blue"} - processor.complete_task(task) - - # Modify the specification, with a minor text change. - 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. - processor.workflow_model.bpmn_workflow_json = processor.serialize() - processor2 = WorkflowProcessor(processor.workflow_model) - self.assertEqual("Step 1", processor2.bpmn_workflow.last_task.task_spec.description) - self.assertNotEqual("# 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(processor.workflow_model, soft_reset=True) - self.assertEqual("Step 1", processor3.bpmn_workflow.last_task.task_spec.description) - self.assertEqual("# This is some documentation I wanted to add.", - processor3.bpmn_workflow.last_task.task_spec.documentation) - - def test_hard_reset(self): self.load_example_data() From 4cf52b527cde4805ffb9f7cad17d23783fa32756 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 8 Jun 2020 07:14:31 -0600 Subject: [PATCH 002/101] Adding admin dashboard --- Pipfile | 1 + Pipfile.lock | 34 ++++++++++++++++++++++++---------- crc/__init__.py | 10 ++++++++++ 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/Pipfile b/Pipfile index 0079962c..a9d95c3d 100644 --- a/Pipfile +++ b/Pipfile @@ -40,6 +40,7 @@ gunicorn = "*" werkzeug = "*" sentry-sdk = {extras = ["flask"],version = "==0.14.4"} flask-mail = "*" +flask-admin = "*" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index f8ab746b..f31d2457 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "6c89585086260ebcb41918b8ef3b1d9e189e1b492208d3ff000a138bc2f2fcee" + "sha256": "282ec41cafca86628782987347085a494c52318c94e56d36d4bbd6a44092b110" }, "pipfile-spec": 6, "requires": { @@ -111,10 +111,10 @@ }, "certifi": { "hashes": [ - "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", - "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" + "sha256:5ad7e9a056d25ffa5082862e36f119f7f7cec6457fa07ee2f8c339814b80c9b1", + "sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc" ], - "version": "==2020.4.5.1" + "version": "==2020.4.5.2" }, "cffi": { "hashes": [ @@ -261,6 +261,13 @@ "index": "pypi", "version": "==1.1.2" }, + "flask-admin": { + "hashes": [ + "sha256:68c761d8582d59b1f7702013e944a7ad11d7659a72f3006b89b68b0bd8df61b8" + ], + "index": "pypi", + "version": "==1.5.6" + }, "flask-bcrypt": { "hashes": [ "sha256:d71c8585b2ee1c62024392ebdbc447438564e2c8c02b4e57b56a4cafd8d13c5f" @@ -285,11 +292,11 @@ }, "flask-marshmallow": { "hashes": [ - "sha256:6e6aec171b8e092e0eafaf035ff5b8637bf3a58ab46f568c4c1bab02f2a3c196", - "sha256:a1685536e7ab5abdc712bbc1ac1a6b0b50951a368502f7985e7d1c27b3c21e59" + "sha256:1da1e6454a56a3e15107b987121729f152325bdef23f3df2f9b52bbd074af38e", + "sha256:aefc1f1d96256c430a409f08241bab75ffe97e5d14ac5d1f000764e39bf4873a" ], "index": "pypi", - "version": "==0.12.0" + "version": "==0.13.0" }, "flask-migrate": { "hashes": [ @@ -359,10 +366,10 @@ }, "inflection": { "hashes": [ - "sha256:32a5c3341d9583ec319548b9015b7fbdf8c429cbcb575d326c33ae3a0e90d52c", - "sha256:9a15d3598f01220e93f2207c432cfede50daff53137ce660fb8be838ef1ca6cc" + "sha256:88b101b2668a1d81d6d72d4c2018e53bc6c7fc544c987849da1c7f77545c3bc9", + "sha256:f576e85132d34f5bf7df5183c2c6f94cfb32e528f53065345cf71329ba0b8924" ], - "version": "==0.4.0" + "version": "==0.5.0" }, "itsdangerous": { "hashes": [ @@ -890,6 +897,13 @@ "index": "pypi", "version": "==1.0.1" }, + "wtforms": { + "hashes": [ + "sha256:6ff8635f4caeed9f38641d48cfe019d0d3896f41910ab04494143fc027866e1b", + "sha256:861a13b3ae521d6700dac3b2771970bd354a63ba7043ecc3a82b5288596a1972" + ], + "version": "==2.3.1" + }, "xlrd": { "hashes": [ "sha256:546eb36cee8db40c3eaa46c351e67ffee6eeb5fa2650b71bc4c758a29a1b29b2", diff --git a/crc/__init__.py b/crc/__init__.py index a1dd95f6..2bcc17ef 100644 --- a/crc/__init__.py +++ b/crc/__init__.py @@ -4,6 +4,8 @@ import sentry_sdk import connexion from jinja2 import Environment, FileSystemLoader +from flask_admin import Admin +from flask_admin.contrib.sqla import ModelView from flask_cors import CORS from flask_marshmallow import Marshmallow from flask_mail import Mail @@ -40,10 +42,18 @@ from crc import api connexion_app.add_api('api.yml', base_path='/v1.0') +# Admin app +admin = Admin(app) +admin.add_view(ModelView(models.study.StudyModel, db.session)) +admin.add_view(ModelView(models.approval.ApprovalModel, db.session)) +admin.add_view(ModelView(models.user.UserModel, db.session)) +admin.add_view(ModelView(models.workflow.WorkflowModel, db.session)) + # Convert list of allowed origins to list of regexes origins_re = [r"^https?:\/\/%s(.*)" % o.replace('.', '\.') for o in app.config['CORS_ALLOW_ORIGINS']] cors = CORS(connexion_app.app, origins=origins_re) +# Sentry error handling if app.config['ENABLE_SENTRY']: sentry_sdk.init( dsn="https://25342ca4e2d443c6a5c49707d68e9f40@o401361.ingest.sentry.io/5260915", From 5c1c0f685eba5ba95d99ce2f6b376fa7e0b865d6 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 8 Jun 2020 16:17:41 -0600 Subject: [PATCH 003/101] Tests update --- crc/static/templates/mails/ramp_up_denied.txt | 2 +- tests/test_mails.py | 77 ++++++++++++++----- 2 files changed, 58 insertions(+), 21 deletions(-) diff --git a/crc/static/templates/mails/ramp_up_denied.txt b/crc/static/templates/mails/ramp_up_denied.txt index 5fbaefda..120522b8 100644 --- a/crc/static/templates/mails/ramp_up_denied.txt +++ b/crc/static/templates/mails/ramp_up_denied.txt @@ -1 +1 @@ - Your Research Ramp-up Plan has been denied by {{ approver_1 }}. Please return to the Research Ramp-up Plan application and review the comments from {{ approver_1 }} on the home page. Next, open the application and locate the first step where changes are needed. Continue to complete additional steps saving your work along the way. Review your revised Research Ramp-up Plan and res-submit for approval. \ No newline at end of file + Your Research Ramp-up Plan has been denied by {{ approver }}. Please return to the Research Ramp-up Plan application and review the comments from {{ approver }} on the home page. Next, open the application and locate the first step where changes are needed. Continue to complete additional steps saving your work along the way. Review your revised Research Ramp-up Plan and res-submit for approval. \ No newline at end of file diff --git a/tests/test_mails.py b/tests/test_mails.py index 15a01583..48752358 100644 --- a/tests/test_mails.py +++ b/tests/test_mails.py @@ -1,6 +1,7 @@ from tests.base_test import BaseTest +from crc import mail from crc.services.mails import ( send_ramp_up_submission_email, send_ramp_up_approval_request_email, @@ -21,35 +22,71 @@ class TestMails(BaseTest): self.approver_2 = 'Close Reviewer' def test_send_ramp_up_submission_email(self): - send_ramp_up_submission_email(self.sender, self.recipients, self.approver_1) - self.assertTrue(True) + with mail.record_messages() as outbox: - send_ramp_up_submission_email(self.sender, self.recipients, self.approver_1, self.approver_2) - self.assertTrue(True) + send_ramp_up_submission_email(self.sender, self.recipients, self.approver_1) + self.assertEqual(len(outbox), 1) + self.assertEqual(outbox[0].subject, 'Research Ramp-up Plan Submitted') + self.assertIn(self.approver_1, outbox[0].body) + self.assertIn(self.approver_1, outbox[0].html) + + send_ramp_up_submission_email(self.sender, self.recipients, self.approver_1, self.approver_2) + self.assertEqual(len(outbox), 2) + self.assertIn(self.approver_1, outbox[1].body) + self.assertIn(self.approver_1, outbox[1].html) + self.assertIn(self.approver_2, outbox[1].body) + self.assertIn(self.approver_2, outbox[1].html) def test_send_ramp_up_approval_request_email(self): - send_ramp_up_approval_request_email(self.sender, self.recipients, self.primary_investigator) - self.assertTrue(True) + with mail.record_messages() as outbox: + send_ramp_up_approval_request_email(self.sender, self.recipients, self.primary_investigator) + + self.assertEqual(len(outbox), 1) + self.assertEqual(outbox[0].subject, 'Research Ramp-up Plan Approval Request') + self.assertIn(self.primary_investigator, outbox[0].body) + self.assertIn(self.primary_investigator, outbox[0].html) def test_send_ramp_up_approval_request_first_review_email(self): - send_ramp_up_approval_request_first_review_email( - self.sender, self.recipients, self.primary_investigator - ) - self.assertTrue(True) + with mail.record_messages() as outbox: + send_ramp_up_approval_request_first_review_email( + self.sender, self.recipients, self.primary_investigator + ) + + self.assertEqual(len(outbox), 1) + self.assertEqual(outbox[0].subject, 'Research Ramp-up Plan Approval Request') + self.assertIn(self.primary_investigator, outbox[0].body) + self.assertIn(self.primary_investigator, outbox[0].html) def test_send_ramp_up_approved_email(self): - send_ramp_up_approved_email(self.sender, self.recipients, self.approver_1) - self.assertTrue(True) + with mail.record_messages() as outbox: + send_ramp_up_approved_email(self.sender, self.recipients, self.approver_1) + self.assertEqual(len(outbox), 1) + self.assertEqual(outbox[0].subject, 'Research Ramp-up Plan Approved') + self.assertIn(self.approver_1, outbox[0].body) + self.assertIn(self.approver_1, outbox[0].html) - send_ramp_up_approved_email(self.sender, self.recipients, self.approver_1, self.approver_2) - self.assertTrue(True) + send_ramp_up_approved_email(self.sender, self.recipients, self.approver_1, self.approver_2) + self.assertEqual(len(outbox), 2) + self.assertIn(self.approver_1, outbox[1].body) + self.assertIn(self.approver_1, outbox[1].html) + self.assertIn(self.approver_2, outbox[1].body) + self.assertIn(self.approver_2, outbox[1].html) def test_send_ramp_up_denied_email(self): - send_ramp_up_denied_email(self.sender, self.recipients, self.approver_1) - self.assertTrue(True) + with mail.record_messages() as outbox: + send_ramp_up_denied_email(self.sender, self.recipients, self.approver_1) + self.assertEqual(outbox[0].subject, 'Research Ramp-up Plan Denied') + self.assertIn(self.approver_1, outbox[0].body) + self.assertIn(self.approver_1, outbox[0].html) def test_send_send_ramp_up_denied_email_to_approver(self): - send_ramp_up_denied_email_to_approver( - self.sender, self.recipients, self.primary_investigator, self.approver_2 - ) - self.assertTrue(True) + with mail.record_messages() as outbox: + send_ramp_up_denied_email_to_approver( + self.sender, self.recipients, self.primary_investigator, self.approver_2 + ) + + self.assertEqual(outbox[0].subject, 'Research Ramp-up Plan Denied') + self.assertIn(self.primary_investigator, outbox[0].body) + self.assertIn(self.primary_investigator, outbox[0].html) + self.assertIn(self.approver_2, outbox[0].body) + self.assertIn(self.approver_2, outbox[0].html) From e9e805b2c96e36f634b58e8b88b64e5725bd48dc Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Tue, 9 Jun 2020 22:57:56 -0600 Subject: [PATCH 004/101] Storing emails in database --- crc/models/email.py | 18 +++++++++ crc/services/approval_service.py | 5 +++ crc/services/email_service.py | 27 ++++++++++++++ crc/services/file_service.py | 2 +- crc/services/mails.py | 55 ++++++++++++++++++++-------- crc/services/study_service.py | 3 -- crc/services/workflow_service.py | 3 +- migrations/versions/62a11a335778_.py | 38 +++++++++++++++++++ tests/test_mails.py | 37 ++++++++++++++----- 9 files changed, 158 insertions(+), 30 deletions(-) create mode 100644 crc/models/email.py create mode 100644 crc/services/email_service.py create mode 100644 migrations/versions/62a11a335778_.py diff --git a/crc/models/email.py b/crc/models/email.py new file mode 100644 index 00000000..c3180a27 --- /dev/null +++ b/crc/models/email.py @@ -0,0 +1,18 @@ +from flask_marshmallow.sqla import SQLAlchemyAutoSchema +from marshmallow import EXCLUDE +from sqlalchemy import func + +from crc import db +from crc.models.approval import ApprovalModel + + +class EmailModel(db.Model): + __tablename__ = 'email' + id = db.Column(db.Integer, primary_key=True) + subject = db.Column(db.String) + sender = db.Column(db.String) + recipients = db.Column(db.String) + content = db.Column(db.String) + content_html = db.Column(db.String) + approval_id = db.Column(db.Integer, db.ForeignKey(ApprovalModel.id), nullable=False) + approval = db.relationship(ApprovalModel) diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index 1f6f56b3..dbeed829 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -132,6 +132,7 @@ class ApprovalService(object): mail_result = send_ramp_up_approved_email( 'askresearch@virginia.edu', [pi_user_info.email_address], + approval_id, f'{approver_info.display_name} - ({approver_info.uid})' ) if mail_result: @@ -144,6 +145,7 @@ class ApprovalService(object): mail_result = send_ramp_up_denied_email( 'askresearch@virginia.edu', [pi_user_info.email_address], + approval_id, f'{approver_info.display_name} - ({approver_info.uid})' ) if mail_result: @@ -159,6 +161,7 @@ class ApprovalService(object): mail_result = send_ramp_up_denied_email_to_approver( 'askresearch@virginia.edu', approver_email, + approval_id, f'{pi_user_info.display_name} - ({pi_user_info.uid})', f'{approver_info.display_name} - ({approver_info.uid})' ) @@ -231,6 +234,7 @@ class ApprovalService(object): mail_result = send_ramp_up_submission_email( 'askresearch@virginia.edu', [pi_user_info.email_address], + model.id, f'{approver_info.display_name} - ({approver_info.uid})' ) if mail_result: @@ -241,6 +245,7 @@ class ApprovalService(object): mail_result = send_ramp_up_approval_request_first_review_email( 'askresearch@virginia.edu', approver_email, + model.id, f'{pi_user_info.display_name} - ({pi_user_info.uid})' ) if mail_result: diff --git a/crc/services/email_service.py b/crc/services/email_service.py new file mode 100644 index 00000000..036ea1c9 --- /dev/null +++ b/crc/services/email_service.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from sqlalchemy import desc + +from crc import app, db, session +from crc.api.common import ApiError + +from crc.models.approval import ApprovalModel +from crc.models.email import EmailModel + + +class EmailService(object): + """Provides common tools for working with an Email""" + + @staticmethod + def add_email(subject, sender, recipients, content, content_html, approval_id): + """We will receive all data related to an email and store it""" + + # Find corresponding approval + approval = db.session.query(ApprovalModel).get(approval_id) + + # Create EmailModel + email_model = EmailModel(subject=subject, sender=sender, recipients=str(recipients), + content=content, content_html=content_html, approval=approval) + + db.session.add(email_model) + db.session.commit() diff --git a/crc/services/file_service.py b/crc/services/file_service.py index ff234a79..ef4e8935 100644 --- a/crc/services/file_service.py +++ b/crc/services/file_service.py @@ -58,7 +58,7 @@ class FileService(object): "irb_docunents.xslx reference file. This code is not found in that file '%s'" % irb_doc_code) """Assure this is unique to the workflow, task, and document code AND the Name - Because we will allow users to upload multiple files for the same form field + Because we will allow users to upload multiple files for the same form field in some cases """ file_model = session.query(FileModel)\ .filter(FileModel.workflow_id == workflow_id)\ diff --git a/crc/services/mails.py b/crc/services/mails.py index bd825f69..40db52c8 100644 --- a/crc/services/mails.py +++ b/crc/services/mails.py @@ -3,13 +3,16 @@ import os from flask import render_template, render_template_string from flask_mail import Message +from crc.services.email_service import EmailService + # TODO: Extract common mailing code into its own function def send_test_email(sender, recipients): try: msg = Message('Research Ramp-up Plan test', sender=sender, - recipients=recipients) + recipients=recipients, + bcc=['rrt_emails@googlegroups.com']) from crc import env, mail template = env.get_template('ramp_up_approval_request_first_review.txt') template_vars = {'primary_investigator': "test"} @@ -20,11 +23,10 @@ def send_test_email(sender, recipients): except Exception as e: return str(e) - - -def send_ramp_up_submission_email(sender, recipients, approver_1, approver_2=None): +def send_ramp_up_submission_email(sender, recipients, approval_id, approver_1, approver_2=None): try: - msg = Message('Research Ramp-up Plan Submitted', + subject = 'Research Ramp-up Plan Submitted' + msg = Message(subject, sender=sender, recipients=recipients, bcc=['rrt_emails@googlegroups.com']) @@ -35,13 +37,17 @@ def send_ramp_up_submission_email(sender, recipients, approver_1, approver_2=Non template = env.get_template('ramp_up_submission.html') msg.html = template.render(template_vars) + EmailService.add_email(subject=subject, sender=sender, recipients=recipients, + content=msg.body, content_html=msg.html, approval_id=approval_id) + mail.send(msg) except Exception as e: return str(e) -def send_ramp_up_approval_request_email(sender, recipients, primary_investigator): +def send_ramp_up_approval_request_email(sender, recipients, approval_id, primary_investigator): try: - msg = Message('Research Ramp-up Plan Approval Request', + subject = 'Research Ramp-up Plan Approval Request' + msg = Message(subject, sender=sender, recipients=recipients, bcc=['rrt_emails@googlegroups.com']) @@ -52,13 +58,17 @@ def send_ramp_up_approval_request_email(sender, recipients, primary_investigator template = env.get_template('ramp_up_approval_request.html') msg.html = template.render(template_vars) + EmailService.add_email(subject=subject, sender=sender, recipients=recipients, + content=msg.body, content_html=msg.html, approval_id=approval_id) + mail.send(msg) except Exception as e: return str(e) -def send_ramp_up_approval_request_first_review_email(sender, recipients, primary_investigator): +def send_ramp_up_approval_request_first_review_email(sender, recipients, approval_id, primary_investigator): try: - msg = Message('Research Ramp-up Plan Approval Request', + subject = 'Research Ramp-up Plan Approval Request' + msg = Message(subject, sender=sender, recipients=recipients, bcc=['rrt_emails@googlegroups.com']) @@ -69,13 +79,17 @@ def send_ramp_up_approval_request_first_review_email(sender, recipients, primary template = env.get_template('ramp_up_approval_request_first_review.html') msg.html = template.render(template_vars) + EmailService.add_email(subject=subject, sender=sender, recipients=recipients, + content=msg.body, content_html=msg.html, approval_id=approval_id) + mail.send(msg) except Exception as e: return str(e) -def send_ramp_up_approved_email(sender, recipients, approver_1, approver_2=None): +def send_ramp_up_approved_email(sender, recipients, approval_id, approver_1, approver_2=None): try: - msg = Message('Research Ramp-up Plan Approved', + subject = 'Research Ramp-up Plan Approved' + msg = Message(subject, sender=sender, recipients=recipients, bcc=['rrt_emails@googlegroups.com']) @@ -87,13 +101,17 @@ def send_ramp_up_approved_email(sender, recipients, approver_1, approver_2=None) template = env.get_template('ramp_up_approved.html') msg.html = template.render(template_vars) + EmailService.add_email(subject=subject, sender=sender, recipients=recipients, + content=msg.body, content_html=msg.html, approval_id=approval_id) + mail.send(msg) except Exception as e: return str(e) -def send_ramp_up_denied_email(sender, recipients, approver): +def send_ramp_up_denied_email(sender, recipients, approval_id, approver): try: - msg = Message('Research Ramp-up Plan Denied', + subject = 'Research Ramp-up Plan Denied' + msg = Message(subject, sender=sender, recipients=recipients, bcc=['rrt_emails@googlegroups.com']) @@ -105,13 +123,17 @@ def send_ramp_up_denied_email(sender, recipients, approver): template = env.get_template('ramp_up_denied.html') msg.html = template.render(template_vars) + EmailService.add_email(subject=subject, sender=sender, recipients=recipients, + content=msg.body, content_html=msg.html, approval_id=approval_id) + mail.send(msg) except Exception as e: return str(e) -def send_ramp_up_denied_email_to_approver(sender, recipients, primary_investigator, approver_2): +def send_ramp_up_denied_email_to_approver(sender, recipients, approval_id, primary_investigator, approver_2): try: - msg = Message('Research Ramp-up Plan Denied', + subject = 'Research Ramp-up Plan Denied' + msg = Message(subject, sender=sender, recipients=recipients, bcc=['rrt_emails@googlegroups.com']) @@ -123,6 +145,9 @@ def send_ramp_up_denied_email_to_approver(sender, recipients, primary_investigat template = env.get_template('ramp_up_denied_first_approver.html') msg.html = template.render(template_vars) + EmailService.add_email(subject=subject, sender=sender, recipients=recipients, + content=msg.body, content_html=msg.html, approval_id=approval_id) + mail.send(msg) except Exception as e: return str(e) diff --git a/crc/services/study_service.py b/crc/services/study_service.py index 4024b5f0..43aa8297 100644 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -181,8 +181,6 @@ class StudyService(object): documents[code] = doc return documents - - @staticmethod def get_investigators(study_id): @@ -224,7 +222,6 @@ class StudyService(object): return FileModelSchema().dump(file) - @staticmethod def synch_with_protocol_builder_if_enabled(user): """Assures that the studies we have locally for the given user are diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 5efa8cab..ab7494ee 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -37,7 +37,7 @@ class WorkflowService(object): the workflow Processor should be hidden behind this service. This will help maintain a structure that avoids circular dependencies. But for now, this contains tools for converting spiff-workflow models into our - own API models with additional information and capabilities and + own API models with additional information and capabilities and handles the testing of a workflow specification by completing it with random selections, attempting to mimic a front end as much as possible. """ @@ -180,7 +180,6 @@ class WorkflowService(object): def __get_options(self): pass - @staticmethod def _random_string(string_length=10): """Generate a random string of fixed length """ diff --git a/migrations/versions/62a11a335778_.py b/migrations/versions/62a11a335778_.py new file mode 100644 index 00000000..ee8d8f91 --- /dev/null +++ b/migrations/versions/62a11a335778_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 62a11a335778 +Revises: 17597692d0b0 +Create Date: 2020-06-09 22:45:52.475183 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '62a11a335778' +down_revision = '17597692d0b0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('email', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('subject', sa.String(), nullable=True), + sa.Column('sender', sa.String(), nullable=True), + sa.Column('recipients', sa.String(), nullable=True), + sa.Column('content', sa.String(), nullable=True), + sa.Column('content_html', sa.String(), nullable=True), + sa.Column('approval_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['approval_id'], ['approval.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('email') + # ### end Alembic commands ### diff --git a/tests/test_mails.py b/tests/test_mails.py index 48752358..916d6ff7 100644 --- a/tests/test_mails.py +++ b/tests/test_mails.py @@ -1,7 +1,8 @@ from tests.base_test import BaseTest -from crc import mail +from crc import mail, session +from crc.models.approval import ApprovalModel, ApprovalStatus from crc.services.mails import ( send_ramp_up_submission_email, send_ramp_up_approval_request_email, @@ -15,6 +16,21 @@ from crc.services.mails import ( class TestMails(BaseTest): def setUp(self): + """Initial setup shared by all TestApprovals tests""" + self.load_example_data() + self.study = self.create_study() + self.workflow = self.create_workflow('random_fact') + + self.approval = ApprovalModel( + study=self.study, + workflow=self.workflow, + approver_uid='lb3dp', + status=ApprovalStatus.PENDING.value, + version=1 + ) + session.add(self.approval) + session.commit() + self.sender = 'sender@sartography.com' self.recipients = ['recipient@sartography.com'] self.primary_investigator = 'Dr. Bartlett' @@ -24,13 +40,14 @@ class TestMails(BaseTest): def test_send_ramp_up_submission_email(self): with mail.record_messages() as outbox: - send_ramp_up_submission_email(self.sender, self.recipients, self.approver_1) + send_ramp_up_submission_email(self.sender, self.recipients, self.approval.id, self.approver_1) self.assertEqual(len(outbox), 1) self.assertEqual(outbox[0].subject, 'Research Ramp-up Plan Submitted') self.assertIn(self.approver_1, outbox[0].body) self.assertIn(self.approver_1, outbox[0].html) - send_ramp_up_submission_email(self.sender, self.recipients, self.approver_1, self.approver_2) + send_ramp_up_submission_email(self.sender, self.recipients, self.approval.id, + self.approver_1, self.approver_2) self.assertEqual(len(outbox), 2) self.assertIn(self.approver_1, outbox[1].body) self.assertIn(self.approver_1, outbox[1].html) @@ -39,7 +56,8 @@ class TestMails(BaseTest): def test_send_ramp_up_approval_request_email(self): with mail.record_messages() as outbox: - send_ramp_up_approval_request_email(self.sender, self.recipients, self.primary_investigator) + send_ramp_up_approval_request_email(self.sender, self.recipients, self.approval.id, + self.primary_investigator) self.assertEqual(len(outbox), 1) self.assertEqual(outbox[0].subject, 'Research Ramp-up Plan Approval Request') @@ -49,7 +67,7 @@ class TestMails(BaseTest): def test_send_ramp_up_approval_request_first_review_email(self): with mail.record_messages() as outbox: send_ramp_up_approval_request_first_review_email( - self.sender, self.recipients, self.primary_investigator + self.sender, self.recipients, self.approval.id, self.primary_investigator ) self.assertEqual(len(outbox), 1) @@ -59,13 +77,14 @@ class TestMails(BaseTest): def test_send_ramp_up_approved_email(self): with mail.record_messages() as outbox: - send_ramp_up_approved_email(self.sender, self.recipients, self.approver_1) + send_ramp_up_approved_email(self.sender, self.recipients, self.approval.id, self.approver_1) self.assertEqual(len(outbox), 1) self.assertEqual(outbox[0].subject, 'Research Ramp-up Plan Approved') self.assertIn(self.approver_1, outbox[0].body) self.assertIn(self.approver_1, outbox[0].html) - send_ramp_up_approved_email(self.sender, self.recipients, self.approver_1, self.approver_2) + send_ramp_up_approved_email(self.sender, self.recipients, self.approval.id, + self.approver_1, self.approver_2) self.assertEqual(len(outbox), 2) self.assertIn(self.approver_1, outbox[1].body) self.assertIn(self.approver_1, outbox[1].html) @@ -74,7 +93,7 @@ class TestMails(BaseTest): def test_send_ramp_up_denied_email(self): with mail.record_messages() as outbox: - send_ramp_up_denied_email(self.sender, self.recipients, self.approver_1) + send_ramp_up_denied_email(self.sender, self.recipients, self.approval.id, self.approver_1) self.assertEqual(outbox[0].subject, 'Research Ramp-up Plan Denied') self.assertIn(self.approver_1, outbox[0].body) self.assertIn(self.approver_1, outbox[0].html) @@ -82,7 +101,7 @@ class TestMails(BaseTest): def test_send_send_ramp_up_denied_email_to_approver(self): with mail.record_messages() as outbox: send_ramp_up_denied_email_to_approver( - self.sender, self.recipients, self.primary_investigator, self.approver_2 + self.sender, self.recipients, self.approval.id, self.primary_investigator, self.approver_2 ) self.assertEqual(outbox[0].subject, 'Research Ramp-up Plan Denied') From 5f13b96079a338a9516295e5a79655ad5b714cb3 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Fri, 12 Jun 2020 12:17:08 -0600 Subject: [PATCH 005/101] More enhancements --- crc/services/mails.py | 172 ++++++++++++++++-------------------- tests/test_email_service.py | 42 +++++++++ tests/test_mails.py | 19 ++++ 3 files changed, 138 insertions(+), 95 deletions(-) create mode 100644 tests/test_email_service.py diff --git a/crc/services/mails.py b/crc/services/mails.py index 40db52c8..6816b586 100644 --- a/crc/services/mails.py +++ b/crc/services/mails.py @@ -23,131 +23,113 @@ def send_test_email(sender, recipients): except Exception as e: return str(e) -def send_ramp_up_submission_email(sender, recipients, approval_id, approver_1, approver_2=None): +def send_mail(subject, sender, recipients, content, content_html): + from crc import mail try: - subject = 'Research Ramp-up Plan Submitted' msg = Message(subject, sender=sender, recipients=recipients, bcc=['rrt_emails@googlegroups.com']) - from crc import env, mail - template = env.get_template('ramp_up_submission.txt') - template_vars = {'approver_1': approver_1, 'approver_2': approver_2} - msg.body = template.render(template_vars) - template = env.get_template('ramp_up_submission.html') - msg.html = template.render(template_vars) - EmailService.add_email(subject=subject, sender=sender, recipients=recipients, - content=msg.body, content_html=msg.html, approval_id=approval_id) + msg.body = content + msg.html = content_html mail.send(msg) except Exception as e: return str(e) +def send_ramp_up_submission_email(sender, recipients, approval_id, approver_1, approver_2=None): + from crc import env + subject = 'Research Ramp-up Plan Submitted' + + template = env.get_template('ramp_up_submission.txt') + template_vars = {'approver_1': approver_1, 'approver_2': approver_2} + content = template.render(template_vars) + template = env.get_template('ramp_up_submission.html') + content_html = template.render(template_vars) + + EmailService.add_email(subject=subject, sender=sender, recipients=recipients, + content=content, content_html=content_html, approval_id=approval_id) + + result = send_mail(subject, sender, recipients, content, content_html) + return result + def send_ramp_up_approval_request_email(sender, recipients, approval_id, primary_investigator): - try: - subject = 'Research Ramp-up Plan Approval Request' - msg = Message(subject, - sender=sender, - recipients=recipients, - bcc=['rrt_emails@googlegroups.com']) - from crc import env, mail - template = env.get_template('ramp_up_approval_request.txt') - template_vars = {'primary_investigator': primary_investigator} - msg.body = template.render(template_vars) - template = env.get_template('ramp_up_approval_request.html') - msg.html = template.render(template_vars) + from crc import env + subject = 'Research Ramp-up Plan Approval Request' - EmailService.add_email(subject=subject, sender=sender, recipients=recipients, - content=msg.body, content_html=msg.html, approval_id=approval_id) + template = env.get_template('ramp_up_approval_request.txt') + template_vars = {'primary_investigator': primary_investigator} + content = template.render(template_vars) + template = env.get_template('ramp_up_approval_request.html') + content_html = template.render(template_vars) - mail.send(msg) - except Exception as e: - return str(e) + EmailService.add_email(subject=subject, sender=sender, recipients=recipients, + content=content, content_html=content_html, approval_id=approval_id) + + result = send_mail(subject, sender, recipients, content, content_html) + return result def send_ramp_up_approval_request_first_review_email(sender, recipients, approval_id, primary_investigator): - try: - subject = 'Research Ramp-up Plan Approval Request' - msg = Message(subject, - sender=sender, - recipients=recipients, - bcc=['rrt_emails@googlegroups.com']) - from crc import env, mail - template = env.get_template('ramp_up_approval_request_first_review.txt') - template_vars = {'primary_investigator': primary_investigator} - msg.body = template.render(template_vars) - template = env.get_template('ramp_up_approval_request_first_review.html') - msg.html = template.render(template_vars) + from crc import env + subject = 'Research Ramp-up Plan Approval Request' - EmailService.add_email(subject=subject, sender=sender, recipients=recipients, - content=msg.body, content_html=msg.html, approval_id=approval_id) + template = env.get_template('ramp_up_approval_request_first_review.txt') + template_vars = {'primary_investigator': primary_investigator} + content = template.render(template_vars) + template = env.get_template('ramp_up_approval_request_first_review.html') + content_html = template.render(template_vars) - mail.send(msg) - except Exception as e: - return str(e) + EmailService.add_email(subject=subject, sender=sender, recipients=recipients, + content=content, content_html=content_html, approval_id=approval_id) + + result = send_mail(subject, sender, recipients, content, content_html) + return result def send_ramp_up_approved_email(sender, recipients, approval_id, approver_1, approver_2=None): - try: - subject = 'Research Ramp-up Plan Approved' - msg = Message(subject, - sender=sender, - recipients=recipients, - bcc=['rrt_emails@googlegroups.com']) + from crc import env + subject = 'Research Ramp-up Plan Approved' - from crc import env, mail - template = env.get_template('ramp_up_approved.txt') - template_vars = {'approver_1': approver_1, 'approver_2': approver_2} - msg.body = template.render(template_vars) - template = env.get_template('ramp_up_approved.html') - msg.html = template.render(template_vars) + template = env.get_template('ramp_up_approved.txt') + template_vars = {'approver_1': approver_1, 'approver_2': approver_2} + content = template.render(template_vars) + template = env.get_template('ramp_up_approved.html') + content_html = template.render(template_vars) - EmailService.add_email(subject=subject, sender=sender, recipients=recipients, - content=msg.body, content_html=msg.html, approval_id=approval_id) + EmailService.add_email(subject=subject, sender=sender, recipients=recipients, + content=content, content_html=content_html, approval_id=approval_id) - mail.send(msg) - except Exception as e: - return str(e) + result = send_mail(subject, sender, recipients, content, content_html) + return result def send_ramp_up_denied_email(sender, recipients, approval_id, approver): - try: - subject = 'Research Ramp-up Plan Denied' - msg = Message(subject, - sender=sender, - recipients=recipients, - bcc=['rrt_emails@googlegroups.com']) + from crc import env + subject = 'Research Ramp-up Plan Denied' - from crc import env, mail - template = env.get_template('ramp_up_denied.txt') - template_vars = {'approver': approver} - msg.body = template.render(template_vars) - template = env.get_template('ramp_up_denied.html') - msg.html = template.render(template_vars) + template = env.get_template('ramp_up_denied.txt') + template_vars = {'approver': approver} + content = template.render(template_vars) + template = env.get_template('ramp_up_denied.html') + content_html = template.render(template_vars) - EmailService.add_email(subject=subject, sender=sender, recipients=recipients, - content=msg.body, content_html=msg.html, approval_id=approval_id) + EmailService.add_email(subject=subject, sender=sender, recipients=recipients, + content=content, content_html=content_html, approval_id=approval_id) - mail.send(msg) - except Exception as e: - return str(e) + result = send_mail(subject, sender, recipients, content, content_html) + return result def send_ramp_up_denied_email_to_approver(sender, recipients, approval_id, primary_investigator, approver_2): - try: - subject = 'Research Ramp-up Plan Denied' - msg = Message(subject, - sender=sender, - recipients=recipients, - bcc=['rrt_emails@googlegroups.com']) + from crc import env + subject = 'Research Ramp-up Plan Denied' - from crc import env, mail - template = env.get_template('ramp_up_denied_first_approver.txt') - template_vars = {'primary_investigator': primary_investigator, 'approver_2': approver_2} - msg.body = template.render(template_vars) - template = env.get_template('ramp_up_denied_first_approver.html') - msg.html = template.render(template_vars) + template = env.get_template('ramp_up_denied_first_approver.txt') + template_vars = {'primary_investigator': primary_investigator, 'approver_2': approver_2} + content = template.render(template_vars) + template = env.get_template('ramp_up_denied_first_approver.html') + content_html = template.render(template_vars) - EmailService.add_email(subject=subject, sender=sender, recipients=recipients, - content=msg.body, content_html=msg.html, approval_id=approval_id) + EmailService.add_email(subject=subject, sender=sender, recipients=recipients, + content=content, content_html=content_html, approval_id=approval_id) - mail.send(msg) - except Exception as e: - return str(e) + result = send_mail(subject, sender, recipients, content, content_html) + return result diff --git a/tests/test_email_service.py b/tests/test_email_service.py new file mode 100644 index 00000000..9e0f2e57 --- /dev/null +++ b/tests/test_email_service.py @@ -0,0 +1,42 @@ +from tests.base_test import BaseTest + +from crc import session +from crc.models.approval import ApprovalModel, ApprovalStatus +from crc.models.email import EmailModel +from crc.services.email_service import EmailService + + +class TestEmailService(BaseTest): + + def test_add_email(self): + self.load_example_data() + study = self.create_study() + workflow = self.create_workflow('random_fact') + + approval = ApprovalModel( + study=study, + workflow=workflow, + approver_uid='lb3dp', + status=ApprovalStatus.PENDING.value, + version=1 + ) + session.add(approval) + session.commit() + + subject = 'Email Subject' + sender = 'sender@sartography.com' + recipients = ['recipient@sartography.com', 'back@sartography.com'] + content = 'Content for this email' + content_html = '

Hypertext Markup Language content for this email

' + + EmailService.add_email(subject=subject, sender=sender, recipients=recipients, + content=content, content_html=content_html, approval_id=approval.id) + + email_model = EmailModel.query.first() + + self.assertEqual(email_model.subject, subject) + self.assertEqual(email_model.sender, sender) + self.assertEqual(email_model.recipients, str(recipients)) + self.assertEqual(email_model.content, content) + self.assertEqual(email_model.content_html, content_html) + self.assertEqual(email_model.approval, approval) diff --git a/tests/test_mails.py b/tests/test_mails.py index 916d6ff7..5408e517 100644 --- a/tests/test_mails.py +++ b/tests/test_mails.py @@ -3,6 +3,7 @@ from tests.base_test import BaseTest from crc import mail, session from crc.models.approval import ApprovalModel, ApprovalStatus +from crc.models.email import EmailModel from crc.services.mails import ( send_ramp_up_submission_email, send_ramp_up_approval_request_email, @@ -54,6 +55,9 @@ class TestMails(BaseTest): self.assertIn(self.approver_2, outbox[1].body) self.assertIn(self.approver_2, outbox[1].html) + db_emails = EmailModel.query.count() + self.assertEqual(db_emails, 2) + def test_send_ramp_up_approval_request_email(self): with mail.record_messages() as outbox: send_ramp_up_approval_request_email(self.sender, self.recipients, self.approval.id, @@ -64,6 +68,9 @@ class TestMails(BaseTest): self.assertIn(self.primary_investigator, outbox[0].body) self.assertIn(self.primary_investigator, outbox[0].html) + db_emails = EmailModel.query.count() + self.assertEqual(db_emails, 1) + def test_send_ramp_up_approval_request_first_review_email(self): with mail.record_messages() as outbox: send_ramp_up_approval_request_first_review_email( @@ -75,6 +82,9 @@ class TestMails(BaseTest): self.assertIn(self.primary_investigator, outbox[0].body) self.assertIn(self.primary_investigator, outbox[0].html) + db_emails = EmailModel.query.count() + self.assertEqual(db_emails, 1) + def test_send_ramp_up_approved_email(self): with mail.record_messages() as outbox: send_ramp_up_approved_email(self.sender, self.recipients, self.approval.id, self.approver_1) @@ -91,6 +101,9 @@ class TestMails(BaseTest): self.assertIn(self.approver_2, outbox[1].body) self.assertIn(self.approver_2, outbox[1].html) + db_emails = EmailModel.query.count() + self.assertEqual(db_emails, 2) + def test_send_ramp_up_denied_email(self): with mail.record_messages() as outbox: send_ramp_up_denied_email(self.sender, self.recipients, self.approval.id, self.approver_1) @@ -98,6 +111,9 @@ class TestMails(BaseTest): self.assertIn(self.approver_1, outbox[0].body) self.assertIn(self.approver_1, outbox[0].html) + db_emails = EmailModel.query.count() + self.assertEqual(db_emails, 1) + def test_send_send_ramp_up_denied_email_to_approver(self): with mail.record_messages() as outbox: send_ramp_up_denied_email_to_approver( @@ -109,3 +125,6 @@ class TestMails(BaseTest): self.assertIn(self.primary_investigator, outbox[0].html) self.assertIn(self.approver_2, outbox[0].body) self.assertIn(self.approver_2, outbox[0].html) + + db_emails = EmailModel.query.count() + self.assertEqual(db_emails, 1) From 0608ffa08a17116aea5f1694d91029aa9ae6d6c0 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 16 Jun 2020 12:26:25 -0400 Subject: [PATCH 006/101] Restricting the admin endpoints to be admin only, and adding a bit of configuration. --- crc/__init__.py | 7 +----- crc/api/admin.py | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 crc/api/admin.py diff --git a/crc/__init__.py b/crc/__init__.py index 62f62de0..a211b0fa 100644 --- a/crc/__init__.py +++ b/crc/__init__.py @@ -39,15 +39,10 @@ ma = Marshmallow(app) from crc import models from crc import api +from crc.api import admin connexion_app.add_api('api.yml', base_path='/v1.0') -# Admin app -admin = Admin(app) -admin.add_view(ModelView(models.study.StudyModel, db.session)) -admin.add_view(ModelView(models.approval.ApprovalModel, db.session)) -admin.add_view(ModelView(models.user.UserModel, db.session)) -admin.add_view(ModelView(models.workflow.WorkflowModel, db.session)) # Convert list of allowed origins to list of regexes origins_re = [r"^https?:\/\/%s(.*)" % o.replace('.', '\.') for o in app.config['CORS_ALLOW_ORIGINS']] diff --git a/crc/api/admin.py b/crc/api/admin.py new file mode 100644 index 00000000..26a1b181 --- /dev/null +++ b/crc/api/admin.py @@ -0,0 +1,56 @@ +# Admin app + +from flask import url_for +from flask_admin import Admin +from flask_admin.contrib import sqla +from flask_admin.contrib.sqla import ModelView +from werkzeug.utils import redirect + +from crc import db, app +from crc.api.user import verify_token, verify_token_admin +from crc.models.approval import ApprovalModel +from crc.models.file import FileModel +from crc.models.study import StudyModel +from crc.models.user import UserModel +from crc.models.workflow import WorkflowModel + + +class AdminModelView(sqla.ModelView): + can_create = False + can_edit = False + can_delete = False + page_size = 50 # the number of entries to display on the list view + column_exclude_list = ['bpmn_workflow_json', ] + column_display_pk = True + can_export = True + + def is_accessible(self): + return verify_token_admin() + + def inaccessible_callback(self, name, **kwargs): + # redirect to login page if user doesn't have access + return redirect(url_for('home')) + +class UserView(AdminModelView): + column_filters = ['uid'] + +class StudyView(AdminModelView): + column_filters = ['id', 'primary_investigator_id'] + column_searchable_list = ['title'] + +class ApprovalView(AdminModelView): + column_filters = ['study_id', 'approver_uid'] + +class WorkflowView(AdminModelView): + column_filters = ['study_id', 'id'] + +class FileView(AdminModelView): + column_filters = ['workflow_id'] + +admin = Admin(app) + +admin.add_view(StudyView(StudyModel, db.session)) +admin.add_view(ApprovalView(ApprovalModel, db.session)) +admin.add_view(UserView(UserModel, db.session)) +admin.add_view(WorkflowView(WorkflowModel, db.session)) +admin.add_view(FileView(FileModel, db.session)) From 1b9166dcb7e0051fe2d8e4f8e1856f010836a1da Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 16 Jun 2020 13:34:21 -0400 Subject: [PATCH 007/101] Cleaning up the merge, which resulted in some lost code. --- crc/models/api_models.py | 7 ++++--- crc/services/workflow_service.py | 4 +--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/crc/models/api_models.py b/crc/models/api_models.py index f98a1b13..53706a75 100644 --- a/crc/models/api_models.py +++ b/crc/models/api_models.py @@ -120,7 +120,7 @@ class NavigationItemSchema(ma.Schema): class WorkflowApi(object): def __init__(self, id, status, next_task, navigation, - spec_version, is_latest_spec, workflow_spec_id, total_tasks, completed_tasks, last_updated): + spec_version, is_latest_spec, workflow_spec_id, total_tasks, completed_tasks, last_updated, title): self.id = id self.status = status self.next_task = next_task # The next task that requires user input. @@ -131,13 +131,14 @@ class WorkflowApi(object): self.total_tasks = total_tasks self.completed_tasks = completed_tasks self.last_updated = last_updated + self.title = title class WorkflowApiSchema(ma.Schema): class Meta: model = WorkflowApi fields = ["id", "status", "next_task", "navigation", "workflow_spec_id", "spec_version", "is_latest_spec", "total_tasks", "completed_tasks", - "last_updated"] + "last_updated", "title"] unknown = INCLUDE status = EnumField(WorkflowStatus) @@ -148,7 +149,7 @@ class WorkflowApiSchema(ma.Schema): def make_workflow(self, data, **kwargs): keys = ['id', 'status', 'next_task', 'navigation', 'workflow_spec_id', 'spec_version', 'is_latest_spec', "total_tasks", "completed_tasks", - "last_updated"] + "last_updated", "title"] 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_service.py b/crc/services/workflow_service.py index b6769458..a8860886 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -9,8 +9,6 @@ from SpiffWorkflow.bpmn.specs.ScriptTask import ScriptTask from SpiffWorkflow.bpmn.specs.UserTask import UserTask from SpiffWorkflow.dmn.specs.BusinessRuleTask import BusinessRuleTask from SpiffWorkflow.specs import CancelTask, StartTask -from SpiffWorkflow.util.deep_merge import DeepMerge -from flask import g from jinja2 import Template from crc import db, app @@ -24,7 +22,7 @@ from crc.models.workflow import WorkflowModel, WorkflowStatus from crc.services.file_service import FileService from crc.services.lookup_service import LookupService from crc.services.study_service import StudyService -from crc.services.workflow_processor import WorkflowProcessor, CustomBpmnScriptEngine +from crc.services.workflow_processor import WorkflowProcessor class WorkflowService(object): From 59d66be10fcb9c21fe977b3c081c3e9719275d54 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Tue, 16 Jun 2020 13:13:30 -0600 Subject: [PATCH 008/101] Handling empty task state --- crc/api/workflow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crc/api/workflow.py b/crc/api/workflow.py index 655a85e7..ae3fd86e 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -188,6 +188,8 @@ def update_task(workflow_id, task_id, body): processor = WorkflowProcessor(workflow_model) task_id = uuid.UUID(task_id) task = processor.bpmn_workflow.get_task(task_id) + if not task: + raise ApiError("empty_task", "Processor failed to obtain task.", status_code=404) if task.state != task.READY: raise ApiError("invalid_state", "You may not update a task unless it is in the READY state. " "Consider calling a token reset to make this task Ready.") From d4a285883f016a9e1d86e1b090962c69700a8332 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Tue, 16 Jun 2020 18:42:36 -0600 Subject: [PATCH 009/101] Email script --- crc/scripts/email.py | 81 +++++++++++++++++++++++++++++++++++++ crc/scripts/fact_service.py | 2 +- tests/data/email/email.bpmn | 58 ++++++++++++++++++++++++++ tests/test_email_script.py | 30 ++++++++++++++ 4 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 crc/scripts/email.py create mode 100644 tests/data/email/email.bpmn create mode 100644 tests/test_email_script.py diff --git a/crc/scripts/email.py b/crc/scripts/email.py new file mode 100644 index 00000000..2958fb29 --- /dev/null +++ b/crc/scripts/email.py @@ -0,0 +1,81 @@ +from jinja2 import Template + +from crc.api.common import ApiError +from crc.scripts.script import Script +from crc.services.ldap_service import LdapService +from crc.services.mails import send_mail + + +class Email(Script): + """This Script allows to be introduced as part of a workflow and called from there, specifying + recipients and content """ + + def get_description(self): + return """ +Creates an email, using the provided arguments (a list of UIDs)" +Each argument will be used to look up personal information needed for +the email creation. + +Example: +Email Subject ApprvlApprvr1 PIComputingID +""" + + def do_task_validate_only(self, task, *args, **kwargs): + self.get_emails(task, args) + + def do_task(self, task, *args, **kwargs): + subject = self.get_subject(task, args) + recipients = self.get_emails(task, args) + content = self.get_content(task) + if recipients: + send_mail( + subject='Test Subject', + sender='sender@sartography.com', + recipients=recipients, + content=content, + content_html=content + ) + + def get_emails(self, task, args): + if len(args) < 1: + raise ApiError(code="missing_argument", + message="Email script requires at least one argument. The " + "name of the variable in the task data that contains user" + "id to process. Multiple arguments are accepted.") + emails = [] + for arg in args[1:]: + uid = task.workflow.script_engine.evaluate_expression(task, arg) + user_info = LdapService.user_info(uid) + email = user_info.email_address + emails.append(user_info.email_address) + if not isinstance(email, str): + raise ApiError(code="invalid_argument", + message="The Email script requires at least 1 UID argument. The " + "name of the variable in the task data that contains subject and" + " user ids to process. This must point to an array or a string, but " + "it currently points to a %s " % emails.__class__.__name__) + + return emails + + def get_subject(self, task, args): + if len(args) < 1: + raise ApiError(code="missing_argument", + message="Email script requires at least one subject argument. The " + "name of the variable in the task data that contains subject" + " to process. Multiple arguments are accepted.") + subject = task.workflow.script_engine.evaluate_expression(task, args[0]) + if not isinstance(subject, str): + raise ApiError(code="invalid_argument", + message="The Email script requires 1 argument. The " + "the name of the variable in the task data that contains user" + "ids to process. This must point to an array or a string, but " + "it currently points to a %s " % emails.__class__.__name__) + + return subject + + def get_content(self, task): + content = task.task_spec.documentation + template = Template(content) + rendered = template.render({'approver': 'Bossman', 'not_here': 22}) + + return rendered diff --git a/crc/scripts/fact_service.py b/crc/scripts/fact_service.py index c4468721..b3701312 100644 --- a/crc/scripts/fact_service.py +++ b/crc/scripts/fact_service.py @@ -5,7 +5,7 @@ from crc.scripts.script import Script class FactService(Script): def get_description(self): - return """Just your basic class that can pull in data from a few api endpoints and + return """Just your basic class that can pull in data from a few api endpoints and do a basic task.""" def get_cat(self): diff --git a/tests/data/email/email.bpmn b/tests/data/email/email.bpmn new file mode 100644 index 00000000..b2221f24 --- /dev/null +++ b/tests/data/email/email.bpmn @@ -0,0 +1,58 @@ + + + + + Flow_1synsig + + + Flow_1xlrgne + + + Email content to be delivered to {{ approver }} + Flow_08n2npe + Flow_1xlrgne + Email Subject ApprvlApprvr1 PIComputingID + + + + + + + + + + + + Flow_1synsig + Flow_08n2npe + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_email_script.py b/tests/test_email_script.py new file mode 100644 index 00000000..9ac93e07 --- /dev/null +++ b/tests/test_email_script.py @@ -0,0 +1,30 @@ +from tests.base_test import BaseTest + +from crc.services.file_service import FileService +from crc.scripts.email import Email +from crc.services.workflow_processor import WorkflowProcessor +from crc.api.common import ApiError + +from crc import db +# from crc.models.approval import ApprovalModel + + +class TestEmailScript(BaseTest): + + def test_do_task(self): + self.load_example_data() + self.create_reference_document() + workflow = self.create_workflow('email') + processor = WorkflowProcessor(workflow) + task = processor.next_task() + processor.complete_task(task) + task = processor.next_task() + task.data = { + 'PIComputingID': 'dhf8r', + 'ApprvlApprvr1': 'lb3dp', + 'Subject': 'Email Script needs your help' + } + + script = Email() + script.do_task(task, 'Subject', 'PIComputingID', 'ApprvlApprvr1') + self.assertTrue(True) From 2ff836019f39c466db6a3ec17db01668550a5695 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Tue, 16 Jun 2020 18:55:18 -0600 Subject: [PATCH 010/101] Sonarcloud fix --- crc/scripts/email.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crc/scripts/email.py b/crc/scripts/email.py index 2958fb29..3dc9cb11 100644 --- a/crc/scripts/email.py +++ b/crc/scripts/email.py @@ -69,7 +69,7 @@ Email Subject ApprvlApprvr1 PIComputingID message="The Email script requires 1 argument. The " "the name of the variable in the task data that contains user" "ids to process. This must point to an array or a string, but " - "it currently points to a %s " % emails.__class__.__name__) + "it currently points to a %s " % subject.__class__.__name__) return subject From c730a7b1ec0c4304b5c95264abb49ad5f35166ed Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Wed, 17 Jun 2020 08:53:02 -0600 Subject: [PATCH 011/101] Sending subject and using default sender --- config/default.py | 1 + crc/scripts/email.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/config/default.py b/config/default.py index 9a606a4c..3faaef7b 100644 --- a/config/default.py +++ b/config/default.py @@ -45,6 +45,7 @@ LDAP_URL = environ.get('LDAP_URL', default="ldap.virginia.edu").strip('/') # No LDAP_TIMEOUT_SEC = int(environ.get('LDAP_TIMEOUT_SEC', default=1)) # Email configuration +DEFAULT_SENDER = 'askresearch@virginia.edu' FALLBACK_EMAILS = ['askresearch@virginia.edu', 'sartographysupport@googlegroups.com'] MAIL_DEBUG = environ.get('MAIL_DEBUG', default=True) MAIL_SERVER = environ.get('MAIL_SERVER', default='smtp.mailtrap.io') diff --git a/crc/scripts/email.py b/crc/scripts/email.py index 3dc9cb11..18e5df86 100644 --- a/crc/scripts/email.py +++ b/crc/scripts/email.py @@ -1,5 +1,6 @@ from jinja2 import Template +from crc import app from crc.api.common import ApiError from crc.scripts.script import Script from crc.services.ldap_service import LdapService @@ -29,8 +30,8 @@ Email Subject ApprvlApprvr1 PIComputingID content = self.get_content(task) if recipients: send_mail( - subject='Test Subject', - sender='sender@sartography.com', + subject=subject, + sender=app.config['DEFAULT_SENDER'], recipients=recipients, content=content, content_html=content From 1844c939199462cfcfcd33e6269f7661f1625ccc Mon Sep 17 00:00:00 2001 From: Kelly McDonald Date: Wed, 17 Jun 2020 11:35:06 -0400 Subject: [PATCH 012/101] STG-26 - basic test case for a looping task Criteria : task.multi_instance_type == 'looping' to terminate, use the standard endpoint for submitting form data with a query variable of terminate_loop=true Will likely need two buttons: "Submit and quit" "Submit and add another" or something similar --- crc/api.yml | 6 +++ crc/api/workflow.py | 5 +- tests/base_test.py | 29 ++++++---- tests/data/looping_task/looping_task.bpmn | 45 ++++++++++++++++ tests/test_looping_task.py | 54 +++++++++++++++++++ .../test_workflow_processor_multi_instance.py | 3 +- 6 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 tests/data/looping_task/looping_task.bpmn create mode 100644 tests/test_looping_task.py diff --git a/crc/api.yml b/crc/api.yml index 64f6086a..24cd2d5d 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -626,6 +626,12 @@ paths: schema: type: string format: uuid + - name: terminate_loop + in: query + required: false + description: Terminate the loop on a looping task + schema: + type: boolean put: operationId: crc.api.workflow.update_task summary: Exclusively for User Tasks, submits form data as a flat set of key/values. diff --git a/crc/api/workflow.py b/crc/api/workflow.py index 655a85e7..890a4de5 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -175,7 +175,7 @@ def set_current_task(workflow_id, task_id): return WorkflowApiSchema().dump(workflow_api_model) -def update_task(workflow_id, task_id, body): +def update_task(workflow_id, task_id, body, terminate_loop=None): workflow_model = session.query(WorkflowModel).filter_by(id=workflow_id).first() if workflow_model is None: @@ -191,6 +191,9 @@ def update_task(workflow_id, task_id, body): if task.state != task.READY: raise ApiError("invalid_state", "You may not update a task unless it is in the READY state. " "Consider calling a token reset to make this task Ready.") + if terminate_loop: + task.terminate_loop() + task.update_data(body) processor.complete_task(task) processor.do_engine_steps() diff --git a/tests/base_test.py b/tests/base_test.py index 93294193..3bdae053 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -290,7 +290,7 @@ class BaseTest(unittest.TestCase): self.assertEqual(workflow.workflow_spec_id, workflow_api.workflow_spec_id) return workflow_api - def complete_form(self, workflow_in, task_in, dict_data, error_code=None, user_uid="dhf8r"): + def complete_form(self, workflow_in, task_in, dict_data, error_code=None, terminate_loop=None, user_uid="dhf8r"): prev_completed_task_count = workflow_in.completed_tasks if isinstance(task_in, dict): task_id = task_in["id"] @@ -299,11 +299,16 @@ class BaseTest(unittest.TestCase): user = session.query(UserModel).filter_by(uid=user_uid).first() self.assertIsNotNone(user) - - rv = self.app.put('/v1.0/workflow/%i/task/%s/data' % (workflow_in.id, task_id), - headers=self.logged_in_headers(user=user), - content_type="application/json", - data=json.dumps(dict_data)) + if terminate_loop: + rv = self.app.put('/v1.0/workflow/%i/task/%s/data?terminate_loop=true' % (workflow_in.id, task_id), + headers=self.logged_in_headers(user=user), + content_type="application/json", + data=json.dumps(dict_data)) + else: + rv = self.app.put('/v1.0/workflow/%i/task/%s/data' % (workflow_in.id, task_id), + headers=self.logged_in_headers(user=user), + content_type="application/json", + data=json.dumps(dict_data)) if error_code: self.assert_failure(rv, error_code=error_code) return @@ -316,7 +321,9 @@ class BaseTest(unittest.TestCase): # The total number of tasks may change over time, as users move through gateways # branches may be pruned. As we hit parallel Multi-Instance new tasks may be created... self.assertIsNotNone(workflow.total_tasks) - self.assertEqual(prev_completed_task_count + 1, workflow.completed_tasks) + # presumably, we also need to deal with sequential items here too . . + if not task_in.multi_instance_type == 'looping': + self.assertEqual(prev_completed_task_count + 1, workflow.completed_tasks) # Assure a record exists in the Task Events task_events = session.query(TaskEventModel) \ @@ -335,7 +342,8 @@ class BaseTest(unittest.TestCase): self.assertEqual(task_in.name, event.task_name) self.assertEqual(task_in.title, event.task_title) self.assertEqual(task_in.type, event.task_type) - self.assertEqual("COMPLETED", event.task_state) + if not task_in.multi_instance_type == 'looping': + self.assertEqual("COMPLETED", event.task_state) # Not sure what voodoo is happening inside of marshmallow to get me in this state. if isinstance(task_in.multi_instance_type, MultiInstanceType): @@ -344,7 +352,10 @@ class BaseTest(unittest.TestCase): self.assertEqual(task_in.multi_instance_type, event.mi_type) self.assertEqual(task_in.multi_instance_count, event.mi_count) - self.assertEqual(task_in.multi_instance_index, event.mi_index) + if task_in.multi_instance_type == 'looping' and not terminate_loop: + self.assertEqual(task_in.multi_instance_index+1, event.mi_index) + else: + self.assertEqual(task_in.multi_instance_index, event.mi_index) self.assertEqual(task_in.process_name, event.process_name) self.assertIsNotNone(event.date) diff --git a/tests/data/looping_task/looping_task.bpmn b/tests/data/looping_task/looping_task.bpmn new file mode 100644 index 00000000..0c3929bf --- /dev/null +++ b/tests/data/looping_task/looping_task.bpmn @@ -0,0 +1,45 @@ + + + + + Flow_0vlor2k + + + + + + + + + Flow_0vlor2k + Flow_1tvod7v + + + + Flow_1tvod7v + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_looping_task.py b/tests/test_looping_task.py new file mode 100644 index 00000000..87701ef4 --- /dev/null +++ b/tests/test_looping_task.py @@ -0,0 +1,54 @@ +from unittest.mock import patch + +from crc import session +from crc.models.api_models import MultiInstanceType +from crc.models.study import StudyModel +from crc.models.workflow import WorkflowStatus +from crc.services.study_service import StudyService +from crc.services.workflow_processor import WorkflowProcessor +from crc.services.workflow_service import WorkflowService +from tests.base_test import BaseTest + + +class TestWorkflowProcessorLoopingTask(BaseTest): + """Tests the Workflow Processor as it deals with a Looping task""" + + def _populate_form_with_random_data(self, task): + api_task = WorkflowService.spiff_task_to_api_task(task, add_docs_and_forms=True) + WorkflowService.populate_form_with_random_data(task, api_task, required_only=False) + + def get_processor(self, study_model, spec_model): + workflow_model = StudyService._create_workflow_model(study_model, spec_model) + return WorkflowProcessor(workflow_model) + + def test_create_and_complete_workflow(self): + # This depends on getting a list of investigators back from the protocol builder. + + workflow = self.create_workflow('looping_task') + task = self.get_workflow_api(workflow).next_task + + self.assertEqual("GetNames", task.name) + + self.assertEqual(task.multi_instance_type, 'looping') + self.assertEqual(1, task.multi_instance_index) + self.complete_form(workflow,task,{'GetNames_MICurrentVar':{'Name': 'Peter Norvig', 'Nickname': 'Pete'}}) + task = self.get_workflow_api(workflow).next_task + + self.assertEqual(task.multi_instance_type,'looping') + self.assertEqual(2, task.multi_instance_index) + self.complete_form(workflow, + task, + {'GetNames_MICurrentVar':{'Name': 'Stuart Russell', 'Nickname': 'Stu'}}, + terminate_loop=True) + + task = self.get_workflow_api(workflow).next_task + self.assertEqual(task.name,'Event_End') + self.assertEqual(workflow.completed_tasks,workflow.total_tasks) + self.assertEqual(task.data, {'GetNames_MICurrentVar': 2, + 'GetNames_MIData': {'1': {'Name': 'Peter Norvig', + 'Nickname': 'Pete'}, + '2': {'Name': 'Stuart Russell', + 'Nickname': 'Stu'}}}) + + + diff --git a/tests/test_workflow_processor_multi_instance.py b/tests/test_workflow_processor_multi_instance.py index aefb73f1..a4c76dd0 100644 --- a/tests/test_workflow_processor_multi_instance.py +++ b/tests/test_workflow_processor_multi_instance.py @@ -32,7 +32,8 @@ class TestWorkflowProcessorMultiInstance(BaseTest): 'error': 'Unable to locate a user with id asd3v in LDAP'}} def _populate_form_with_random_data(self, task): - WorkflowProcessor.populate_form_with_random_data(task) + + WorkflowService.populate_form_with_random_data(task) def get_processor(self, study_model, spec_model): workflow_model = StudyService._create_workflow_model(study_model, spec_model) From 3b57adb84caf864f80c5dd47ff9684ecd704bc50 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Wed, 17 Jun 2020 17:11:15 -0400 Subject: [PATCH 013/101] Continuing a major refactor. Some important points: * TaskEvents now contain the data for each event as it was when the task was completed. * When loading a task for the front end, if the task was completed previously, we take that data, and overwrite it with the lastest data, allowing users to see previously entered values. * Pulling in the Admin branch, as there are changes in that branch that are critical to seeing what is happening when we do this thing. * Moved code for converting a workflow to an API ready data stricture into the Workflow service where it belongs, and out of the API. * Hard resets just convert to using the latest spec, they don't try to keep the data from the last task. There is a better way. * Moving to a previous task does not attept to keep the data from the last completed task. * Added a function that will fix all the existing RRT data by adding critical data into the TaskEvent model. This can be called with from the flask command line tool. --- crc/__init__.py | 7 + crc/api/admin.py | 16 ++ crc/api/workflow.py | 62 +------- crc/models/api_models.py | 1 + crc/services/workflow_processor.py | 26 +--- crc/services/workflow_service.py | 140 +++++++++++++++++- .../{3876e130664e_.py => 1fdd1bdb600e_.py} | 10 +- tests/test_workflow_service.py | 58 +++++++- 8 files changed, 232 insertions(+), 88 deletions(-) rename migrations/versions/{3876e130664e_.py => 1fdd1bdb600e_.py} (78%) diff --git a/crc/__init__.py b/crc/__init__.py index a211b0fa..59ffeac7 100644 --- a/crc/__init__.py +++ b/crc/__init__.py @@ -93,3 +93,10 @@ def clear_db(): """Load example data into the database.""" from example_data import ExampleDataLoader ExampleDataLoader.clean_db() + +@app.cli.command() +def rrt_data_fix(): + """Finds all the empty task event logs, and populates + them with good wholesome data.""" + from crc.services.workflow_service import WorkflowService + WorkflowService.fix_legacy_data_model_for_rrt() diff --git a/crc/api/admin.py b/crc/api/admin.py index 26a1b181..6a27b6da 100644 --- a/crc/api/admin.py +++ b/crc/api/admin.py @@ -1,15 +1,18 @@ # Admin app +import json from flask import url_for from flask_admin import Admin from flask_admin.contrib import sqla from flask_admin.contrib.sqla import ModelView from werkzeug.utils import redirect +from jinja2 import Markup from crc import db, app from crc.api.user import verify_token, verify_token_admin from crc.models.approval import ApprovalModel from crc.models.file import FileModel +from crc.models.stats import TaskEventModel from crc.models.study import StudyModel from crc.models.user import UserModel from crc.models.workflow import WorkflowModel @@ -47,6 +50,18 @@ class WorkflowView(AdminModelView): class FileView(AdminModelView): column_filters = ['workflow_id'] +def json_formatter(view, context, model, name): + value = getattr(model, name) + json_value = json.dumps(value, ensure_ascii=False, indent=2) + return Markup('
{}
'.format(json_value)) + +class TaskEventView(AdminModelView): + column_filters = ['workflow_id', 'action'] + column_list = ['study_id', 'user_id', 'workflow_id', 'action', 'task_title', 'task_data', 'date'] + column_formatters = { + 'task_data': json_formatter, + } + admin = Admin(app) admin.add_view(StudyView(StudyModel, db.session)) @@ -54,3 +69,4 @@ admin.add_view(ApprovalView(ApprovalModel, db.session)) admin.add_view(UserView(UserModel, db.session)) admin.add_view(WorkflowView(WorkflowModel, db.session)) admin.add_view(FileView(FileModel, db.session)) +admin.add_view(TaskEventView(TaskEventModel, db.session)) diff --git a/crc/api/workflow.py b/crc/api/workflow.py index 4d1667dd..14c40df5 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -96,66 +96,10 @@ def delete_workflow_specification(spec_id): session.commit() -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') - # fixme: duplicate code from the workflow_service. Should only do this in one place. - if ' ' in nav_item['title']: - nav_item['title'] = nav_item['title'].partition(' ')[2] - 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) - - spec = session.query(WorkflowSpecModel).filter_by(id=processor.workflow_spec_id).first() - workflow_api = WorkflowApi( - id=processor.get_workflow_id(), - status=processor.get_status(), - next_task=None, - navigation=navigation, - workflow_spec_id=processor.workflow_spec_id, - spec_version=processor.get_version_string(), - is_latest_spec=processor.is_latest_spec, - total_tasks=len(navigation), - completed_tasks=processor.workflow_model.completed_tasks, - last_updated=processor.workflow_model.last_updated, - ) - 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: - latest_event = session.query(TaskEventModel) \ - .filter_by(workflow_id=processor.workflow_model.id) \ - .filter_by(task_name=next_task.task_spec.name) \ - .filter_by(action=WorkflowService.TASK_ACTION_COMPLETE) \ - .order_by(TaskEventModel.date.desc()).first() - if latest_event: - next_task.data = DeepMerge.merge(next_task.data, latest_event.task_data) - - workflow_api.next_task = WorkflowService.spiff_task_to_api_task(next_task, add_docs_and_forms=True) - - return workflow_api - - def get_workflow(workflow_id, soft_reset=False, hard_reset=False): workflow_model: WorkflowModel = session.query(WorkflowModel).filter_by(id=workflow_id).first() processor = WorkflowProcessor(workflow_model, soft_reset=soft_reset, hard_reset=hard_reset) - workflow_api_model = __get_workflow_api_model(processor) + workflow_api_model = WorkflowService.processor_to_workflow_api(processor) return WorkflowApiSchema().dump(workflow_api_model) @@ -181,7 +125,7 @@ def set_current_task(workflow_id, task_id): WorkflowService.log_task_action(user_uid, workflow_model, spiff_task, WorkflowService.TASK_ACTION_TOKEN_RESET, version=processor.get_version_string()) - workflow_api_model = __get_workflow_api_model(processor, spiff_task) + workflow_api_model = WorkflowService.processor_to_workflow_api(processor, spiff_task) return WorkflowApiSchema().dump(workflow_api_model) @@ -209,7 +153,7 @@ def update_task(workflow_id, task_id, body): WorkflowService.log_task_action(user_uid, workflow_model, spiff_task, WorkflowService.TASK_ACTION_COMPLETE, version=processor.get_version_string(), updated_data=spiff_task.data) - workflow_api_model = __get_workflow_api_model(processor) + workflow_api_model = WorkflowService.processor_to_workflow_api(processor) return WorkflowApiSchema().dump(workflow_api_model) diff --git a/crc/models/api_models.py b/crc/models/api_models.py index 53706a75..361b9183 100644 --- a/crc/models/api_models.py +++ b/crc/models/api_models.py @@ -36,6 +36,7 @@ class Task(object): PROP_OPTIONS_FILE = "spreadsheet.name" PROP_OPTIONS_VALUE_COLUMN = "spreadsheet.value.column" PROP_OPTIONS_LABEL_COL = "spreadsheet.label.column" + PROP_OPTIONS_READ_ONLY = "read_only" PROP_LDAP_LOOKUP = "ldap.lookup" VALIDATION_REQUIRED = "required" FIELD_TYPE_AUTO_COMPLETE = "autocomplete" diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index e5cbe0a3..c84aa3fa 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -102,14 +102,15 @@ class WorkflowProcessor(object): def __init__(self, workflow_model: WorkflowModel, soft_reset=False, hard_reset=False, validate_only=False): """Create a Workflow 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 soft_reset is set to true, it will try to use the latest version of the workflow specification + without resetting to the beginning of the workflow. This will work for some minor changes to the spec. + If hard_reset is set to true, it will use the latest spec, and start the workflow over from the beginning. + which should work in casees where a soft reset fails. If neither flag is set, it will use the same version of the specification that was used to originally create the workflow model. """ self.workflow_model = workflow_model - if soft_reset or len(workflow_model.dependencies) == 0: + if soft_reset or len(workflow_model.dependencies) == 0: # Depenencies of 0 means the workflow was never started. self.spec_data_files = FileService.get_spec_data_files( workflow_spec_id=workflow_model.workflow_spec_id) else: @@ -216,8 +217,6 @@ class WorkflowProcessor(object): full_version = "v%s (%s)" % (version, files) return full_version - - def update_dependencies(self, spec_data_files): existing_dependencies = FileService.get_spec_data_files( workflow_spec_id=self.workflow_model.workflow_spec_id, @@ -299,25 +298,12 @@ class WorkflowProcessor(object): return WorkflowStatus.waiting 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 they don't need to reenter all the previous data. - - Returns the new version. + """Recreate this workflow. This will be useful when a workflow specification changes. """ - - # Create a new workflow based on the latest specs. self.spec_data_files = FileService.get_spec_data_files(workflow_spec_id=self.workflow_spec_id) new_spec = WorkflowProcessor.get_spec(self.spec_data_files, self.workflow_spec_id) new_bpmn_workflow = BpmnWorkflow(new_spec, script_engine=self._script_engine) new_bpmn_workflow.data = self.bpmn_workflow.data - - # Reset the current workflow to the beginning - which we will consider to be the first task after the root - # element. This feels a little sketchy, but I think it is safe to assume root will have one child. - first_task = self.bpmn_workflow.task_tree.children[0] - first_task.reset_token(reset_data=True) # Clear out the data. - for task in new_bpmn_workflow.get_tasks(SpiffTask.READY): - task.data = first_task.data new_bpmn_workflow.do_engine_steps() self.bpmn_workflow = new_bpmn_workflow diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index a8860886..3b064954 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -1,3 +1,4 @@ +import copy import string from datetime import datetime import random @@ -9,16 +10,17 @@ from SpiffWorkflow.bpmn.specs.ScriptTask import ScriptTask from SpiffWorkflow.bpmn.specs.UserTask import UserTask from SpiffWorkflow.dmn.specs.BusinessRuleTask import BusinessRuleTask from SpiffWorkflow.specs import CancelTask, StartTask +from SpiffWorkflow.util.deep_merge import DeepMerge from jinja2 import Template from crc import db, app from crc.api.common import ApiError -from crc.models.api_models import Task, MultiInstanceType +from crc.models.api_models import Task, MultiInstanceType, NavigationItem, NavigationItemSchema, WorkflowApi from crc.models.file import LookupDataModel from crc.models.stats import TaskEventModel from crc.models.study import StudyModel from crc.models.user import UserModel -from crc.models.workflow import WorkflowModel, WorkflowStatus +from crc.models.workflow import WorkflowModel, WorkflowStatus, WorkflowSpecModel from crc.services.file_service import FileService from crc.services.lookup_service import LookupService from crc.services.study_service import StudyService @@ -179,13 +181,81 @@ class WorkflowService(object): def __get_options(self): pass - @staticmethod def _random_string(string_length=10): """Generate a random string of fixed length """ letters = string.ascii_lowercase return ''.join(random.choice(letters) for i in range(string_length)) + @staticmethod + def processor_to_workflow_api(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') + # fixme: duplicate code from the workflow_service. Should only do this in one place. + if ' ' in nav_item['title']: + nav_item['title'] = nav_item['title'].partition(' ')[2] + 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) + + spec = db.session.query(WorkflowSpecModel).filter_by(id=processor.workflow_spec_id).first() + workflow_api = WorkflowApi( + id=processor.get_workflow_id(), + status=processor.get_status(), + next_task=None, + navigation=navigation, + workflow_spec_id=processor.workflow_spec_id, + spec_version=processor.get_version_string(), + is_latest_spec=processor.is_latest_spec, + total_tasks=len(navigation), + completed_tasks=processor.workflow_model.completed_tasks, + last_updated=processor.workflow_model.last_updated, + title=spec.display_name + ) + 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) + + return workflow_api + + @staticmethod + def get_previously_submitted_data(workflow_id, task): + """ If the user has completed this task previously, find that data in the task events table, and return it.""" + latest_event = db.session.query(TaskEventModel) \ + .filter_by(workflow_id=workflow_id) \ + .filter_by(task_name=task.task_spec.name) \ + .filter_by(action=WorkflowService.TASK_ACTION_COMPLETE) \ + .order_by(TaskEventModel.date.desc()).first() + if latest_event: + if latest_event.task_data is not None: + return latest_event.task_data + else: + app.logger.error("missing_task_data", "We have lost data for workflow %i, task %s, it is not " + "in the task event model, " + "and it should be." % (workflow_id, task.task_spec.name)) + return {} + else: + return {} + + @staticmethod def spiff_task_to_api_task(spiff_task, add_docs_and_forms=False): task_type = spiff_task.task_spec.__class__.__name__ @@ -342,3 +412,67 @@ class WorkflowService(object): db.session.add(task_event) db.session.commit() + @staticmethod + def fix_legacy_data_model_for_rrt(): + """ Remove this after use! This is just to fix RRT so the data is handled correctly. + + Utility that is likely called via the flask command line, it will loop through all the + workflows in the system and attempt to add the right data into the task action log so that + users do not have to re fill out all of the forms if they start over or go back in the workflow. + Viciously inefficient, but should only have to run one time for RRT""" + workflows = db.session.query(WorkflowModel).all() + for workflow_model in workflows: + task_logs = db.session.query(TaskEventModel) \ + .filter(TaskEventModel.workflow_id == workflow_model.id) \ + .filter(TaskEventModel.action == WorkflowService.TASK_ACTION_COMPLETE) \ + .order_by(TaskEventModel.date.desc()).all() + + processor = WorkflowProcessor(workflow_model) + # Grab all the data from last task completed, which will be everything in this + # rrt situation because of how we were keeping all the data at the time. + latest_data = processor.next_task().data + + # Move forward in the task spec tree, dropping any data that would have been + # added in subsequent tasks, just looking at form data, will not track the automated + # task data additions, hopefully this doesn't hang us. + for log in task_logs: + if log.task_data is not None: # Only do this if the task event does not have data populated in it. + continue + data = copy.deepcopy(latest_data) # Or you end up with insane crazy issues. + # In the simple case of RRT, there is exactly one task for the given task_spec + task = processor.bpmn_workflow.get_tasks_from_spec_name(log.task_name)[0] + data = WorkflowService.__remove_data_added_by_children(data, task.children[0]) + log.task_data = data + db.session.add(log) + + db.session.commit() + + @staticmethod + def __remove_data_added_by_children(latest_data, child_task): + """Removes data from latest_data that would be added by the child task or any of it's children.""" + if hasattr(child_task.task_spec, 'form'): + for field in child_task.task_spec.form.fields: + latest_data.pop(field.id, None) + if field.has_property(Task.PROP_OPTIONS_READ_ONLY) and \ + field.get_property(Task.PROP_OPTIONS_READ_ONLY).lower().strip() == "true": + continue # Don't pop off read only fields. + if field.has_property(Task.PROP_OPTIONS_REPEAT): + group = field.get_property(Task.PROP_OPTIONS_REPEAT) + group_data = [] + if group in latest_data: + for item in latest_data[group]: + item.pop(field.id, None) + if item: + group_data.append(item) + latest_data[group] = group_data + if not latest_data[group]: + latest_data.pop(group, None) + if isinstance(child_task.task_spec, BusinessRuleTask): + for output in child_task.task_spec.dmnEngine.decisionTable.outputs: + latest_data.pop(output.name, None) + for child in child_task.children: + latest_data = WorkflowService.__remove_data_added_by_children(latest_data, child) + return latest_data + + + diff --git a/migrations/versions/3876e130664e_.py b/migrations/versions/1fdd1bdb600e_.py similarity index 78% rename from migrations/versions/3876e130664e_.py rename to migrations/versions/1fdd1bdb600e_.py index 31e7ce13..dff1fdae 100644 --- a/migrations/versions/3876e130664e_.py +++ b/migrations/versions/1fdd1bdb600e_.py @@ -1,8 +1,8 @@ """empty message -Revision ID: 3876e130664e -Revises: 5064b72284b7 -Create Date: 2020-06-01 15:39:53.937591 +Revision ID: 1fdd1bdb600e +Revises: 17597692d0b0 +Create Date: 2020-06-17 16:44:16.427988 """ from alembic import op @@ -10,8 +10,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '3876e130664e' -down_revision = '5064b72284b7' +revision = '1fdd1bdb600e' +down_revision = '17597692d0b0' branch_labels = None depends_on = None diff --git a/tests/test_workflow_service.py b/tests/test_workflow_service.py index 9f3ceda1..6f0fa5e3 100644 --- a/tests/test_workflow_service.py +++ b/tests/test_workflow_service.py @@ -1,7 +1,14 @@ +import json + from tests.base_test import BaseTest from crc.services.workflow_processor import WorkflowProcessor from crc.services.workflow_service import WorkflowService +from SpiffWorkflow import Task as SpiffTask, WorkflowException +from example_data import ExampleDataLoader +from crc import db +from crc.models.stats import TaskEventModel +from crc.models.api_models import Task class TestWorkflowService(BaseTest): @@ -78,4 +85,53 @@ class TestWorkflowService(BaseTest): task = processor.next_task() task_api = WorkflowService.spiff_task_to_api_task(task, add_docs_and_forms=True) WorkflowService.populate_form_with_random_data(task, task_api, required_only=False) - self.assertTrue(isinstance(task.data["sponsor"], dict)) \ No newline at end of file + self.assertTrue(isinstance(task.data["sponsor"], dict)) + + def test_fix_legacy_data_model_for_rrt(self): + ExampleDataLoader().load_rrt() # Make sure the research_rampup is loaded, as it's not a test spec. + workflow = self.create_workflow('research_rampup') + processor = WorkflowProcessor(workflow, validate_only=True) + + # Use the test spec code to complete the workflow of research rampup. + while not processor.bpmn_workflow.is_completed(): + processor.bpmn_workflow.do_engine_steps() + tasks = processor.bpmn_workflow.get_tasks(SpiffTask.READY) + for task in tasks: + task_api = WorkflowService.spiff_task_to_api_task(task, add_docs_and_forms=True) + WorkflowService.populate_form_with_random_data(task, task_api, False) + task.complete() + # create the task events with no task_data in them. + WorkflowService.log_task_action('dhf8r', workflow, task, + WorkflowService.TASK_ACTION_COMPLETE, + version=processor.get_version_string(), + updated_data=None) + processor.save() + db.session.commit() + + WorkflowService.fix_legacy_data_model_for_rrt() + + # All tasks should now have data associated with them. + task_logs = db.session.query(TaskEventModel) \ + .filter(TaskEventModel.workflow_id == workflow.id) \ + .filter(TaskEventModel.action == WorkflowService.TASK_ACTION_COMPLETE) \ + .order_by(TaskEventModel.date).all() # Get them back in order. + + self.assertEqual(17, len(task_logs)) + for log in task_logs: + task = processor.bpmn_workflow.get_tasks_from_spec_name(log.task_name)[0] + self.assertIsNotNone(log.task_data) + # Each task should have the data in the form for that task in the task event. + if hasattr(task.task_spec, 'form'): + for field in task.task_spec.form.fields: + if field.has_property(Task.PROP_OPTIONS_REPEAT): + self.assertIn(field.get_property(Task.PROP_OPTIONS_REPEAT), log.task_data) + else: + self.assertIn(field.id, log.task_data) + + # Some spot checks: + # The first task should be empty, with all the data removed. + self.assertEqual({}, task_logs[0].task_data) + + # The last task should have all the data. + self.assertDictEqual(processor.bpmn_workflow.last_task.data, task_logs[16].task_data) + From 2ce2dc73b547c721482418e6ad62231f442345cf Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Wed, 17 Jun 2020 16:09:38 -0600 Subject: [PATCH 014/101] Rendering proper content & organizing file structure for email tests --- crc/models/ldap.py | 3 +++ crc/scripts/email.py | 18 +++++++++++------- tests/data/email/email.bpmn | 2 +- tests/{ => emails}/test_email_script.py | 0 tests/{ => emails}/test_email_service.py | 0 tests/{ => emails}/test_mails.py | 0 6 files changed, 15 insertions(+), 8 deletions(-) rename tests/{ => emails}/test_email_script.py (100%) rename tests/{ => emails}/test_email_service.py (100%) rename tests/{ => emails}/test_mails.py (100%) diff --git a/crc/models/ldap.py b/crc/models/ldap.py index 7e05eccd..802e0d36 100644 --- a/crc/models/ldap.py +++ b/crc/models/ldap.py @@ -29,6 +29,9 @@ class LdapModel(db.Model): affiliation=", ".join(entry.uvaPersonIAMAffiliation), sponsor_type=", ".join(entry.uvaPersonSponsoredType)) + def proper_name(self): + return f'{self.display_name} - ({self.uid})' + class LdapSchema(SQLAlchemyAutoSchema): class Meta: diff --git a/crc/scripts/email.py b/crc/scripts/email.py index 18e5df86..cbc093e8 100644 --- a/crc/scripts/email.py +++ b/crc/scripts/email.py @@ -22,12 +22,14 @@ Email Subject ApprvlApprvr1 PIComputingID """ def do_task_validate_only(self, task, *args, **kwargs): - self.get_emails(task, args) + self.get_subject(task, args) + self.get_users_info(task, args) + self.get_content(task, {}) def do_task(self, task, *args, **kwargs): subject = self.get_subject(task, args) - recipients = self.get_emails(task, args) - content = self.get_content(task) + recipients, display_keys = self.get_users_info(task, args) + content = self.get_content(task, display_keys) if recipients: send_mail( subject=subject, @@ -37,18 +39,20 @@ Email Subject ApprvlApprvr1 PIComputingID content_html=content ) - def get_emails(self, task, args): + def get_users_info(self, task, args): if len(args) < 1: raise ApiError(code="missing_argument", message="Email script requires at least one argument. The " "name of the variable in the task data that contains user" "id to process. Multiple arguments are accepted.") emails = [] + display_keys = {} for arg in args[1:]: uid = task.workflow.script_engine.evaluate_expression(task, arg) user_info = LdapService.user_info(uid) email = user_info.email_address emails.append(user_info.email_address) + display_keys[arg] = user_info.proper_name() if not isinstance(email, str): raise ApiError(code="invalid_argument", message="The Email script requires at least 1 UID argument. The " @@ -56,7 +60,7 @@ Email Subject ApprvlApprvr1 PIComputingID " user ids to process. This must point to an array or a string, but " "it currently points to a %s " % emails.__class__.__name__) - return emails + return emails, display_keys def get_subject(self, task, args): if len(args) < 1: @@ -74,9 +78,9 @@ Email Subject ApprvlApprvr1 PIComputingID return subject - def get_content(self, task): + def get_content(self, task, display_keys): content = task.task_spec.documentation template = Template(content) - rendered = template.render({'approver': 'Bossman', 'not_here': 22}) + rendered = template.render(display_keys) return rendered diff --git a/tests/data/email/email.bpmn b/tests/data/email/email.bpmn index b2221f24..c3887d68 100644 --- a/tests/data/email/email.bpmn +++ b/tests/data/email/email.bpmn @@ -8,7 +8,7 @@ Flow_1xlrgne - Email content to be delivered to {{ approver }} + Email content to be delivered to {{ ApprvlApprvr1 }} Flow_08n2npe Flow_1xlrgne Email Subject ApprvlApprvr1 PIComputingID diff --git a/tests/test_email_script.py b/tests/emails/test_email_script.py similarity index 100% rename from tests/test_email_script.py rename to tests/emails/test_email_script.py diff --git a/tests/test_email_service.py b/tests/emails/test_email_service.py similarity index 100% rename from tests/test_email_service.py rename to tests/emails/test_email_service.py diff --git a/tests/test_mails.py b/tests/emails/test_mails.py similarity index 100% rename from tests/test_mails.py rename to tests/emails/test_mails.py From ddf1f4640cc0df8aed62fdcd79a840aa63996e8b Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Wed, 17 Jun 2020 16:10:06 -0600 Subject: [PATCH 015/101] Re-organizing tests file structure --- tests/{ => approval}/test_approvals_api.py | 0 tests/{ => approval}/test_approvals_service.py | 0 tests/{ => approval}/test_request_approval_script.py | 0 tests/{ => files}/test_file_service.py | 0 tests/{ => files}/test_files_api.py | 0 tests/{ => study}/test_study_api.py | 0 tests/{ => study}/test_study_details_documents.py | 0 tests/{ => study}/test_study_service.py | 0 tests/{ => study}/test_update_study_script.py | 0 tests/{ => workflow}/test_workflow_processor.py | 0 tests/{ => workflow}/test_workflow_processor_multi_instance.py | 0 tests/{ => workflow}/test_workflow_service.py | 0 tests/{ => workflow}/test_workflow_spec_api.py | 0 tests/{ => workflow}/test_workflow_spec_validation_api.py | 0 14 files changed, 0 insertions(+), 0 deletions(-) rename tests/{ => approval}/test_approvals_api.py (100%) rename tests/{ => approval}/test_approvals_service.py (100%) rename tests/{ => approval}/test_request_approval_script.py (100%) rename tests/{ => files}/test_file_service.py (100%) rename tests/{ => files}/test_files_api.py (100%) rename tests/{ => study}/test_study_api.py (100%) rename tests/{ => study}/test_study_details_documents.py (100%) rename tests/{ => study}/test_study_service.py (100%) rename tests/{ => study}/test_update_study_script.py (100%) rename tests/{ => workflow}/test_workflow_processor.py (100%) rename tests/{ => workflow}/test_workflow_processor_multi_instance.py (100%) rename tests/{ => workflow}/test_workflow_service.py (100%) rename tests/{ => workflow}/test_workflow_spec_api.py (100%) rename tests/{ => workflow}/test_workflow_spec_validation_api.py (100%) diff --git a/tests/test_approvals_api.py b/tests/approval/test_approvals_api.py similarity index 100% rename from tests/test_approvals_api.py rename to tests/approval/test_approvals_api.py diff --git a/tests/test_approvals_service.py b/tests/approval/test_approvals_service.py similarity index 100% rename from tests/test_approvals_service.py rename to tests/approval/test_approvals_service.py diff --git a/tests/test_request_approval_script.py b/tests/approval/test_request_approval_script.py similarity index 100% rename from tests/test_request_approval_script.py rename to tests/approval/test_request_approval_script.py diff --git a/tests/test_file_service.py b/tests/files/test_file_service.py similarity index 100% rename from tests/test_file_service.py rename to tests/files/test_file_service.py diff --git a/tests/test_files_api.py b/tests/files/test_files_api.py similarity index 100% rename from tests/test_files_api.py rename to tests/files/test_files_api.py diff --git a/tests/test_study_api.py b/tests/study/test_study_api.py similarity index 100% rename from tests/test_study_api.py rename to tests/study/test_study_api.py diff --git a/tests/test_study_details_documents.py b/tests/study/test_study_details_documents.py similarity index 100% rename from tests/test_study_details_documents.py rename to tests/study/test_study_details_documents.py diff --git a/tests/test_study_service.py b/tests/study/test_study_service.py similarity index 100% rename from tests/test_study_service.py rename to tests/study/test_study_service.py diff --git a/tests/test_update_study_script.py b/tests/study/test_update_study_script.py similarity index 100% rename from tests/test_update_study_script.py rename to tests/study/test_update_study_script.py diff --git a/tests/test_workflow_processor.py b/tests/workflow/test_workflow_processor.py similarity index 100% rename from tests/test_workflow_processor.py rename to tests/workflow/test_workflow_processor.py diff --git a/tests/test_workflow_processor_multi_instance.py b/tests/workflow/test_workflow_processor_multi_instance.py similarity index 100% rename from tests/test_workflow_processor_multi_instance.py rename to tests/workflow/test_workflow_processor_multi_instance.py diff --git a/tests/test_workflow_service.py b/tests/workflow/test_workflow_service.py similarity index 100% rename from tests/test_workflow_service.py rename to tests/workflow/test_workflow_service.py diff --git a/tests/test_workflow_spec_api.py b/tests/workflow/test_workflow_spec_api.py similarity index 100% rename from tests/test_workflow_spec_api.py rename to tests/workflow/test_workflow_spec_api.py diff --git a/tests/test_workflow_spec_validation_api.py b/tests/workflow/test_workflow_spec_validation_api.py similarity index 100% rename from tests/test_workflow_spec_validation_api.py rename to tests/workflow/test_workflow_spec_validation_api.py From 896ba6b37777e07459d0118106c889af23268cdc Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Wed, 17 Jun 2020 17:00:16 -0600 Subject: [PATCH 016/101] Email relies now on markdown content --- Pipfile | 1 + Pipfile.lock | 64 +++++++++++-------- crc/models/email.py | 6 +- crc/scripts/email.py | 10 +-- crc/services/email_service.py | 14 ++-- crc/services/mails.py | 23 ++----- .../{62a11a335778_.py => 839f6f255b81_.py} | 10 +-- tests/data/email/email.bpmn | 10 ++- tests/emails/test_email_script.py | 4 +- tests/emails/test_email_service.py | 17 ++--- 10 files changed, 81 insertions(+), 78 deletions(-) rename migrations/versions/{62a11a335778_.py => 839f6f255b81_.py} (79%) diff --git a/Pipfile b/Pipfile index 0079962c..3cf80ffc 100644 --- a/Pipfile +++ b/Pipfile @@ -40,6 +40,7 @@ gunicorn = "*" werkzeug = "*" sentry-sdk = {extras = ["flask"],version = "==0.14.4"} flask-mail = "*" +markdown = "*" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index f8ab746b..19fcdf9d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "6c89585086260ebcb41918b8ef3b1d9e189e1b492208d3ff000a138bc2f2fcee" + "sha256": "f5c922e74e296622c19ecfdd5c22cdcc71841fe81cdd95f407a2eb2ba475e615" }, "pipfile-spec": 6, "requires": { @@ -104,17 +104,17 @@ }, "celery": { "hashes": [ - "sha256:9ae2e73b93cc7d6b48b56aaf49a68c91752d0ffd7dfdcc47f842ca79a6f13eae", - "sha256:c2037b6a8463da43b19969a0fc13f9023ceca6352b4dd51be01c66fbbb13647e" + "sha256:c3f4173f83ceb5a5c986c5fdaefb9456de3b0729a72a5776e46bd405fda7b647", + "sha256:d1762d6065522879f341c3d67c2b9fe4615eb79756d59acb1434601d4aca474b" ], - "version": "==4.4.4" + "version": "==4.4.5" }, "certifi": { "hashes": [ - "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", - "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" + "sha256:5ad7e9a056d25ffa5082862e36f119f7f7cec6457fa07ee2f8c339814b80c9b1", + "sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc" ], - "version": "==2020.4.5.1" + "version": "==2020.4.5.2" }, "cffi": { "hashes": [ @@ -285,11 +285,11 @@ }, "flask-marshmallow": { "hashes": [ - "sha256:6e6aec171b8e092e0eafaf035ff5b8637bf3a58ab46f568c4c1bab02f2a3c196", - "sha256:a1685536e7ab5abdc712bbc1ac1a6b0b50951a368502f7985e7d1c27b3c21e59" + "sha256:1da1e6454a56a3e15107b987121729f152325bdef23f3df2f9b52bbd074af38e", + "sha256:aefc1f1d96256c430a409f08241bab75ffe97e5d14ac5d1f000764e39bf4873a" ], "index": "pypi", - "version": "==0.12.0" + "version": "==0.13.0" }, "flask-migrate": { "hashes": [ @@ -359,10 +359,10 @@ }, "inflection": { "hashes": [ - "sha256:32a5c3341d9583ec319548b9015b7fbdf8c429cbcb575d326c33ae3a0e90d52c", - "sha256:9a15d3598f01220e93f2207c432cfede50daff53137ce660fb8be838ef1ca6cc" + "sha256:88b101b2668a1d81d6d72d4c2018e53bc6c7fc544c987849da1c7f77545c3bc9", + "sha256:f576e85132d34f5bf7df5183c2c6f94cfb32e528f53065345cf71329ba0b8924" ], - "version": "==0.4.0" + "version": "==0.5.0" }, "itsdangerous": { "hashes": [ @@ -446,6 +446,14 @@ ], "version": "==1.1.3" }, + "markdown": { + "hashes": [ + "sha256:1fafe3f1ecabfb514a5285fca634a53c1b32a81cb0feb154264d55bf2ff22c17", + "sha256:c467cd6233885534bf0fe96e62e3cf46cfc1605112356c4f9981512b8174de59" + ], + "index": "pypi", + "version": "==3.2.2" + }, "markupsafe": { "hashes": [ "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", @@ -711,11 +719,11 @@ }, "requests": { "hashes": [ - "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", - "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" + "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", + "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" ], "index": "pypi", - "version": "==2.23.0" + "version": "==2.24.0" }, "sentry-sdk": { "extras": [ @@ -751,11 +759,11 @@ }, "sphinx": { "hashes": [ - "sha256:779a519adbd3a70fc7c468af08c5e74829868b0a5b34587b33340e010291856c", - "sha256:ea64df287958ee5aac46be7ac2b7277305b0381d213728c3a49d8bb9b8415807" + "sha256:74fbead182a611ce1444f50218a1c5fc70b6cc547f64948f5182fb30a2a20258", + "sha256:97c9e3bcce2f61d9f5edf131299ee9d1219630598d9f9a8791459a4d9e815be5" ], "index": "pypi", - "version": "==3.0.4" + "version": "==3.1.1" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -932,10 +940,10 @@ }, "more-itertools": { "hashes": [ - "sha256:558bb897a2232f5e4f8e2399089e35aecb746e1f9191b6584a151647e89267be", - "sha256:7818f596b1e87be009031c7653d01acc46ed422e6656b394b0f765ce66ed4982" + "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", + "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" ], - "version": "==8.3.0" + "version": "==8.4.0" }, "packaging": { "hashes": [ @@ -961,10 +969,10 @@ }, "py": { "hashes": [ - "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa", - "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0" + "sha256:a673fa23d7000440cc885c17dbd34fafcb7d7a6e230b29f6766400de36a33c44", + "sha256:f3b3a4c36512a4c4f024041ab51866f11761cc169670204b235f6b20523d4e6b" ], - "version": "==1.8.1" + "version": "==1.8.2" }, "pyparsing": { "hashes": [ @@ -990,10 +998,10 @@ }, "wcwidth": { "hashes": [ - "sha256:980fbf4f3c196c0f329cdcd1e84c554d6a211f18e252e525a0cf4223154a41d6", - "sha256:edbc2b718b4db6cdf393eefe3a420183947d6aa312505ce6754516f458ff8830" + "sha256:79375666b9954d4a1a10739315816324c3e73110af9d0e102d906fdb0aec009f", + "sha256:8c6b5b6ee1360b842645f336d9e5d68c55817c26d3050f46b235ef2bc650e48f" ], - "version": "==0.2.3" + "version": "==0.2.4" }, "zipp": { "hashes": [ diff --git a/crc/models/email.py b/crc/models/email.py index c3180a27..dc8c6834 100644 --- a/crc/models/email.py +++ b/crc/models/email.py @@ -3,7 +3,7 @@ from marshmallow import EXCLUDE from sqlalchemy import func from crc import db -from crc.models.approval import ApprovalModel +from crc.models.study import StudyModel class EmailModel(db.Model): @@ -14,5 +14,5 @@ class EmailModel(db.Model): recipients = db.Column(db.String) content = db.Column(db.String) content_html = db.Column(db.String) - approval_id = db.Column(db.Integer, db.ForeignKey(ApprovalModel.id), nullable=False) - approval = db.relationship(ApprovalModel) + study_id = db.Column(db.Integer, db.ForeignKey(StudyModel.id), nullable=True) + study = db.relationship(StudyModel) diff --git a/crc/scripts/email.py b/crc/scripts/email.py index cbc093e8..f2f34a66 100644 --- a/crc/scripts/email.py +++ b/crc/scripts/email.py @@ -1,3 +1,4 @@ +import markdown from jinja2 import Template from crc import app @@ -29,14 +30,15 @@ Email Subject ApprvlApprvr1 PIComputingID def do_task(self, task, *args, **kwargs): subject = self.get_subject(task, args) recipients, display_keys = self.get_users_info(task, args) - content = self.get_content(task, display_keys) + content, content_html = self.get_content(task, display_keys) + import pdb; pdb.set_trace() if recipients: send_mail( subject=subject, sender=app.config['DEFAULT_SENDER'], recipients=recipients, content=content, - content_html=content + content_html=content_html ) def get_users_info(self, task, args): @@ -82,5 +84,5 @@ Email Subject ApprvlApprvr1 PIComputingID content = task.task_spec.documentation template = Template(content) rendered = template.render(display_keys) - - return rendered + rendered_markdown = markdown.markdown(rendered).replace('\n', '
') + return rendered, rendered_markdown diff --git a/crc/services/email_service.py b/crc/services/email_service.py index 036ea1c9..633f2102 100644 --- a/crc/services/email_service.py +++ b/crc/services/email_service.py @@ -5,7 +5,7 @@ from sqlalchemy import desc from crc import app, db, session from crc.api.common import ApiError -from crc.models.approval import ApprovalModel +from crc.models.study import StudyModel from crc.models.email import EmailModel @@ -13,15 +13,19 @@ class EmailService(object): """Provides common tools for working with an Email""" @staticmethod - def add_email(subject, sender, recipients, content, content_html, approval_id): + def add_email(subject, sender, recipients, content, content_html, study_id): """We will receive all data related to an email and store it""" - # Find corresponding approval - approval = db.session.query(ApprovalModel).get(approval_id) + # Find corresponding study - if any + study = None + if type(study_id) == int: + study = db.session.query(StudyModel).get(study_id) # Create EmailModel email_model = EmailModel(subject=subject, sender=sender, recipients=str(recipients), - content=content, content_html=content_html, approval=approval) + content=content, content_html=content_html, study=study) + + # TODO: Send email from here, not from caller functions db.session.add(email_model) db.session.commit() diff --git a/crc/services/mails.py b/crc/services/mails.py index 6816b586..b9b18bd1 100644 --- a/crc/services/mails.py +++ b/crc/services/mails.py @@ -23,7 +23,7 @@ def send_test_email(sender, recipients): except Exception as e: return str(e) -def send_mail(subject, sender, recipients, content, content_html): +def send_mail(subject, sender, recipients, content, content_html, study_id=None): from crc import mail try: msg = Message(subject, @@ -34,6 +34,9 @@ def send_mail(subject, sender, recipients, content, content_html): msg.body = content msg.html = content_html + EmailService.add_email(subject=subject, sender=sender, recipients=recipients, + content=content, content_html=content_html, study_id=study_id) + mail.send(msg) except Exception as e: return str(e) @@ -48,9 +51,6 @@ def send_ramp_up_submission_email(sender, recipients, approval_id, approver_1, a template = env.get_template('ramp_up_submission.html') content_html = template.render(template_vars) - EmailService.add_email(subject=subject, sender=sender, recipients=recipients, - content=content, content_html=content_html, approval_id=approval_id) - result = send_mail(subject, sender, recipients, content, content_html) return result @@ -64,9 +64,6 @@ def send_ramp_up_approval_request_email(sender, recipients, approval_id, primary template = env.get_template('ramp_up_approval_request.html') content_html = template.render(template_vars) - EmailService.add_email(subject=subject, sender=sender, recipients=recipients, - content=content, content_html=content_html, approval_id=approval_id) - result = send_mail(subject, sender, recipients, content, content_html) return result @@ -80,9 +77,6 @@ def send_ramp_up_approval_request_first_review_email(sender, recipients, approva template = env.get_template('ramp_up_approval_request_first_review.html') content_html = template.render(template_vars) - EmailService.add_email(subject=subject, sender=sender, recipients=recipients, - content=content, content_html=content_html, approval_id=approval_id) - result = send_mail(subject, sender, recipients, content, content_html) return result @@ -96,9 +90,6 @@ def send_ramp_up_approved_email(sender, recipients, approval_id, approver_1, app template = env.get_template('ramp_up_approved.html') content_html = template.render(template_vars) - EmailService.add_email(subject=subject, sender=sender, recipients=recipients, - content=content, content_html=content_html, approval_id=approval_id) - result = send_mail(subject, sender, recipients, content, content_html) return result @@ -112,9 +103,6 @@ def send_ramp_up_denied_email(sender, recipients, approval_id, approver): template = env.get_template('ramp_up_denied.html') content_html = template.render(template_vars) - EmailService.add_email(subject=subject, sender=sender, recipients=recipients, - content=content, content_html=content_html, approval_id=approval_id) - result = send_mail(subject, sender, recipients, content, content_html) return result @@ -128,8 +116,5 @@ def send_ramp_up_denied_email_to_approver(sender, recipients, approval_id, prima template = env.get_template('ramp_up_denied_first_approver.html') content_html = template.render(template_vars) - EmailService.add_email(subject=subject, sender=sender, recipients=recipients, - content=content, content_html=content_html, approval_id=approval_id) - result = send_mail(subject, sender, recipients, content, content_html) return result diff --git a/migrations/versions/62a11a335778_.py b/migrations/versions/839f6f255b81_.py similarity index 79% rename from migrations/versions/62a11a335778_.py rename to migrations/versions/839f6f255b81_.py index ee8d8f91..e5400627 100644 --- a/migrations/versions/62a11a335778_.py +++ b/migrations/versions/839f6f255b81_.py @@ -1,8 +1,8 @@ """empty message -Revision ID: 62a11a335778 +Revision ID: 839f6f255b81 Revises: 17597692d0b0 -Create Date: 2020-06-09 22:45:52.475183 +Create Date: 2020-06-17 16:22:05.076206 """ from alembic import op @@ -10,7 +10,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '62a11a335778' +revision = '839f6f255b81' down_revision = '17597692d0b0' branch_labels = None depends_on = None @@ -25,8 +25,8 @@ def upgrade(): sa.Column('recipients', sa.String(), nullable=True), sa.Column('content', sa.String(), nullable=True), sa.Column('content_html', sa.String(), nullable=True), - sa.Column('approval_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['approval_id'], ['approval.id'], ), + sa.Column('study_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['study_id'], ['study.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### diff --git a/tests/data/email/email.bpmn b/tests/data/email/email.bpmn index c3887d68..4830e28f 100644 --- a/tests/data/email/email.bpmn +++ b/tests/data/email/email.bpmn @@ -8,7 +8,15 @@ Flow_1xlrgne - Email content to be delivered to {{ ApprvlApprvr1 }} + # Dear Approver +## you have been requested for approval + + +--- + +Email content to be delivered to {{ ApprvlApprvr1 }} + +--- Flow_08n2npe Flow_1xlrgne Email Subject ApprvlApprvr1 PIComputingID diff --git a/tests/emails/test_email_script.py b/tests/emails/test_email_script.py index 9ac93e07..2e1a5e04 100644 --- a/tests/emails/test_email_script.py +++ b/tests/emails/test_email_script.py @@ -6,7 +6,6 @@ from crc.services.workflow_processor import WorkflowProcessor from crc.api.common import ApiError from crc import db -# from crc.models.approval import ApprovalModel class TestEmailScript(BaseTest): @@ -17,6 +16,7 @@ class TestEmailScript(BaseTest): workflow = self.create_workflow('email') processor = WorkflowProcessor(workflow) task = processor.next_task() + # TODO: Replace with proper `complete_form` method from test_tasks processor.complete_task(task) task = processor.next_task() task.data = { @@ -27,4 +27,6 @@ class TestEmailScript(BaseTest): script = Email() script.do_task(task, 'Subject', 'PIComputingID', 'ApprvlApprvr1') + + # TODO: Add proper assertions self.assertTrue(True) diff --git a/tests/emails/test_email_service.py b/tests/emails/test_email_service.py index 9e0f2e57..c165ed10 100644 --- a/tests/emails/test_email_service.py +++ b/tests/emails/test_email_service.py @@ -13,24 +13,15 @@ class TestEmailService(BaseTest): study = self.create_study() workflow = self.create_workflow('random_fact') - approval = ApprovalModel( - study=study, - workflow=workflow, - approver_uid='lb3dp', - status=ApprovalStatus.PENDING.value, - version=1 - ) - session.add(approval) - session.commit() - subject = 'Email Subject' sender = 'sender@sartography.com' recipients = ['recipient@sartography.com', 'back@sartography.com'] content = 'Content for this email' content_html = '

Hypertext Markup Language content for this email

' + import pdb; pdb.set_trace() EmailService.add_email(subject=subject, sender=sender, recipients=recipients, - content=content, content_html=content_html, approval_id=approval.id) + content=content, content_html=content_html, study_id=study.id) email_model = EmailModel.query.first() @@ -39,4 +30,6 @@ class TestEmailService(BaseTest): self.assertEqual(email_model.recipients, str(recipients)) self.assertEqual(email_model.content, content) self.assertEqual(email_model.content_html, content_html) - self.assertEqual(email_model.approval, approval) + self.assertEqual(email_model.study, study) + + # TODO: Create email model without study From 5ce279b6637a3be7d61f840909dce9ead6185ce5 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Wed, 17 Jun 2020 17:36:15 -0600 Subject: [PATCH 017/101] Dropping silly pdb statement --- crc/scripts/email.py | 1 - tests/emails/test_email_service.py | 1 - 2 files changed, 2 deletions(-) diff --git a/crc/scripts/email.py b/crc/scripts/email.py index f2f34a66..f9d345a8 100644 --- a/crc/scripts/email.py +++ b/crc/scripts/email.py @@ -31,7 +31,6 @@ Email Subject ApprvlApprvr1 PIComputingID subject = self.get_subject(task, args) recipients, display_keys = self.get_users_info(task, args) content, content_html = self.get_content(task, display_keys) - import pdb; pdb.set_trace() if recipients: send_mail( subject=subject, diff --git a/tests/emails/test_email_service.py b/tests/emails/test_email_service.py index c165ed10..e2bcd139 100644 --- a/tests/emails/test_email_service.py +++ b/tests/emails/test_email_service.py @@ -19,7 +19,6 @@ class TestEmailService(BaseTest): content = 'Content for this email' content_html = '

Hypertext Markup Language content for this email

' - import pdb; pdb.set_trace() EmailService.add_email(subject=subject, sender=sender, recipients=recipients, content=content, content_html=content_html, study_id=study.id) From 4db815a999882b488cdae83fbb7372f52f3ce5be Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Wed, 17 Jun 2020 21:11:47 -0600 Subject: [PATCH 018/101] Handling incoming values from processor --- crc/scripts/email.py | 37 ++++++++++++++++++++---------- crc/services/workflow_processor.py | 2 +- tests/data/email/email.bpmn | 2 +- tests/emails/test_email_script.py | 32 +++++++++++++++----------- tests/files/test_file_service.py | 4 ++-- tests/files/test_files_api.py | 16 ++++--------- 6 files changed, 52 insertions(+), 41 deletions(-) diff --git a/crc/scripts/email.py b/crc/scripts/email.py index f9d345a8..01def412 100644 --- a/crc/scripts/email.py +++ b/crc/scripts/email.py @@ -28,8 +28,9 @@ Email Subject ApprvlApprvr1 PIComputingID self.get_content(task, {}) def do_task(self, task, *args, **kwargs): - subject = self.get_subject(task, args) - recipients, display_keys = self.get_users_info(task, args) + args = [arg for arg in args if type(arg) == str] + subject, subject_index = self.get_subject(task, args) + recipients, display_keys = self.get_users_info(task, args, subject_index) content, content_html = self.get_content(task, display_keys) if recipients: send_mail( @@ -40,7 +41,7 @@ Email Subject ApprvlApprvr1 PIComputingID content_html=content_html ) - def get_users_info(self, task, args): + def get_users_info(self, task, args, subject_index): if len(args) < 1: raise ApiError(code="missing_argument", message="Email script requires at least one argument. The " @@ -48,7 +49,7 @@ Email Subject ApprvlApprvr1 PIComputingID "id to process. Multiple arguments are accepted.") emails = [] display_keys = {} - for arg in args[1:]: + for arg in args[subject_index+1:]: uid = task.workflow.script_engine.evaluate_expression(task, arg) user_info = LdapService.user_info(uid) email = user_info.email_address @@ -69,15 +70,27 @@ Email Subject ApprvlApprvr1 PIComputingID message="Email script requires at least one subject argument. The " "name of the variable in the task data that contains subject" " to process. Multiple arguments are accepted.") - subject = task.workflow.script_engine.evaluate_expression(task, args[0]) - if not isinstance(subject, str): - raise ApiError(code="invalid_argument", - message="The Email script requires 1 argument. The " - "the name of the variable in the task data that contains user" - "ids to process. This must point to an array or a string, but " - "it currently points to a %s " % subject.__class__.__name__) - return subject + subject_index = 0 + subject = args[subject_index] + if subject.startswith('"') and not subject.endswith('"'): + # Multi-word subject + subject_index += 1 + next_word = args[subject_index] + while not next_word.endswith('"'): + subject = ' '.join((subject, next_word)) + subject_index += 1 + next_word = args[subject_index] + subject = ' '.join((subject, next_word)) + subject = subject.replace('"', '') + if not isinstance(subject, str): + raise ApiError(code="invalid_argument", + message="The Email script requires 1 argument. The " + "the name of the variable in the task data that contains user" + "ids to process. This must point to an array or a string, but " + "it currently points to a %s " % subject.__class__.__name__) + + return subject, subject_index def get_content(self, task, display_keys): content = task.task_spec.documentation diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index 93590d94..f04fb332 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -60,7 +60,7 @@ class CustomBpmnScriptEngine(BpmnScriptEngine): "does not properly implement the CRC Script class.", task=task) if task.workflow.data[WorkflowProcessor.VALIDATION_PROCESS_KEY]: - """If this is running a validation, and not a normal process, then we want to + """If this is running a validation, and not a normal process, then we want to mimic running the script, but not make any external calls or database changes.""" klass().do_task_validate_only(task, study_id, workflow_id, *commands[1:]) else: diff --git a/tests/data/email/email.bpmn b/tests/data/email/email.bpmn index 4830e28f..54ec61a8 100644 --- a/tests/data/email/email.bpmn +++ b/tests/data/email/email.bpmn @@ -19,7 +19,7 @@ Email content to be delivered to {{ ApprvlApprvr1 }} --- Flow_08n2npe Flow_1xlrgne - Email Subject ApprvlApprvr1 PIComputingID + Email "Camunda Email Subject" ApprvlApprvr1 PIComputingID
diff --git a/tests/emails/test_email_script.py b/tests/emails/test_email_script.py index 2e1a5e04..79d5c6ee 100644 --- a/tests/emails/test_email_script.py +++ b/tests/emails/test_email_script.py @@ -11,22 +11,26 @@ from crc import db class TestEmailScript(BaseTest): def test_do_task(self): - self.load_example_data() - self.create_reference_document() + # self.load_example_data() + # self.create_reference_document() workflow = self.create_workflow('email') - processor = WorkflowProcessor(workflow) - task = processor.next_task() - # TODO: Replace with proper `complete_form` method from test_tasks - processor.complete_task(task) - task = processor.next_task() - task.data = { - 'PIComputingID': 'dhf8r', - 'ApprvlApprvr1': 'lb3dp', - 'Subject': 'Email Script needs your help' - } - script = Email() - script.do_task(task, 'Subject', 'PIComputingID', 'ApprvlApprvr1') + # processor = WorkflowProcessor(workflow) + # task = processor.next_task() + # TODO: Replace with proper `complete_form` method from test_tasks + # processor.complete_task(task) + # task = processor.next_task() + task_data = { + 'PIComputingID': 'dhf8r', + 'ApprvlApprvr1': 'lb3dp' + } + task = self.get_workflow_api(workflow).next_task + + self.complete_form(workflow, task, task_data) + + + # script = Email() + # script.do_task(task, 'Subject', 'PIComputingID', 'ApprvlApprvr1') # TODO: Add proper assertions self.assertTrue(True) diff --git a/tests/files/test_file_service.py b/tests/files/test_file_service.py index 1dea810c..dd95e458 100644 --- a/tests/files/test_file_service.py +++ b/tests/files/test_file_service.py @@ -61,14 +61,14 @@ class TestFileService(BaseTest): # Archive the file file_models = FileService.get_workflow_files(workflow_id=workflow.id) - self.assertEquals(1, len(file_models)) + self.assertEqual(1, len(file_models)) file_model = file_models[0] file_model.archived = True db.session.add(file_model) # Assure that the file no longer comes back. file_models = FileService.get_workflow_files(workflow_id=workflow.id) - self.assertEquals(0, len(file_models)) + self.assertEqual(0, len(file_models)) # Add the file again with different data FileService.add_workflow_file(workflow_id=workflow.id, diff --git a/tests/files/test_files_api.py b/tests/files/test_files_api.py index 2d14a8b5..59e6c1f6 100644 --- a/tests/files/test_files_api.py +++ b/tests/files/test_files_api.py @@ -91,7 +91,6 @@ class TestFilesApi(BaseTest): content_type='multipart/form-data', headers=self.logged_in_headers()) self.assert_success(rv) - def test_archive_file_no_longer_shows_up(self): self.load_example_data() self.create_reference_document() @@ -109,21 +108,16 @@ class TestFilesApi(BaseTest): self.assert_success(rv) rv = self.app.get('/v1.0/file?workflow_id=%s' % workflow.id, headers=self.logged_in_headers()) self.assert_success(rv) - self.assertEquals(1, len(json.loads(rv.get_data(as_text=True)))) + self.assertEqual(1, len(json.loads(rv.get_data(as_text=True)))) file_model = db.session.query(FileModel).filter(FileModel.workflow_id == workflow.id).all() - self.assertEquals(1, len(file_model)) + self.assertEqual(1, len(file_model)) file_model[0].archived = True db.session.commit() rv = self.app.get('/v1.0/file?workflow_id=%s' % workflow.id, headers=self.logged_in_headers()) self.assert_success(rv) - self.assertEquals(0, len(json.loads(rv.get_data(as_text=True)))) - - - - - + self.assertEqual(0, len(json.loads(rv.get_data(as_text=True)))) def test_set_reference_file(self): file_name = "irb_document_types.xls" @@ -285,8 +279,8 @@ class TestFilesApi(BaseTest): .filter(ApprovalModel.status == ApprovalStatus.PENDING.value)\ .filter(ApprovalModel.study_id == workflow.study_id).all() - self.assertEquals(1, len(approvals)) - self.assertEquals(1, len(approvals[0].approval_files)) + self.assertEqual(1, len(approvals)) + self.assertEqual(1, len(approvals[0].approval_files)) def test_change_primary_bpmn(self): From 479f6d9647de8efeceffd78e9a9ab08e9ad39cf0 Mon Sep 17 00:00:00 2001 From: Kelly McDonald Date: Thu, 18 Jun 2020 12:01:02 -0400 Subject: [PATCH 019/101] STG-26 Do rename per conversation, continue to look for ways to implement looping in a way that is re-entrant --- tests/data/looping_task/looping_task.bpmn | 4 ++-- tests/test_looping_task.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/data/looping_task/looping_task.bpmn b/tests/data/looping_task/looping_task.bpmn index 0c3929bf..96b1b32f 100644 --- a/tests/data/looping_task/looping_task.bpmn +++ b/tests/data/looping_task/looping_task.bpmn @@ -7,8 +7,8 @@ - - + + Flow_0vlor2k diff --git a/tests/test_looping_task.py b/tests/test_looping_task.py index 87701ef4..e56e0877 100644 --- a/tests/test_looping_task.py +++ b/tests/test_looping_task.py @@ -31,21 +31,21 @@ class TestWorkflowProcessorLoopingTask(BaseTest): self.assertEqual(task.multi_instance_type, 'looping') self.assertEqual(1, task.multi_instance_index) - self.complete_form(workflow,task,{'GetNames_MICurrentVar':{'Name': 'Peter Norvig', 'Nickname': 'Pete'}}) + self.complete_form(workflow,task,{'GetNames_CurrentVar':{'Name': 'Peter Norvig', 'Nickname': 'Pete'}}) task = self.get_workflow_api(workflow).next_task self.assertEqual(task.multi_instance_type,'looping') self.assertEqual(2, task.multi_instance_index) self.complete_form(workflow, task, - {'GetNames_MICurrentVar':{'Name': 'Stuart Russell', 'Nickname': 'Stu'}}, + {'GetNames_CurrentVar':{'Name': 'Stuart Russell', 'Nickname': 'Stu'}}, terminate_loop=True) task = self.get_workflow_api(workflow).next_task self.assertEqual(task.name,'Event_End') self.assertEqual(workflow.completed_tasks,workflow.total_tasks) - self.assertEqual(task.data, {'GetNames_MICurrentVar': 2, - 'GetNames_MIData': {'1': {'Name': 'Peter Norvig', + self.assertEqual(task.data, {'GetNames_CurrentVar': 2, + 'GetNames': {'1': {'Name': 'Peter Norvig', 'Nickname': 'Pete'}, '2': {'Name': 'Stuart Russell', 'Nickname': 'Stu'}}}) From e6d74aaa1afebb5f7718243fb42bc11efff899f8 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Thu, 18 Jun 2020 12:53:50 -0600 Subject: [PATCH 020/101] Removing extra index when parsing users info --- crc/api/tools.py | 2 +- crc/scripts/email.py | 17 +++++++++++------ tests/data/random_fact/random_fact.bpmn | 6 +++--- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/crc/api/tools.py b/crc/api/tools.py index d140e962..fa969a1e 100644 --- a/crc/api/tools.py +++ b/crc/api/tools.py @@ -14,7 +14,7 @@ from crc.services.mails import send_test_email def render_markdown(data, template): """ - Provides a quick way to very that a Jinja markdown template will work properly on a given json + Provides a quick way to very that a Jinja markdown template will work properly on a given json data structure. Useful for folks that are building these markdown templates. """ try: diff --git a/crc/scripts/email.py b/crc/scripts/email.py index 01def412..c94a98a3 100644 --- a/crc/scripts/email.py +++ b/crc/scripts/email.py @@ -29,8 +29,8 @@ Email Subject ApprvlApprvr1 PIComputingID def do_task(self, task, *args, **kwargs): args = [arg for arg in args if type(arg) == str] - subject, subject_index = self.get_subject(task, args) - recipients, display_keys = self.get_users_info(task, args, subject_index) + subject = self.get_subject(task, args) + recipients, display_keys = self.get_users_info(task, args) content, content_html = self.get_content(task, display_keys) if recipients: send_mail( @@ -41,7 +41,7 @@ Email Subject ApprvlApprvr1 PIComputingID content_html=content_html ) - def get_users_info(self, task, args, subject_index): + def get_users_info(self, task, args): if len(args) < 1: raise ApiError(code="missing_argument", message="Email script requires at least one argument. The " @@ -49,8 +49,13 @@ Email Subject ApprvlApprvr1 PIComputingID "id to process. Multiple arguments are accepted.") emails = [] display_keys = {} - for arg in args[subject_index+1:]: - uid = task.workflow.script_engine.evaluate_expression(task, arg) + for arg in args: + try: + uid = task.workflow.script_engine.evaluate_expression(task, arg) + except Exception as e: + app.logger.error(f'Workflow engines could not parse {arg}') + app.logger.error(str(e)) + continue user_info = LdapService.user_info(uid) email = user_info.email_address emails.append(user_info.email_address) @@ -90,7 +95,7 @@ Email Subject ApprvlApprvr1 PIComputingID "ids to process. This must point to an array or a string, but " "it currently points to a %s " % subject.__class__.__name__) - return subject, subject_index + return subject def get_content(self, task, display_keys): content = task.task_spec.documentation diff --git a/tests/data/random_fact/random_fact.bpmn b/tests/data/random_fact/random_fact.bpmn index 628f1bd4..fc5e41bb 100644 --- a/tests/data/random_fact/random_fact.bpmn +++ b/tests/data/random_fact/random_fact.bpmn @@ -175,9 +175,6 @@ Your random fact is: - - - @@ -187,6 +184,9 @@ Your random fact is: + + + From 6aec15cc7c2642ab5fca22de1e0633278e1b4c4b Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Fri, 19 Jun 2020 08:22:53 -0400 Subject: [PATCH 021/101] Shifting to a different model, where the TaskEvents store ONLY the form data submitted for that task. In order to allow proper deletion of tasks, we no longer merge data returned from the front end, we set it directly as the task_data. When returning data to the front end, we take any previous form submission and merge it into the current task data, allowing users to keep their previous submissions. There is now an "extract_form_data" method that does it's best job to calculate what form data might have changed from the front end. --- Pipfile.lock | 58 ++++++++++++++++---------- crc/api/admin.py | 4 +- crc/api/workflow.py | 6 +-- crc/models/stats.py | 2 +- crc/services/workflow_service.py | 62 ++++++++++++++-------------- migrations/versions/de30304ff5e6_.py | 30 ++++++++++++++ tests/test_tasks_api.py | 13 +++--- tests/test_workflow_service.py | 15 +++---- 8 files changed, 116 insertions(+), 74 deletions(-) create mode 100644 migrations/versions/de30304ff5e6_.py diff --git a/Pipfile.lock b/Pipfile.lock index 2f99c84f..8cc805d0 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "faaf0e1f31f4bf99df366e52df20bb148a05996a0e6467767660665c514af2d7" + "sha256": "78a8da35dec2fb58b02a58afc8ffabe8b1c22bec8f054295e8b1ba3b4a6f4ec0" }, "pipfile-spec": 6, "requires": { @@ -261,6 +261,13 @@ "index": "pypi", "version": "==1.1.2" }, + "flask-admin": { + "hashes": [ + "sha256:68c761d8582d59b1f7702013e944a7ad11d7659a72f3006b89b68b0bd8df61b8" + ], + "index": "pypi", + "version": "==1.5.6" + }, "flask-bcrypt": { "hashes": [ "sha256:d71c8585b2ee1c62024392ebdbc447438564e2c8c02b4e57b56a4cafd8d13c5f" @@ -558,25 +565,25 @@ }, "pandas": { "hashes": [ - "sha256:034185bb615dc96d08fa13aacba8862949db19d5e7804d6ee242d086f07bcc46", - "sha256:0c9b7f1933e3226cc16129cf2093338d63ace5c85db7c9588e3e1ac5c1937ad5", - "sha256:1f6fcf0404626ca0475715da045a878c7062ed39bc859afc4ccf0ba0a586a0aa", - "sha256:1fc963ba33c299973e92d45466e576d11f28611f3549469aec4a35658ef9f4cc", - "sha256:29b4cfee5df2bc885607b8f016e901e63df7ffc8f00209000471778f46cc6678", - "sha256:2a8b6c28607e3f3c344fe3e9b3cd76d2bf9f59bc8c0f2e582e3728b80e1786dc", - "sha256:2bc2ff52091a6ac481cc75d514f06227dc1b10887df1eb72d535475e7b825e31", - "sha256:415e4d52fcfd68c3d8f1851cef4d947399232741cc994c8f6aa5e6a9f2e4b1d8", - "sha256:519678882fd0587410ece91e3ff7f73ad6ded60f6fcb8aa7bcc85c1dc20ecac6", - "sha256:51e0abe6e9f5096d246232b461649b0aa627f46de8f6344597ca908f2240cbaa", - "sha256:698e26372dba93f3aeb09cd7da2bb6dd6ade248338cfe423792c07116297f8f4", - "sha256:83af85c8e539a7876d23b78433d90f6a0e8aa913e37320785cf3888c946ee874", - "sha256:982cda36d1773076a415ec62766b3c0a21cdbae84525135bdb8f460c489bb5dd", - "sha256:a647e44ba1b3344ebc5991c8aafeb7cca2b930010923657a273b41d86ae225c4", - "sha256:b35d625282baa7b51e82e52622c300a1ca9f786711b2af7cbe64f1e6831f4126", - "sha256:bab51855f8b318ef39c2af2c11095f45a10b74cbab4e3c8199efcc5af314c648" + "sha256:02f1e8f71cd994ed7fcb9a35b6ddddeb4314822a0e09a9c5b2d278f8cb5d4096", + "sha256:13f75fb18486759da3ff40f5345d9dd20e7d78f2a39c5884d013456cec9876f0", + "sha256:35b670b0abcfed7cad76f2834041dcf7ae47fd9b22b63622d67cdc933d79f453", + "sha256:4c73f373b0800eb3062ffd13d4a7a2a6d522792fa6eb204d67a4fad0a40f03dc", + "sha256:5759edf0b686b6f25a5d4a447ea588983a33afc8a0081a0954184a4a87fd0dd7", + "sha256:5a7cf6044467c1356b2b49ef69e50bf4d231e773c3ca0558807cdba56b76820b", + "sha256:69c5d920a0b2a9838e677f78f4dde506b95ea8e4d30da25859db6469ded84fa8", + "sha256:8778a5cc5a8437a561e3276b85367412e10ae9fff07db1eed986e427d9a674f8", + "sha256:9871ef5ee17f388f1cb35f76dc6106d40cb8165c562d573470672f4cdefa59ef", + "sha256:9c31d52f1a7dd2bb4681d9f62646c7aa554f19e8e9addc17e8b1b20011d7522d", + "sha256:ab8173a8efe5418bbe50e43f321994ac6673afc5c7c4839014cf6401bbdd0705", + "sha256:ae961f1f0e270f1e4e2273f6a539b2ea33248e0e3a11ffb479d757918a5e03a9", + "sha256:b3c4f93fcb6e97d993bf87cdd917883b7dab7d20c627699f360a8fb49e9e0b91", + "sha256:c9410ce8a3dee77653bc0684cfa1535a7f9c291663bd7ad79e39f5ab58f67ab3", + "sha256:f69e0f7b7c09f1f612b1f8f59e2df72faa8a6b41c5a436dde5b615aaf948f107", + "sha256:faa42a78d1350b02a7d2f0dbe3c80791cf785663d6997891549d0f86dc49125e" ], "index": "pypi", - "version": "==1.0.4" + "version": "==1.0.5" }, "psycopg2-binary": { "hashes": [ @@ -711,11 +718,11 @@ }, "requests": { "hashes": [ - "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", - "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" + "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", + "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" ], "index": "pypi", - "version": "==2.23.0" + "version": "==2.24.0" }, "sentry-sdk": { "extras": [ @@ -802,7 +809,7 @@ "spiffworkflow": { "editable": true, "git": "https://github.com/sartography/SpiffWorkflow.git", - "ref": "b8a064a0bb76c705a1be04ee9bb8ac7beee56eb0" + "ref": "5450dc0463a95811d386b7de063d950bf6179d2b" }, "sqlalchemy": { "hashes": [ @@ -890,6 +897,13 @@ "index": "pypi", "version": "==1.0.1" }, + "wtforms": { + "hashes": [ + "sha256:6ff8635f4caeed9f38641d48cfe019d0d3896f41910ab04494143fc027866e1b", + "sha256:861a13b3ae521d6700dac3b2771970bd354a63ba7043ecc3a82b5288596a1972" + ], + "version": "==2.3.1" + }, "xlrd": { "hashes": [ "sha256:546eb36cee8db40c3eaa46c351e67ffee6eeb5fa2650b71bc4c758a29a1b29b2", diff --git a/crc/api/admin.py b/crc/api/admin.py index 6a27b6da..37532c38 100644 --- a/crc/api/admin.py +++ b/crc/api/admin.py @@ -57,9 +57,9 @@ def json_formatter(view, context, model, name): class TaskEventView(AdminModelView): column_filters = ['workflow_id', 'action'] - column_list = ['study_id', 'user_id', 'workflow_id', 'action', 'task_title', 'task_data', 'date'] + column_list = ['study_id', 'user_id', 'workflow_id', 'action', 'task_title', 'form_data', 'date'] column_formatters = { - 'task_data': json_formatter, + 'form_data': json_formatter, } admin = Admin(app) diff --git a/crc/api/workflow.py b/crc/api/workflow.py index 14c40df5..9e1dffc2 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -145,14 +145,14 @@ def update_task(workflow_id, task_id, body): if spiff_task.state != spiff_task.READY: raise ApiError("invalid_state", "You may not update a task unless it is in the READY state. " "Consider calling a token reset to make this task Ready.") - spiff_task.update_data(body) + if body: # IF and only if we get the body back, update the task data with the content. + spiff_task.data = body # Accept the data from the front end as complete. Do not merge it in, as then it is impossible to remove items. processor.complete_task(spiff_task) processor.do_engine_steps() processor.save() WorkflowService.log_task_action(user_uid, workflow_model, spiff_task, WorkflowService.TASK_ACTION_COMPLETE, - version=processor.get_version_string(), - updated_data=spiff_task.data) + version=processor.get_version_string()) workflow_api_model = WorkflowService.processor_to_workflow_api(processor) return WorkflowApiSchema().dump(workflow_api_model) diff --git a/crc/models/stats.py b/crc/models/stats.py index 8912b1d1..0a2e69b7 100644 --- a/crc/models/stats.py +++ b/crc/models/stats.py @@ -17,7 +17,7 @@ class TaskEventModel(db.Model): task_title = db.Column(db.String) task_type = db.Column(db.String) task_state = db.Column(db.String) - task_data = db.Column(db.JSON) + form_data = db.Column(db.JSON) # And form data submitted when the task was completed. mi_type = db.Column(db.String) mi_count = db.Column(db.Integer) mi_index = db.Column(db.Integer) diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 3b064954..2ce7b078 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -6,6 +6,7 @@ import random import jinja2 from SpiffWorkflow import Task as SpiffTask, WorkflowException from SpiffWorkflow.bpmn.specs.ManualTask import ManualTask +from SpiffWorkflow.bpmn.specs.MultiInstanceTask import MultiInstanceTask from SpiffWorkflow.bpmn.specs.ScriptTask import ScriptTask from SpiffWorkflow.bpmn.specs.UserTask import UserTask from SpiffWorkflow.dmn.specs.BusinessRuleTask import BusinessRuleTask @@ -232,23 +233,25 @@ class WorkflowService(object): # This may or may not work, sometimes there is no next task to complete. next_task = processor.next_task() if next_task: + previous_form_data = WorkflowService.get_previously_submitted_data(processor.workflow_model.id, next_task) + DeepMerge.merge(next_task.data, previous_form_data) workflow_api.next_task = WorkflowService.spiff_task_to_api_task(next_task, add_docs_and_forms=True) return workflow_api @staticmethod def get_previously_submitted_data(workflow_id, task): - """ If the user has completed this task previously, find that data in the task events table, and return it.""" + """ If the user has completed this task previously, find the form data for the last submission.""" latest_event = db.session.query(TaskEventModel) \ .filter_by(workflow_id=workflow_id) \ .filter_by(task_name=task.task_spec.name) \ .filter_by(action=WorkflowService.TASK_ACTION_COMPLETE) \ .order_by(TaskEventModel.date.desc()).first() if latest_event: - if latest_event.task_data is not None: - return latest_event.task_data + if latest_event.form_data is not None: + return latest_event.form_data else: - app.logger.error("missing_task_data", "We have lost data for workflow %i, task %s, it is not " + app.logger.error("missing_form_dat", "We have lost data for workflow %i, task %s, it is not " "in the task event model, " "and it should be." % (workflow_id, task.task_spec.name)) return {} @@ -387,9 +390,9 @@ class WorkflowService(object): field.options.append({"id": d.value, "name": d.label}) @staticmethod - def log_task_action(user_uid, workflow_model, spiff_task, action, - version, updated_data=None): + def log_task_action(user_uid, workflow_model, spiff_task, action, version): task = WorkflowService.spiff_task_to_api_task(spiff_task) + form_data = WorkflowService.extract_form_data(spiff_task.data, spiff_task) task_event = TaskEventModel( study_id=workflow_model.study_id, user_uid=user_uid, @@ -402,7 +405,7 @@ class WorkflowService(object): task_title=task.title, task_type=str(task.type), task_state=task.state, - task_data=updated_data, + form_data=form_data, 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. @@ -436,43 +439,40 @@ class WorkflowService(object): # added in subsequent tasks, just looking at form data, will not track the automated # task data additions, hopefully this doesn't hang us. for log in task_logs: - if log.task_data is not None: # Only do this if the task event does not have data populated in it. - continue +# if log.task_data is not None: # Only do this if the task event does not have data populated in it. +# continue data = copy.deepcopy(latest_data) # Or you end up with insane crazy issues. # In the simple case of RRT, there is exactly one task for the given task_spec task = processor.bpmn_workflow.get_tasks_from_spec_name(log.task_name)[0] - data = WorkflowService.__remove_data_added_by_children(data, task.children[0]) - log.task_data = data + data = WorkflowService.extract_form_data(data, task) + log.form_data = data db.session.add(log) db.session.commit() @staticmethod - def __remove_data_added_by_children(latest_data, child_task): + def extract_form_data(latest_data, task): """Removes data from latest_data that would be added by the child task or any of it's children.""" - if hasattr(child_task.task_spec, 'form'): - for field in child_task.task_spec.form.fields: - latest_data.pop(field.id, None) + data = {} + + if hasattr(task.task_spec, 'form'): + for field in task.task_spec.form.fields: if field.has_property(Task.PROP_OPTIONS_READ_ONLY) and \ field.get_property(Task.PROP_OPTIONS_READ_ONLY).lower().strip() == "true": - continue # Don't pop off read only fields. - if field.has_property(Task.PROP_OPTIONS_REPEAT): + continue # Don't add read-only data + elif field.has_property(Task.PROP_OPTIONS_REPEAT): group = field.get_property(Task.PROP_OPTIONS_REPEAT) - group_data = [] if group in latest_data: - for item in latest_data[group]: - item.pop(field.id, None) - if item: - group_data.append(item) - latest_data[group] = group_data - if not latest_data[group]: - latest_data.pop(group, None) - if isinstance(child_task.task_spec, BusinessRuleTask): - for output in child_task.task_spec.dmnEngine.decisionTable.outputs: - latest_data.pop(output.name, None) - for child in child_task.children: - latest_data = WorkflowService.__remove_data_added_by_children(latest_data, child) - return latest_data + data[group] = latest_data[group] + elif isinstance(task.task_spec, MultiInstanceTask): + group = task.task_spec.elementVar + if group in latest_data: + data[group] = latest_data[group] + else: + if field.id in latest_data: + data[field.id] = latest_data[field.id] + + return data diff --git a/migrations/versions/de30304ff5e6_.py b/migrations/versions/de30304ff5e6_.py new file mode 100644 index 00000000..46a43f18 --- /dev/null +++ b/migrations/versions/de30304ff5e6_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: de30304ff5e6 +Revises: 1fdd1bdb600e +Create Date: 2020-06-18 16:19:11.133665 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'de30304ff5e6' +down_revision = '1fdd1bdb600e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('task_event', sa.Column('form_data', sa.JSON(), nullable=True)) + op.drop_column('task_event', 'task_data') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('task_event', sa.Column('task_data', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True)) + op.drop_column('task_event', 'form_data') + # ### end Alembic commands ### diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index b51bca99..1b35434c 100644 --- a/tests/test_tasks_api.py +++ b/tests/test_tasks_api.py @@ -77,9 +77,8 @@ class TestTasksApi(BaseTest): self.assertEquals(task_in.process_name, event.process_name) self.assertIsNotNone(event.date) - # Assure that the data provided occurs in the task data log. - for key in dict_data.keys(): - self.assertIn(key, event.task_data) + # Assure that there is data in the form_data + self.assertIsNotNone(event.form_data) workflow = WorkflowApiSchema().load(json_data) return workflow @@ -372,13 +371,13 @@ class TestTasksApi(BaseTest): self.assertEqual("UserTask", task.type) self.assertEqual("Activity_A", task.name) self.assertEqual("My Sub Process", task.process_name) - workflow_api = self.complete_form(workflow, task, {"name": "Dan"}) + workflow_api = self.complete_form(workflow, task, {"FieldA": "Dan"}) task = workflow_api.next_task self.assertIsNotNone(task) self.assertEqual("Activity_B", task.name) self.assertEqual("Sub Workflow Example", task.process_name) - workflow_api = self.complete_form(workflow, task, {"name": "Dan"}) + workflow_api = self.complete_form(workflow, task, {"FieldB": "Dan"}) self.assertEqual(WorkflowStatus.complete, workflow_api.status) def test_update_task_resets_token(self): @@ -446,7 +445,9 @@ class TestTasksApi(BaseTest): for i in random.sample(range(9), 9): task = TaskSchema().load(ready_items[i]['task']) - self.complete_form(workflow, task, {"investigator":{"email": "dhf8r@virginia.edu"}}) + data = workflow_api.next_task.data + data['investigator']['email'] = "dhf8r@virginia.edu" + self.complete_form(workflow, task, data) #tasks = self.get_workflow_api(workflow).user_tasks workflow = self.get_workflow_api(workflow) diff --git a/tests/test_workflow_service.py b/tests/test_workflow_service.py index 6f0fa5e3..6b1b5c58 100644 --- a/tests/test_workflow_service.py +++ b/tests/test_workflow_service.py @@ -100,11 +100,10 @@ class TestWorkflowService(BaseTest): task_api = WorkflowService.spiff_task_to_api_task(task, add_docs_and_forms=True) WorkflowService.populate_form_with_random_data(task, task_api, False) task.complete() - # create the task events with no task_data in them. + # create the task events WorkflowService.log_task_action('dhf8r', workflow, task, WorkflowService.TASK_ACTION_COMPLETE, - version=processor.get_version_string(), - updated_data=None) + version=processor.get_version_string()) processor.save() db.session.commit() @@ -119,19 +118,17 @@ class TestWorkflowService(BaseTest): self.assertEqual(17, len(task_logs)) for log in task_logs: task = processor.bpmn_workflow.get_tasks_from_spec_name(log.task_name)[0] - self.assertIsNotNone(log.task_data) + self.assertIsNotNone(log.form_data) # Each task should have the data in the form for that task in the task event. if hasattr(task.task_spec, 'form'): for field in task.task_spec.form.fields: if field.has_property(Task.PROP_OPTIONS_REPEAT): - self.assertIn(field.get_property(Task.PROP_OPTIONS_REPEAT), log.task_data) + self.assertIn(field.get_property(Task.PROP_OPTIONS_REPEAT), log.form_data) else: - self.assertIn(field.id, log.task_data) + self.assertIn(field.id, log.form_data) # Some spot checks: # The first task should be empty, with all the data removed. - self.assertEqual({}, task_logs[0].task_data) + self.assertEqual({}, task_logs[0].form_data) - # The last task should have all the data. - self.assertDictEqual(processor.bpmn_workflow.last_task.data, task_logs[16].task_data) From 9d1c495c905f32748702f3b6958297a306b6f404 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Fri, 19 Jun 2020 08:44:02 -0400 Subject: [PATCH 022/101] Fix RRT Data added to docker run --- docker_run.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docker_run.sh b/docker_run.sh index 6bc3c90b..4cd2cbc4 100755 --- a/docker_run.sh +++ b/docker_run.sh @@ -28,3 +28,8 @@ if [ "$APPLICATION_ROOT" = "/" ]; then else pipenv run gunicorn -e SCRIPT_NAME="$APPLICATION_ROOT" --bind 0.0.0.0:$PORT0 wsgi:app fi + +if [ "$FIX_RRT_DATA" = "true" ]; then + echo 'Fixing RRT data...' + pipenv run flask rrt-data-fix +fi \ No newline at end of file From 8384497600e882ce89fb95962b514d309f7f6ba0 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Fri, 19 Jun 2020 10:07:10 -0400 Subject: [PATCH 023/101] Move the fix rrt data to a place where it will get picked up. --- docker_run.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docker_run.sh b/docker_run.sh index 4cd2cbc4..8ad66274 100755 --- a/docker_run.sh +++ b/docker_run.sh @@ -23,13 +23,16 @@ if [ "$RESET_DB_RRT" = "true" ]; then pipenv run flask load-example-rrt-data fi +if [ "$FIX_RRT_DATA" = "true" ]; then + echo 'Fixing RRT data...' + pipenv run flask rrt-data-fix +fi + + +# THIS MUST BE THE LAST COMMAND! if [ "$APPLICATION_ROOT" = "/" ]; then pipenv run gunicorn --bind 0.0.0.0:$PORT0 wsgi:app else pipenv run gunicorn -e SCRIPT_NAME="$APPLICATION_ROOT" --bind 0.0.0.0:$PORT0 wsgi:app fi -if [ "$FIX_RRT_DATA" = "true" ]; then - echo 'Fixing RRT data...' - pipenv run flask rrt-data-fix -fi \ No newline at end of file From b8d60ca94467f03e5c28cb73be75fa1f71f5cf82 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 22 Jun 2020 07:14:00 -0600 Subject: [PATCH 024/101] Spreadsheet generation --- crc/services/approval_service.py | 39 ++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index 1f6f56b3..81608a34 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -1,6 +1,6 @@ -from datetime import datetime +from datetime import datetime, timedelta -from sqlalchemy import desc +from sqlalchemy import desc, func from crc import app, db, session from crc.api.common import ApiError @@ -109,6 +109,41 @@ class ApprovalService(object): db_approvals = query.all() return [Approval.from_model(approval_model) for approval_model in db_approvals] + @staticmethod + def get_health_attesting_for_today(): + """Return a CSV with prepared information related to approvals + created today""" + # import pdb; pdb.set_trace() + today = datetime.now() - timedelta(days=3) + today = today.date() + approvals = session.query(ApprovalModel).filter( + # func.date(ApprovalModel.date_created)==today, + ApprovalModel.status==ApprovalStatus.APPROVED.value + ) + + health_attesting_rows = [ + 'university_computing_id', + 'last_name', + 'first_name', + 'department', + 'job_title', + 'supervisor_university_computing_id' + ] + for approval in approvals: + pi_info = LdapService.user_info(approval.study.primary_investigator_id) + approver_info = LdapService.user_info(approval.approver_uid) + first_name = pi_info.given_name + last_name = pi_info.display_name.replace(first_name, '').strip() + health_attesting_rows.append([ + pi_info.uid, + last_name, + first_name, + '', + 'Academic Researcher', + approver_info.uid + ]) + + return health_attesting_rows @staticmethod def update_approval(approval_id, approver_uid): From e5541e4950416ebb9746a893c5386b3219f5826b Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 22 Jun 2020 09:24:58 -0600 Subject: [PATCH 025/101] Enable CSV download --- crc/api.yml | 22 ++++++++++++++++ crc/api/approval.py | 15 ++++++++++- crc/services/approval_service.py | 44 +++++++++++++++++--------------- 3 files changed, 60 insertions(+), 21 deletions(-) diff --git a/crc/api.yml b/crc/api.yml index 64f6086a..71710881 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -917,6 +917,28 @@ paths: application/json: schema: type: object + /health_attesting: + parameters: + - name: all_approvals + in: query + required: false + description: If set to false, returns just approvals for today. + schema: + type: string + get: + operationId: crc.api.approval.get_health_attesting_csv + summary: Returns a CSV file with health attesting records + tags: + - Approvals + responses: + '200': + description: A CSV file + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Approval" components: securitySchemes: jwt: diff --git a/crc/api/approval.py b/crc/api/approval.py index b3ee0fed..a44dfc5b 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -1,9 +1,11 @@ +import csv +import io import json import pickle from base64 import b64decode from datetime import datetime -from flask import g +from flask import g, make_response from crc import db, session from crc.api.common import ApiError @@ -88,6 +90,17 @@ def get_approvals_for_study(study_id=None): return results +def get_health_attesting_csv(all_approvals=True): + records = ApprovalService.get_health_attesting_records(all_approvals) + si = io.StringIO() + cw = csv.writer(si) + cw.writerows(records) + output = make_response(si.getvalue()) + output.headers["Content-Disposition"] = "attachment; filename=health_attesting.csv" + output.headers["Content-type"] = "text/csv" + return output + + # ----- Begin descent into madness ---- # def get_csv(): """A damn lie, it's a json file. A huge bit of a one-off for RRT, but 3 weeks of midnight work can convince a diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index 81608a34..f98733a5 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -110,24 +110,27 @@ class ApprovalService(object): return [Approval.from_model(approval_model) for approval_model in db_approvals] @staticmethod - def get_health_attesting_for_today(): - """Return a CSV with prepared information related to approvals - created today""" - # import pdb; pdb.set_trace() - today = datetime.now() - timedelta(days=3) - today = today.date() - approvals = session.query(ApprovalModel).filter( - # func.date(ApprovalModel.date_created)==today, - ApprovalModel.status==ApprovalStatus.APPROVED.value - ) + def get_health_attesting_records(all_approvals=True): + """Return a list with prepared information related to all approvals + approved or filtered by today """ + if all_approvals: + approvals = session.query(ApprovalModel).filter( + ApprovalModel.status==ApprovalStatus.APPROVED.value + ) + else: + today = datetime.now().date() + approvals = session.query(ApprovalModel).filter( + func.date(ApprovalModel.date_created)==today, + ApprovalModel.status==ApprovalStatus.APPROVED.value + ) health_attesting_rows = [ - 'university_computing_id', - 'last_name', - 'first_name', - 'department', - 'job_title', - 'supervisor_university_computing_id' + ['university_computing_id', + 'last_name', + 'first_name', + 'department', + 'job_title', + 'supervisor_university_computing_id'] ] for approval in approvals: pi_info = LdapService.user_info(approval.study.primary_investigator_id) @@ -147,13 +150,14 @@ class ApprovalService(object): @staticmethod def update_approval(approval_id, approver_uid): - """Update a specific approval""" + """Update a specific approval + NOTE: Actual update happens in the API layer, this + funtion is currently in charge of only sending + corresponding emails + """ db_approval = session.query(ApprovalModel).get(approval_id) status = db_approval.status if db_approval: - # db_approval.status = status - # session.add(db_approval) - # session.commit() if status == ApprovalStatus.APPROVED.value: # second_approval = ApprovalModel().query.filter_by( # study_id=db_approval.study_id, workflow_id=db_approval.workflow_id, From dc5ffd29d0eae051140f9dfcb0408b73e80955c8 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 22 Jun 2020 14:07:57 -0600 Subject: [PATCH 026/101] Refactoring shared code --- crc/api/approval.py | 65 +--------------- crc/services/approval_service.py | 123 ++++++++++++++++++++++++------- tests/test_approvals_service.py | 26 +++++++ tests/test_tasks_api.py | 32 ++++---- 4 files changed, 144 insertions(+), 102 deletions(-) diff --git a/crc/api/approval.py b/crc/api/approval.py index a44dfc5b..fd01e221 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -90,8 +90,8 @@ def get_approvals_for_study(study_id=None): return results -def get_health_attesting_csv(all_approvals=True): - records = ApprovalService.get_health_attesting_records(all_approvals) +def get_health_attesting_csv(): + records = ApprovalService.get_health_attesting_records() si = io.StringIO() cw = csv.writer(si) cw.writerows(records) @@ -105,67 +105,10 @@ def get_health_attesting_csv(all_approvals=True): def get_csv(): """A damn lie, it's a json file. A huge bit of a one-off for RRT, but 3 weeks of midnight work can convince a man to do just about anything""" - approvals = ApprovalService.get_all_approvals(include_cancelled=False) - output = [] - errors = [] - for approval in approvals: - try: - if approval.status != ApprovalStatus.APPROVED.value: - continue - for related_approval in approval.related_approvals: - if related_approval.status != ApprovalStatus.APPROVED.value: - continue - workflow = db.session.query(WorkflowModel).filter(WorkflowModel.id == approval.workflow_id).first() - data = json.loads(workflow.bpmn_workflow_json) - last_task = find_task(data['last_task']['__uuid__'], data['task_tree']) - personnel = extract_value(last_task, 'personnel') - training_val = extract_value(last_task, 'RequiredTraining') - pi_supervisor = extract_value(last_task, 'PISupervisor')['value'] - review_complete = 'AllRequiredTraining' in training_val - pi_uid = workflow.study.primary_investigator_id - pi_details = LdapService.user_info(pi_uid) - details = [] - details.append(pi_details) - for person in personnel: - uid = person['PersonnelComputingID']['value'] - details.append(LdapService.user_info(uid)) + content = ApprovalService.get_not_really_csv_content() - for person in details: - record = { - "study_id": approval.study_id, - "pi_uid": pi_details.uid, - "pi": pi_details.display_name, - "name": person.display_name, - "uid": person.uid, - "email": person.email_address, - "supervisor": "", - "review_complete": review_complete, - } - # We only know the PI's supervisor. - if person.uid == pi_details.uid: - record["supervisor"] = pi_supervisor + return content - output.append(record) - - except Exception as e: - errors.append("Error pulling data for workflow #%i: %s" % (approval.workflow_id, str(e))) - return {"results": output, "errors": errors } - - -def extract_value(task, key): - if key in task['data']: - return pickle.loads(b64decode(task['data'][key]['__bytes__'])) - else: - return "" - - -def find_task(uuid, task): - if task['id']['__uuid__'] == uuid: - return task - for child in task['children']: - task = find_task(uuid, child) - if task: - return task # ----- come back to the world of the living ---- # diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index f98733a5..cd3a6549 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -1,3 +1,6 @@ +import json +import pickle +from base64 import b64decode from datetime import datetime, timedelta from sqlalchemy import desc, func @@ -110,19 +113,51 @@ class ApprovalService(object): return [Approval.from_model(approval_model) for approval_model in db_approvals] @staticmethod - def get_health_attesting_records(all_approvals=True): - """Return a list with prepared information related to all approvals - approved or filtered by today """ - if all_approvals: - approvals = session.query(ApprovalModel).filter( - ApprovalModel.status==ApprovalStatus.APPROVED.value - ) - else: - today = datetime.now().date() - approvals = session.query(ApprovalModel).filter( - func.date(ApprovalModel.date_created)==today, - ApprovalModel.status==ApprovalStatus.APPROVED.value - ) + def get_approval_details(approval): + """Returns a list of packed approval details, obtained from + the task data sent during the workflow """ + def extract_value(task, key): + if key in task['data']: + return pickle.loads(b64decode(task['data'][key]['__bytes__'])) + else: + return "" + + def find_task(uuid, task): + if task['id']['__uuid__'] == uuid: + return task + for child in task['children']: + task = find_task(uuid, child) + if task: + return task + + if approval.status != ApprovalStatus.APPROVED.value: + return {} + for related_approval in approval.related_approvals: + if related_approval.status != ApprovalStatus.APPROVED.value: + continue + workflow = db.session.query(WorkflowModel).filter(WorkflowModel.id == approval.workflow_id).first() + data = json.loads(workflow.bpmn_workflow_json) + last_task = find_task(data['last_task']['__uuid__'], data['task_tree']) + personnel = extract_value(last_task, 'personnel') + training_val = extract_value(last_task, 'RequiredTraining') + pi_supervisor = extract_value(last_task, 'PISupervisor')['value'] + review_complete = 'AllRequiredTraining' in training_val + pi_uid = workflow.study.primary_investigator_id + pi_details = LdapService.user_info(pi_uid) + details = {'Supervisor': pi_supervisor} + details['person_details'] = [] + details['person_details'].append(pi_details) + for person in personnel: + uid = person['PersonnelComputingID']['value'] + details['person_details'].append(LdapService.user_info(uid)) + + return details + + @staticmethod + def get_health_attesting_records(): + """Return a list with prepared information related to all approvals """ + + approvals = ApprovalService.get_all_approvals(include_cancelled=False) health_attesting_rows = [ ['university_computing_id', @@ -132,22 +167,60 @@ class ApprovalService(object): 'job_title', 'supervisor_university_computing_id'] ] + for approval in approvals: - pi_info = LdapService.user_info(approval.study.primary_investigator_id) - approver_info = LdapService.user_info(approval.approver_uid) - first_name = pi_info.given_name - last_name = pi_info.display_name.replace(first_name, '').strip() - health_attesting_rows.append([ - pi_info.uid, - last_name, - first_name, - '', - 'Academic Researcher', - approver_info.uid - ]) + try: + details = ApprovalService.get_approval_details(approval) + if not details: + continue + + for person in details['person_details']: + first_name = person.given_name + last_name = person.display_name.replace(first_name, '').strip() + record = [ + person.uid, + last_name, + first_name, + '', + 'Academic Researcher', + details['Supervisor'] if person.uid == details['person_details'][0].uid else 'askresearch' + ] + + if record not in health_attesting_rows: + health_attesting_rows.append(record) + + except Exception as e: + app.logger.error("Error pulling data for workflow #%i: %s" % (approval.workflow_id, str(e))) return health_attesting_rows + @staticmethod + def get_not_really_csv_content(): + approvals = ApprovalService.get_all_approvals(include_cancelled=False) + output = [] + errors = [] + for approval in approvals: + try: + details = ApprovalService.get_approval_details(approval) + + for person in details['person_details']: + record = { + "study_id": approval.study_id, + "pi_uid": pi_details.uid, + "pi": pi_details.display_name, + "name": person.display_name, + "uid": person.uid, + "email": person.email_address, + "supervisor": details['Supervisor'] if person.uid == details['person_details'][0].uid else "", + "review_complete": review_complete, + } + + output.append(record) + + except Exception as e: + errors.append("Error pulling data for workflow #%i: %s" % (approval.workflow_id, str(e))) + return {"results": output, "errors": errors } + @staticmethod def update_approval(approval_id, approver_uid): """Update a specific approval diff --git a/tests/test_approvals_service.py b/tests/test_approvals_service.py index 26a26ef4..d8f8d503 100644 --- a/tests/test_approvals_service.py +++ b/tests/test_approvals_service.py @@ -57,6 +57,32 @@ class TestApprovalsService(BaseTest): self.assertEqual(1, models[0].version) self.assertEqual(2, models[1].version) + def test_get_health_attesting_records(self): + self.load_example_data() + self.create_reference_document() + workflow = self.create_workflow('empty_workflow') + FileService.add_workflow_file(workflow_id=workflow.id, + name="anything.png", content_type="text", + binary_data=b'5678', irb_doc_code="AD_CoCAppr") + + ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r") + records = ApprovalService.get_health_attesting_records() + + self.assertEqual(len(records), 1) + + def test_get_not_really_csv_content(self): + self.load_example_data() + self.create_reference_document() + workflow = self.create_workflow('empty_workflow') + FileService.add_workflow_file(workflow_id=workflow.id, + name="anything.png", content_type="text", + binary_data=b'5678', irb_doc_code="AD_CoCAppr") + + ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r") + records = ApprovalService.get_not_really_csv_content() + + self.assertEqual(len(records), 1) + def test_new_approval_sends_proper_emails(self): self.assertEqual(1, 1) diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index 1b35434c..7288b5e4 100644 --- a/tests/test_tasks_api.py +++ b/tests/test_tasks_api.py @@ -47,7 +47,7 @@ class TestTasksApi(BaseTest): # The total number of tasks may change over time, as users move through gateways # branches may be pruned. As we hit parallel Multi-Instance new tasks may be created... self.assertIsNotNone(workflow.total_tasks) - self.assertEquals(prev_completed_task_count + 1, workflow.completed_tasks) + self.assertEqual(prev_completed_task_count + 1, workflow.completed_tasks) # Assure a record exists in the Task Events task_events = session.query(TaskEventModel) \ .filter_by(workflow_id=workflow.id) \ @@ -56,25 +56,25 @@ class TestTasksApi(BaseTest): self.assertGreater(len(task_events), 0) event = task_events[0] self.assertIsNotNone(event.study_id) - self.assertEquals("dhf8r", event.user_uid) - self.assertEquals(workflow.id, event.workflow_id) - self.assertEquals(workflow.workflow_spec_id, event.workflow_spec_id) - self.assertEquals(workflow.spec_version, event.spec_version) - self.assertEquals(WorkflowService.TASK_ACTION_COMPLETE, event.action) - self.assertEquals(task_in.id, task_id) - self.assertEquals(task_in.name, event.task_name) - self.assertEquals(task_in.title, event.task_title) - self.assertEquals(task_in.type, event.task_type) - self.assertEquals("COMPLETED", event.task_state) + self.assertEqual("dhf8r", event.user_uid) + self.assertEqual(workflow.id, event.workflow_id) + self.assertEqual(workflow.workflow_spec_id, event.workflow_spec_id) + self.assertEqual(workflow.spec_version, event.spec_version) + self.assertEqual(WorkflowService.TASK_ACTION_COMPLETE, event.action) + self.assertEqual(task_in.id, task_id) + self.assertEqual(task_in.name, event.task_name) + self.assertEqual(task_in.title, event.task_title) + self.assertEqual(task_in.type, event.task_type) + self.assertEqual("COMPLETED", event.task_state) # 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) + self.assertEqual(task_in.multi_instance_type.value, event.mi_type) else: - self.assertEquals(task_in.multi_instance_type, event.mi_type) + self.assertEqual(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.assertEqual(task_in.multi_instance_count, event.mi_count) + self.assertEqual(task_in.multi_instance_index, event.mi_index) + self.assertEqual(task_in.process_name, event.process_name) self.assertIsNotNone(event.date) # Assure that there is data in the form_data From bb825f80971f6034d1b802502e8d7e021c13f7b5 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 22 Jun 2020 14:09:58 -0600 Subject: [PATCH 027/101] Dropping old parameter from endpoint --- crc/api.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/crc/api.yml b/crc/api.yml index 71710881..b60dcc23 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -918,13 +918,6 @@ paths: schema: type: object /health_attesting: - parameters: - - name: all_approvals - in: query - required: false - description: If set to false, returns just approvals for today. - schema: - type: string get: operationId: crc.api.approval.get_health_attesting_csv summary: Returns a CSV file with health attesting records From 91fe5f0cdd5190b89b25e2df444b783af0cc76a4 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 22 Jun 2020 14:22:56 -0600 Subject: [PATCH 028/101] Fixing broken test --- tests/test_approvals_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_approvals_service.py b/tests/test_approvals_service.py index d8f8d503..34871fec 100644 --- a/tests/test_approvals_service.py +++ b/tests/test_approvals_service.py @@ -81,7 +81,7 @@ class TestApprovalsService(BaseTest): ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r") records = ApprovalService.get_not_really_csv_content() - self.assertEqual(len(records), 1) + self.assertEqual(len(records), 2) def test_new_approval_sends_proper_emails(self): self.assertEqual(1, 1) From dd10e56d1a7a83cf507db863a0eeb5f326c216f8 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 22 Jun 2020 14:56:24 -0600 Subject: [PATCH 029/101] Adding forgotten variables to returned dict --- crc/services/approval_service.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index cd3a6549..eacac72c 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -144,7 +144,11 @@ class ApprovalService(object): review_complete = 'AllRequiredTraining' in training_val pi_uid = workflow.study.primary_investigator_id pi_details = LdapService.user_info(pi_uid) - details = {'Supervisor': pi_supervisor} + details = { + 'Supervisor': pi_supervisor, + 'PI_Details': pi_details, + 'Review': review_complete + } details['person_details'] = [] details['person_details'].append(pi_details) for person in personnel: @@ -206,13 +210,13 @@ class ApprovalService(object): for person in details['person_details']: record = { "study_id": approval.study_id, - "pi_uid": pi_details.uid, - "pi": pi_details.display_name, + "pi_uid": details['PI_Details'].uid, + "pi": details['PI_Details'].display_name, "name": person.display_name, "uid": person.uid, "email": person.email_address, "supervisor": details['Supervisor'] if person.uid == details['person_details'][0].uid else "", - "review_complete": review_complete, + "review_complete": details['Review'], } output.append(record) From a29b41048493f7c01ca8ddf8d1c176fea5972090 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Wed, 24 Jun 2020 21:47:15 -0600 Subject: [PATCH 030/101] Updating migrations --- .../versions/{839f6f255b81_.py => 5acd138e969c_.py} | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) rename migrations/versions/{839f6f255b81_.py => 5acd138e969c_.py} (86%) diff --git a/migrations/versions/839f6f255b81_.py b/migrations/versions/5acd138e969c_.py similarity index 86% rename from migrations/versions/839f6f255b81_.py rename to migrations/versions/5acd138e969c_.py index e5400627..22b6b79a 100644 --- a/migrations/versions/839f6f255b81_.py +++ b/migrations/versions/5acd138e969c_.py @@ -1,8 +1,8 @@ """empty message -Revision ID: 839f6f255b81 -Revises: 17597692d0b0 -Create Date: 2020-06-17 16:22:05.076206 +Revision ID: 5acd138e969c +Revises: de30304ff5e6 +Create Date: 2020-06-24 21:36:15.128632 """ from alembic import op @@ -10,8 +10,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '839f6f255b81' -down_revision = '17597692d0b0' +revision = '5acd138e969c' +down_revision = 'de30304ff5e6' branch_labels = None depends_on = None From a0d877e02f14dc815c3c1f64cadd592000826c9e Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Wed, 24 Jun 2020 22:23:31 -0600 Subject: [PATCH 031/101] Feedback from PR addressed --- crc/services/approval_service.py | 3 --- crc/services/mails.py | 12 ++++++------ tests/data/email/email.bpmn | 13 +++++++------ tests/emails/test_email_script.py | 29 ++++++++++++++++------------- 4 files changed, 29 insertions(+), 28 deletions(-) diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index dbeed829..754bd48d 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -132,7 +132,6 @@ class ApprovalService(object): mail_result = send_ramp_up_approved_email( 'askresearch@virginia.edu', [pi_user_info.email_address], - approval_id, f'{approver_info.display_name} - ({approver_info.uid})' ) if mail_result: @@ -145,7 +144,6 @@ class ApprovalService(object): mail_result = send_ramp_up_denied_email( 'askresearch@virginia.edu', [pi_user_info.email_address], - approval_id, f'{approver_info.display_name} - ({approver_info.uid})' ) if mail_result: @@ -161,7 +159,6 @@ class ApprovalService(object): mail_result = send_ramp_up_denied_email_to_approver( 'askresearch@virginia.edu', approver_email, - approval_id, f'{pi_user_info.display_name} - ({pi_user_info.uid})', f'{approver_info.display_name} - ({approver_info.uid})' ) diff --git a/crc/services/mails.py b/crc/services/mails.py index b9b18bd1..c4942a7d 100644 --- a/crc/services/mails.py +++ b/crc/services/mails.py @@ -41,7 +41,7 @@ def send_mail(subject, sender, recipients, content, content_html, study_id=None) except Exception as e: return str(e) -def send_ramp_up_submission_email(sender, recipients, approval_id, approver_1, approver_2=None): +def send_ramp_up_submission_email(sender, recipients, approver_1, approver_2=None): from crc import env subject = 'Research Ramp-up Plan Submitted' @@ -54,7 +54,7 @@ def send_ramp_up_submission_email(sender, recipients, approval_id, approver_1, a result = send_mail(subject, sender, recipients, content, content_html) return result -def send_ramp_up_approval_request_email(sender, recipients, approval_id, primary_investigator): +def send_ramp_up_approval_request_email(sender, recipients, primary_investigator): from crc import env subject = 'Research Ramp-up Plan Approval Request' @@ -67,7 +67,7 @@ def send_ramp_up_approval_request_email(sender, recipients, approval_id, primary result = send_mail(subject, sender, recipients, content, content_html) return result -def send_ramp_up_approval_request_first_review_email(sender, recipients, approval_id, primary_investigator): +def send_ramp_up_approval_request_first_review_email(sender, recipients, primary_investigator): from crc import env subject = 'Research Ramp-up Plan Approval Request' @@ -80,7 +80,7 @@ def send_ramp_up_approval_request_first_review_email(sender, recipients, approva result = send_mail(subject, sender, recipients, content, content_html) return result -def send_ramp_up_approved_email(sender, recipients, approval_id, approver_1, approver_2=None): +def send_ramp_up_approved_email(sender, recipients, approver_1, approver_2=None): from crc import env subject = 'Research Ramp-up Plan Approved' @@ -93,7 +93,7 @@ def send_ramp_up_approved_email(sender, recipients, approval_id, approver_1, app result = send_mail(subject, sender, recipients, content, content_html) return result -def send_ramp_up_denied_email(sender, recipients, approval_id, approver): +def send_ramp_up_denied_email(sender, recipients, approver): from crc import env subject = 'Research Ramp-up Plan Denied' @@ -106,7 +106,7 @@ def send_ramp_up_denied_email(sender, recipients, approval_id, approver): result = send_mail(subject, sender, recipients, content, content_html) return result -def send_ramp_up_denied_email_to_approver(sender, recipients, approval_id, primary_investigator, approver_2): +def send_ramp_up_denied_email_to_approver(sender, recipients, primary_investigator, approver_2): from crc import env subject = 'Research Ramp-up Plan Denied' diff --git a/tests/data/email/email.bpmn b/tests/data/email/email.bpmn index 54ec61a8..1b8d5252 100644 --- a/tests/data/email/email.bpmn +++ b/tests/data/email/email.bpmn @@ -13,6 +13,7 @@ --- +New request submitted by {{ PIComputingID }} Email content to be delivered to {{ ApprvlApprvr1 }} @@ -37,17 +38,17 @@ Email content to be delivered to {{ ApprvlApprvr1 }} - - - + + + - - - + + + diff --git a/tests/emails/test_email_script.py b/tests/emails/test_email_script.py index 79d5c6ee..12a00fac 100644 --- a/tests/emails/test_email_script.py +++ b/tests/emails/test_email_script.py @@ -1,36 +1,39 @@ from tests.base_test import BaseTest +from crc.models.email import EmailModel from crc.services.file_service import FileService from crc.scripts.email import Email from crc.services.workflow_processor import WorkflowProcessor from crc.api.common import ApiError -from crc import db +from crc import db, mail class TestEmailScript(BaseTest): def test_do_task(self): - # self.load_example_data() - # self.create_reference_document() workflow = self.create_workflow('email') - # processor = WorkflowProcessor(workflow) - # task = processor.next_task() - # TODO: Replace with proper `complete_form` method from test_tasks - # processor.complete_task(task) - # task = processor.next_task() task_data = { 'PIComputingID': 'dhf8r', 'ApprvlApprvr1': 'lb3dp' } task = self.get_workflow_api(workflow).next_task - self.complete_form(workflow, task, task_data) + with mail.record_messages() as outbox: + self.complete_form(workflow, task, task_data) - # script = Email() - # script.do_task(task, 'Subject', 'PIComputingID', 'ApprvlApprvr1') + self.assertEqual(len(outbox), 1) + self.assertEqual(outbox[0].subject, 'Camunda Email Subject') - # TODO: Add proper assertions - self.assertTrue(True) + # PI is present + self.assertIn(task_data['PIComputingID'], outbox[0].body) + self.assertIn(task_data['PIComputingID'], outbox[0].html) + + # Approver is present + self.assertIn(task_data['ApprvlApprvr1'], outbox[0].body) + self.assertIn(task_data['ApprvlApprvr1'], outbox[0].html) + + db_emails = EmailModel.query.count() + self.assertEqual(db_emails, 1) From 5d1ae402b68bd5332f07da4a0bdea5401525bf82 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Wed, 24 Jun 2020 22:43:44 -0600 Subject: [PATCH 032/101] Slight refactor on data passed to template rendering --- crc/scripts/email.py | 14 ++++++-------- crc/services/approval_service.py | 2 -- tests/emails/test_mails.py | 29 ++++++++--------------------- 3 files changed, 14 insertions(+), 31 deletions(-) diff --git a/crc/scripts/email.py b/crc/scripts/email.py index c94a98a3..d3a64725 100644 --- a/crc/scripts/email.py +++ b/crc/scripts/email.py @@ -25,13 +25,13 @@ Email Subject ApprvlApprvr1 PIComputingID def do_task_validate_only(self, task, *args, **kwargs): self.get_subject(task, args) self.get_users_info(task, args) - self.get_content(task, {}) + self.get_content(task) def do_task(self, task, *args, **kwargs): args = [arg for arg in args if type(arg) == str] subject = self.get_subject(task, args) - recipients, display_keys = self.get_users_info(task, args) - content, content_html = self.get_content(task, display_keys) + recipients = self.get_users_info(task, args) + content, content_html = self.get_content(task) if recipients: send_mail( subject=subject, @@ -48,7 +48,6 @@ Email Subject ApprvlApprvr1 PIComputingID "name of the variable in the task data that contains user" "id to process. Multiple arguments are accepted.") emails = [] - display_keys = {} for arg in args: try: uid = task.workflow.script_engine.evaluate_expression(task, arg) @@ -59,7 +58,6 @@ Email Subject ApprvlApprvr1 PIComputingID user_info = LdapService.user_info(uid) email = user_info.email_address emails.append(user_info.email_address) - display_keys[arg] = user_info.proper_name() if not isinstance(email, str): raise ApiError(code="invalid_argument", message="The Email script requires at least 1 UID argument. The " @@ -67,7 +65,7 @@ Email Subject ApprvlApprvr1 PIComputingID " user ids to process. This must point to an array or a string, but " "it currently points to a %s " % emails.__class__.__name__) - return emails, display_keys + return emails def get_subject(self, task, args): if len(args) < 1: @@ -97,9 +95,9 @@ Email Subject ApprvlApprvr1 PIComputingID return subject - def get_content(self, task, display_keys): + def get_content(self, task): content = task.task_spec.documentation template = Template(content) - rendered = template.render(display_keys) + rendered = template.render(task.data) rendered_markdown = markdown.markdown(rendered).replace('\n', '
') return rendered, rendered_markdown diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index 754bd48d..1f6f56b3 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -231,7 +231,6 @@ class ApprovalService(object): mail_result = send_ramp_up_submission_email( 'askresearch@virginia.edu', [pi_user_info.email_address], - model.id, f'{approver_info.display_name} - ({approver_info.uid})' ) if mail_result: @@ -242,7 +241,6 @@ class ApprovalService(object): mail_result = send_ramp_up_approval_request_first_review_email( 'askresearch@virginia.edu', approver_email, - model.id, f'{pi_user_info.display_name} - ({pi_user_info.uid})' ) if mail_result: diff --git a/tests/emails/test_mails.py b/tests/emails/test_mails.py index 5408e517..0710e02e 100644 --- a/tests/emails/test_mails.py +++ b/tests/emails/test_mails.py @@ -22,16 +22,6 @@ class TestMails(BaseTest): self.study = self.create_study() self.workflow = self.create_workflow('random_fact') - self.approval = ApprovalModel( - study=self.study, - workflow=self.workflow, - approver_uid='lb3dp', - status=ApprovalStatus.PENDING.value, - version=1 - ) - session.add(self.approval) - session.commit() - self.sender = 'sender@sartography.com' self.recipients = ['recipient@sartography.com'] self.primary_investigator = 'Dr. Bartlett' @@ -41,14 +31,13 @@ class TestMails(BaseTest): def test_send_ramp_up_submission_email(self): with mail.record_messages() as outbox: - send_ramp_up_submission_email(self.sender, self.recipients, self.approval.id, self.approver_1) + send_ramp_up_submission_email(self.sender, self.recipients, self.approver_1) self.assertEqual(len(outbox), 1) self.assertEqual(outbox[0].subject, 'Research Ramp-up Plan Submitted') self.assertIn(self.approver_1, outbox[0].body) self.assertIn(self.approver_1, outbox[0].html) - send_ramp_up_submission_email(self.sender, self.recipients, self.approval.id, - self.approver_1, self.approver_2) + send_ramp_up_submission_email(self.sender, self.recipients, self.approver_1, self.approver_2) self.assertEqual(len(outbox), 2) self.assertIn(self.approver_1, outbox[1].body) self.assertIn(self.approver_1, outbox[1].html) @@ -60,8 +49,7 @@ class TestMails(BaseTest): def test_send_ramp_up_approval_request_email(self): with mail.record_messages() as outbox: - send_ramp_up_approval_request_email(self.sender, self.recipients, self.approval.id, - self.primary_investigator) + send_ramp_up_approval_request_email(self.sender, self.recipients, self.primary_investigator) self.assertEqual(len(outbox), 1) self.assertEqual(outbox[0].subject, 'Research Ramp-up Plan Approval Request') @@ -74,7 +62,7 @@ class TestMails(BaseTest): def test_send_ramp_up_approval_request_first_review_email(self): with mail.record_messages() as outbox: send_ramp_up_approval_request_first_review_email( - self.sender, self.recipients, self.approval.id, self.primary_investigator + self.sender, self.recipients, self.primary_investigator ) self.assertEqual(len(outbox), 1) @@ -87,14 +75,13 @@ class TestMails(BaseTest): def test_send_ramp_up_approved_email(self): with mail.record_messages() as outbox: - send_ramp_up_approved_email(self.sender, self.recipients, self.approval.id, self.approver_1) + send_ramp_up_approved_email(self.sender, self.recipients, self.approver_1) self.assertEqual(len(outbox), 1) self.assertEqual(outbox[0].subject, 'Research Ramp-up Plan Approved') self.assertIn(self.approver_1, outbox[0].body) self.assertIn(self.approver_1, outbox[0].html) - send_ramp_up_approved_email(self.sender, self.recipients, self.approval.id, - self.approver_1, self.approver_2) + send_ramp_up_approved_email(self.sender, self.recipients, self.approver_1, self.approver_2) self.assertEqual(len(outbox), 2) self.assertIn(self.approver_1, outbox[1].body) self.assertIn(self.approver_1, outbox[1].html) @@ -106,7 +93,7 @@ class TestMails(BaseTest): def test_send_ramp_up_denied_email(self): with mail.record_messages() as outbox: - send_ramp_up_denied_email(self.sender, self.recipients, self.approval.id, self.approver_1) + send_ramp_up_denied_email(self.sender, self.recipients, self.approver_1) self.assertEqual(outbox[0].subject, 'Research Ramp-up Plan Denied') self.assertIn(self.approver_1, outbox[0].body) self.assertIn(self.approver_1, outbox[0].html) @@ -117,7 +104,7 @@ class TestMails(BaseTest): def test_send_send_ramp_up_denied_email_to_approver(self): with mail.record_messages() as outbox: send_ramp_up_denied_email_to_approver( - self.sender, self.recipients, self.approval.id, self.primary_investigator, self.approver_2 + self.sender, self.recipients, self.primary_investigator, self.approver_2 ) self.assertEqual(outbox[0].subject, 'Research Ramp-up Plan Denied') From 23941d73ad49a0e83eb0d59f11dbb1d50de44e85 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Thu, 25 Jun 2020 11:02:57 -0400 Subject: [PATCH 033/101] Fixes variable names. Updates Spiff to STG-26 branch. Updates package versions. --- Pipfile | 2 +- Pipfile.lock | 213 ++++++++++++++++++++++++++++++++++---------- crc/api/workflow.py | 6 +- src/spiffworkflow | 1 + 4 files changed, 173 insertions(+), 49 deletions(-) create mode 160000 src/spiffworkflow diff --git a/Pipfile b/Pipfile index e78257d8..96f8a748 100644 --- a/Pipfile +++ b/Pipfile @@ -26,7 +26,7 @@ pyjwt = "*" requests = "*" xlsxwriter = "*" webtest = "*" -spiffworkflow = {editable = true,git = "https://github.com/sartography/SpiffWorkflow.git",ref = "deploy"} +spiffworkflow = {editable = true,git = "https://github.com/sartography/SpiffWorkflow.git",ref = "STG-26"} alembic = "*" coverage = "*" sphinx = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 8cc805d0..baea6649 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "78a8da35dec2fb58b02a58afc8ffabe8b1c22bec8f054295e8b1ba3b4a6f4ec0" + "sha256": "8d6d99bcacef0b12f29f3c402f7980799812f645c576767b5477445a1fc03062" }, "pipfile-spec": 6, "requires": { @@ -35,6 +35,7 @@ "sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b", "sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.6.0" }, "aniso8601": { @@ -49,6 +50,7 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "babel": { @@ -56,6 +58,7 @@ "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.0" }, "bcrypt": { @@ -79,6 +82,7 @@ "sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7", "sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.1.7" }, "beautifulsoup4": { @@ -104,17 +108,18 @@ }, "celery": { "hashes": [ - "sha256:c3f4173f83ceb5a5c986c5fdaefb9456de3b0729a72a5776e46bd405fda7b647", - "sha256:d1762d6065522879f341c3d67c2b9fe4615eb79756d59acb1434601d4aca474b" + "sha256:ef17d7dffde7fc73ecab3a3b6389d93d3213bac53fa7f28e68e33647ad50b916", + "sha256:fd77e4248bb1b7af5f7922dd8e81156f540306e3a5c4b1c24167c1f5f06025da" ], - "version": "==4.4.5" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==4.4.6" }, "certifi": { "hashes": [ - "sha256:5ad7e9a056d25ffa5082862e36f119f7f7cec6457fa07ee2f8c339814b80c9b1", - "sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc" + "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", + "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" ], - "version": "==2020.4.5.2" + "version": "==2020.6.20" }, "cffi": { "hashes": [ @@ -161,6 +166,7 @@ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "clickclick": { @@ -182,6 +188,7 @@ "sha256:2ca44140ee259b5e3d8aaf47c79c36a7ab0d5e94d70bd4105c03ede7a20ea5a1", "sha256:cffc044844040c7ce04e9acd1838b5f2e5fa3170182f6fda4d2ea8b0099dbadd" ], + "markers": "python_version >= '3.6'", "version": "==5.0.0" }, "connexion": { @@ -237,6 +244,7 @@ "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==0.16" }, "docxtpl": { @@ -319,12 +327,14 @@ "sha256:0b656fbf87c5f24109d859bafa791d29751fabbda2302b606881ae5485b557a5", "sha256:fcfe6df52cd2ed8a63008ca36b86a51fa7a4b70cef1c39e5625f722fca32308e" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.3" }, "future": { "hashes": [ "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", "version": "==0.18.2" }, "gunicorn": { @@ -347,6 +357,7 @@ "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.9" }, "imagesize": { @@ -354,6 +365,7 @@ "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.2.0" }, "importlib-metadata": { @@ -361,7 +373,7 @@ "sha256:0505dd08068cfec00f53a74a0ad927676d7757da81b7436a6eefe4c7cf75c545", "sha256:15ec6c0fd909e893e3a08b3a7c76ecb149122fb14b7efe1199ddd4c7c57ea958" ], - "markers": "python_version < '3.8'", + "markers": "python_version < '3.8' and python_version < '3.8'", "version": "==1.6.1" }, "inflection": { @@ -369,6 +381,7 @@ "sha256:88b101b2668a1d81d6d72d4c2018e53bc6c7fc544c987849da1c7f77545c3bc9", "sha256:f576e85132d34f5bf7df5183c2c6f94cfb32e528f53065345cf71329ba0b8924" ], + "markers": "python_version >= '3.5'", "version": "==0.5.0" }, "itsdangerous": { @@ -376,6 +389,7 @@ "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.0" }, "jdcal": { @@ -390,6 +404,7 @@ "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.2" }, "jsonschema": { @@ -401,14 +416,19 @@ }, "kombu": { "hashes": [ - "sha256:437b9cdea193cc2ed0b8044c85fd0f126bb3615ca2f4d4a35b39de7cacfa3c1a", - "sha256:dc282bb277197d723bccda1a9ba30a27a28c9672d0ab93e9e51bb05a37bd29c3" + "sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a", + "sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74" ], - "version": "==4.6.10" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==4.6.11" }, "ldap3": { "hashes": [ "sha256:17f04298b70bf7ecaa5db8a7d8622b5a962ef7fc2b245b2eea705ac1c24338c0", + "sha256:298769ab0232b3a3efa1e84881096c24526fe37911c83a11285f222fe4975efd", + "sha256:4fd2db72d0412cc16ee86be01332095e86e361329c3579b314231eb2e56c7871", + "sha256:52ab557b3c4908db4a90bea16731aa714b1b54e039b54fd4c4b83994c6c48c0c", + "sha256:53aaae5bf14f3827c69600ddf4d61b88f49c055bb93060e9702c5bafd206c744", "sha256:81df4ac8b6df10fb1f05b17c18d0cb8c4c344d5a03083c382824960ed959cf5b" ], "index": "pypi", @@ -444,6 +464,7 @@ "sha256:f95d28193c3863132b1f55c1056036bf580b5a488d908f7d22a04ace8935a3a9", "sha256:fadd2a63a2bfd7fb604508e553d1cf68eca250b2fbdbd81213b5f6f2fbf23529" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.5.1" }, "mako": { @@ -451,6 +472,7 @@ "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27", "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.3" }, "markupsafe": { @@ -489,6 +511,7 @@ "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "marshmallow": { @@ -517,29 +540,35 @@ }, "numpy": { "hashes": [ - "sha256:0172304e7d8d40e9e49553901903dc5f5a49a703363ed756796f5808a06fc233", - "sha256:34e96e9dae65c4839bd80012023aadd6ee2ccb73ce7fdf3074c62f301e63120b", - "sha256:3676abe3d621fc467c4c1469ee11e395c82b2d6b5463a9454e37fe9da07cd0d7", - "sha256:3dd6823d3e04b5f223e3e265b4a1eae15f104f4366edd409e5a5e413a98f911f", - "sha256:4064f53d4cce69e9ac613256dc2162e56f20a4e2d2086b1956dd2fcf77b7fac5", - "sha256:4674f7d27a6c1c52a4d1aa5f0881f1eff840d2206989bae6acb1c7668c02ebfb", - "sha256:7d42ab8cedd175b5ebcb39b5208b25ba104842489ed59fbb29356f671ac93583", - "sha256:965df25449305092b23d5145b9bdaeb0149b6e41a77a7d728b1644b3c99277c1", - "sha256:9c9d6531bc1886454f44aa8f809268bc481295cf9740827254f53c30104f074a", - "sha256:a78e438db8ec26d5d9d0e584b27ef25c7afa5a182d1bf4d05e313d2d6d515271", - "sha256:a7acefddf994af1aeba05bbbafe4ba983a187079f125146dc5859e6d817df824", - "sha256:a87f59508c2b7ceb8631c20630118cc546f1f815e034193dc72390db038a5cb3", - "sha256:ac792b385d81151bae2a5a8adb2b88261ceb4976dbfaaad9ce3a200e036753dc", - "sha256:b03b2c0badeb606d1232e5f78852c102c0a7989d3a534b3129e7856a52f3d161", - "sha256:b39321f1a74d1f9183bf1638a745b4fd6fe80efbb1f6b32b932a588b4bc7695f", - "sha256:cae14a01a159b1ed91a324722d746523ec757357260c6804d11d6147a9e53e3f", - "sha256:cd49930af1d1e49a812d987c2620ee63965b619257bd76eaaa95870ca08837cf", - "sha256:e15b382603c58f24265c9c931c9a45eebf44fe2e6b4eaedbb0d025ab3255228b", - "sha256:e91d31b34fc7c2c8f756b4e902f901f856ae53a93399368d9a0dc7be17ed2ca0", - "sha256:ef627986941b5edd1ed74ba89ca43196ed197f1a206a3f18cc9faf2fb84fd675", - "sha256:f718a7949d1c4f622ff548c572e0c03440b49b9531ff00e4ed5738b459f011e8" + "sha256:13af0184177469192d80db9bd02619f6fa8b922f9f327e077d6f2a6acb1ce1c0", + "sha256:26a45798ca2a4e168d00de75d4a524abf5907949231512f372b217ede3429e98", + "sha256:26f509450db547e4dfa3ec739419b31edad646d21fb8d0ed0734188b35ff6b27", + "sha256:30a59fb41bb6b8c465ab50d60a1b298d1cd7b85274e71f38af5a75d6c475d2d2", + "sha256:33c623ef9ca5e19e05991f127c1be5aeb1ab5cdf30cb1c5cf3960752e58b599b", + "sha256:356f96c9fbec59974a592452ab6a036cd6f180822a60b529a975c9467fcd5f23", + "sha256:3c40c827d36c6d1c3cf413694d7dc843d50997ebffbc7c87d888a203ed6403a7", + "sha256:4d054f013a1983551254e2379385e359884e5af105e3efe00418977d02f634a7", + "sha256:63d971bb211ad3ca37b2adecdd5365f40f3b741a455beecba70fd0dde8b2a4cb", + "sha256:658624a11f6e1c252b2cd170d94bf28c8f9410acab9f2fd4369e11e1cd4e1aaf", + "sha256:76766cc80d6128750075378d3bb7812cf146415bd29b588616f72c943c00d598", + "sha256:7b57f26e5e6ee2f14f960db46bd58ffdca25ca06dd997729b1b179fddd35f5a3", + "sha256:7b852817800eb02e109ae4a9cef2beda8dd50d98b76b6cfb7b5c0099d27b52d4", + "sha256:8cde829f14bd38f6da7b2954be0f2837043e8b8d7a9110ec5e318ae6bf706610", + "sha256:a2e3a39f43f0ce95204beb8fe0831199542ccab1e0c6e486a0b4947256215632", + "sha256:a86c962e211f37edd61d6e11bb4df7eddc4a519a38a856e20a6498c319efa6b0", + "sha256:a8705c5073fe3fcc297fb8e0b31aa794e05af6a329e81b7ca4ffecab7f2b95ef", + "sha256:b6aaeadf1e4866ca0fdf7bb4eed25e521ae21a7947c59f78154b24fc7abbe1dd", + "sha256:be62aeff8f2f054eff7725f502f6228298891fd648dc2630e03e44bf63e8cee0", + "sha256:c2edbb783c841e36ca0fa159f0ae97a88ce8137fb3a6cd82eae77349ba4b607b", + "sha256:cbe326f6d364375a8e5a8ccb7e9cd73f4b2f6dc3b2ed205633a0db8243e2a96a", + "sha256:d34fbb98ad0d6b563b95de852a284074514331e6b9da0a9fc894fb1cdae7a79e", + "sha256:d97a86937cf9970453c3b62abb55a6475f173347b4cde7f8dcdb48c8e1b9952d", + "sha256:dd53d7c4a69e766e4900f29db5872f5824a06827d594427cf1a4aa542818b796", + "sha256:df1889701e2dfd8ba4dc9b1a010f0a60950077fb5242bb92c8b5c7f1a6f2668a", + "sha256:fa1fe75b4a9e18b66ae7f0b122543c42debcf800aaafa0212aaff3ad273c2596" ], - "version": "==1.18.5" + "markers": "python_version >= '3.6'", + "version": "==1.19.0" }, "openapi-spec-validator": { "hashes": [ @@ -551,16 +580,17 @@ }, "openpyxl": { "hashes": [ - "sha256:547a9fc6aafcf44abe358b89ed4438d077e9d92e4f182c87e2dc294186dc4b64" + "sha256:6e62f058d19b09b95d20ebfbfb04857ad08d0833190516c1660675f699c6186f" ], "index": "pypi", - "version": "==3.0.3" + "version": "==3.0.4" }, "packaging": { "hashes": [ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pandas": { @@ -623,8 +653,19 @@ }, "pyasn1": { "hashes": [ + "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", + "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", + "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", + "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba" + "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", + "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", + "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", + "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", + "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", + "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", + "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" ], "version": "==0.4.8" }, @@ -633,6 +674,7 @@ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.20" }, "pygments": { @@ -640,6 +682,7 @@ "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" ], + "markers": "python_version >= '3.5'", "version": "==2.6.1" }, "pyjwt": { @@ -655,6 +698,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", "version": "==2.4.7" }, "pyrsistent": { @@ -681,10 +725,67 @@ "hashes": [ "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", - "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8" + "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8", + "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77", + "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522" ], "version": "==1.0.4" }, + "python-levenshtein-wheels": { + "hashes": [ + "sha256:0065529c8aec4c044468286177761857d36981ba6f7fdb62d7d5f7ffd143de5d", + "sha256:016924a59d689f9f47d5f7b26b70f31e309255e8dd72602c91e93ceb752b9f92", + "sha256:089d046ea7727e583233c71fef1046663ed67b96967063ae8ddc9f551e86a4fc", + "sha256:09f9faaaa8f65726f91b44c11d3d622fee0f1780cfbe2bf3f410dd0e7345adcb", + "sha256:0aea217eab612acd45dcc3424a2e8dbd977cc309f80359d0c01971f1e65b9a9b", + "sha256:0beb91ad80b1573829066e5af36b80190c367be6e0a65292f073353b0388c7fc", + "sha256:0ec1bc73f5ed3a1a06e02d13bb3cd22a0b32ebf65a9667bbccba106bfa0546f1", + "sha256:0fa2ca69ef803bc6037a8c919e2e8a17b55e94c9c9ffcb4c21befbb15a1d0f40", + "sha256:11c77d0d74ab7f46f89a58ae9c2d67349ebc1ae3e18636627f9939d810167c31", + "sha256:19a68716a322486ddffc8bf7e5cf44a82f7700b05a10658e6e7fc5c7ae92b13d", + "sha256:19a95a01d28d63b042438ba860c4ace90362906a038fa77962ba33325d377d10", + "sha256:1a61f3a51e00a3608659bbaabb3f27af37c9dbe84d843369061a3e45cf0d5103", + "sha256:1c50aebebab403fb2dd415d70355446ac364dece502b0e2737a1a085bb9a4aa4", + "sha256:1d2390d04f9b673391e5ce1a0b054d0565f2e00ea5d1187a044221dc5c02c3e6", + "sha256:1e51cdc123625a28709662d24ea0cb4cf6f991845e6054d9f803c78da1d6b08f", + "sha256:1eca6dc97dfcf588f53281fe48a6d5c423d4e14bdab658a1aa6efd447acc64e0", + "sha256:1f0056d3216b0fe38f25c6f8ebc84bd9f6d34c55a7a9414341b674fb98961399", + "sha256:228b59460e9a786e498bdfc8011838b89c6054650b115c86c9c819a055a793b0", + "sha256:23020f9ff2cb3457a926dcc470b84f9bd5b7646bd8b8e06b915bdbbc905cb23f", + "sha256:2b7b7cf0f43b677f818aa9a610464abf06106c19a51b9ac35bd051a439f337a5", + "sha256:3b591c9a7e91480f0d7bf2041d325f578b9b9c2f2d593304377cb28862e7f9a2", + "sha256:3ca9c70411ab587d071c1d8fc8b69d0558be8e4aa920f2595e2cb5eb229ccc4c", + "sha256:3e6bcca97a7ff4e720352b57ddc26380c0583dcdd4b791acef7b574ad58468a7", + "sha256:3ed88f9e638da57647149115c34e0e120cae6f3d35eee7d77e22cc9c1d8eced3", + "sha256:445bf7941cb1fa05d6c2a4a502ad4868a5cacd92e8eb77b2bd008cdda9d37c55", + "sha256:4ba5e147d76d7ee884fd6eae461438b080bcc9f2c6eb9b576811e1bcfe8f808e", + "sha256:4bb128b719c30f3b9feacfe71a338ae07d39dbffc077139416f3535c89f12362", + "sha256:4e951907b9b5d40c9f1b611c8bdfe46ff8cf8371877cebbd589bf5840feab662", + "sha256:53c0c9964390368fd64460b690f168221c669766b193b7e80ae3950c2b9551f8", + "sha256:57c4edef81611098d37176278f2b6a3712bf864eed313496d7d80504805896d1", + "sha256:5b36e406937c6463d1c1ef3dd82d3f771d9d845f21351e8a026fe4dd398ea8d0", + "sha256:7d0821dab24b430dfdc2cba70a06e6d7a45cb839d0dd0e6db97bb99e23c3d884", + "sha256:7f7283dfe50eac8a8cd9b777de9eb50b1edf7dbb46fc7cc9d9b0050d0c135021", + "sha256:7f9759095b3fc825464a72b1cae95125e610eba3c70f91557754c32a0bf32ea2", + "sha256:8005a4df455569c0d490ddfd9e5a163f21293477fd0ed4ea9effdd723ddd8eaa", + "sha256:86e865f29ad3dc3bb4733e5247220173d90f05ac8d2ad18e9689a220f90de55f", + "sha256:98727050ba70eb8d318ec8a8203531c20119347fc8f281102b097326812742ab", + "sha256:ac9cdf044dcb9481c7da782db01b50c1f0e7cdd78c8507b963b6d072829c0263", + "sha256:acfad8ffed96891fe7c583d92717cd8ec0c03b59a954c389fd4e26a5cdeac610", + "sha256:ad15f25abff8220e556d64e2a27c646241b08f00faf1bc02313655696cd3edfa", + "sha256:b679f951f842c38665aa54bea4d7403099131f71fac6d8584f893a731fe1266d", + "sha256:b8c183dc4aa4e95dc5c373eedc3d205c176805835611fcfec5d9050736c695c4", + "sha256:c097a6829967c76526a037ed34500a028f78f0d765c8e3dbd1a7717afd09fb92", + "sha256:c2c76f483d05eddec60a5cd89e92385adef565a4f243b1d9a6abe2f6bd2a7c0a", + "sha256:c388baa3c04272a7c585d3da24030c142353eb26eb531dd2681502e6be7d7a26", + "sha256:cb0f2a711db665b5bf8697b5af3b9884bb1139385c5c12c2e472e4bbee62da99", + "sha256:cbac984d7b36e75b440d1c8ff9d3425d778364a0cbc23f8943383d4decd35d5e", + "sha256:f55adf069be2d655f8d668594fe1be1b84d9dc8106d380a9ada06f34941c33c8", + "sha256:f9084ed3b8997ad4353d124b903f2860a9695b9e080663276d9e58c32e293244", + "sha256:fb7df3504222fcb1fa593f76623abbb54d6019eec15aac5d05cd07ad90ac016c" + ], + "version": "==0.13.1" + }, "pytz": { "hashes": [ "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", @@ -740,6 +841,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==1.15.0" }, "snowballstemmer": { @@ -754,6 +856,7 @@ "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" ], + "markers": "python_version >= '3.5'", "version": "==2.0.1" }, "sphinx": { @@ -769,6 +872,7 @@ "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" ], + "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-devhelp": { @@ -776,6 +880,7 @@ "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" ], + "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-htmlhelp": { @@ -783,6 +888,7 @@ "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" ], + "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-jsmath": { @@ -790,6 +896,7 @@ "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" ], + "markers": "python_version >= '3.5'", "version": "==1.0.1" }, "sphinxcontrib-qthelp": { @@ -797,6 +904,7 @@ "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" ], + "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-serializinghtml": { @@ -804,12 +912,13 @@ "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc", "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a" ], + "markers": "python_version >= '3.5'", "version": "==1.1.4" }, "spiffworkflow": { "editable": true, "git": "https://github.com/sartography/SpiffWorkflow.git", - "ref": "5450dc0463a95811d386b7de063d950bf6179d2b" + "ref": "49163a983b7d8b8e564079c79277b21e358a26ac" }, "sqlalchemy": { "hashes": [ @@ -842,6 +951,7 @@ "sha256:f502ef245c492b391e0e23e94cba030ab91722dcc56963c85bfd7f3441ea2bbe", "sha256:fe01bac7226499aedf472c62fa3b85b2c619365f3f14dd222ffe4f3aa91e5f98" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.17" }, "swagger-ui-bundle": { @@ -858,6 +968,7 @@ "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==1.25.9" }, "vine": { @@ -865,6 +976,7 @@ "sha256:133ee6d7a9016f177ddeaf191c1f58421a1dcc6ee9a42c58b34bed40e1d2cd87", "sha256:ea4947cc56d1fd6f2095c8d543ee25dad966f78692528e68b4fada11ba3f98af" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.0" }, "waitress": { @@ -872,6 +984,7 @@ "sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261", "sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==1.4.4" }, "webob": { @@ -879,6 +992,7 @@ "sha256:a3c89a8e9ba0aeb17382836cdb73c516d0ecf6630ec40ec28288f3ed459ce87b", "sha256:aa3a917ed752ba3e0b242234b2a373f9c4e2a75d35291dcbe977649bd21fd108" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==1.8.6" }, "webtest": { @@ -925,6 +1039,7 @@ "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" ], + "markers": "python_version >= '3.6'", "version": "==3.1.0" } }, @@ -934,6 +1049,7 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "coverage": { @@ -978,7 +1094,7 @@ "sha256:0505dd08068cfec00f53a74a0ad927676d7757da81b7436a6eefe4c7cf75c545", "sha256:15ec6c0fd909e893e3a08b3a7c76ecb149122fb14b7efe1199ddd4c7c57ea958" ], - "markers": "python_version < '3.8'", + "markers": "python_version < '3.8' and python_version < '3.8'", "version": "==1.6.1" }, "more-itertools": { @@ -986,6 +1102,7 @@ "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" ], + "markers": "python_version >= '3.5'", "version": "==8.4.0" }, "packaging": { @@ -993,6 +1110,7 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pbr": { @@ -1008,20 +1126,23 @@ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.13.1" }, "py": { "hashes": [ - "sha256:a673fa23d7000440cc885c17dbd34fafcb7d7a6e230b29f6766400de36a33c44", - "sha256:f3b3a4c36512a4c4f024041ab51866f11761cc169670204b235f6b20523d4e6b" + "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", + "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" ], - "version": "==1.8.2" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.9.0" }, "pyparsing": { "hashes": [ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", "version": "==2.4.7" }, "pytest": { @@ -1037,20 +1158,22 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==1.15.0" }, "wcwidth": { "hashes": [ - "sha256:79375666b9954d4a1a10739315816324c3e73110af9d0e102d906fdb0aec009f", - "sha256:8c6b5b6ee1360b842645f336d9e5d68c55817c26d3050f46b235ef2bc650e48f" + "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784", + "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83" ], - "version": "==0.2.4" + "version": "==0.2.5" }, "zipp": { "hashes": [ "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" ], + "markers": "python_version >= '3.6'", "version": "==3.1.0" } } diff --git a/crc/api/workflow.py b/crc/api/workflow.py index e5ea738b..2e35dad2 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -146,10 +146,10 @@ def update_task(workflow_id, task_id, body, terminate_loop=None): raise ApiError("invalid_state", "You may not update a task unless it is in the READY state. " "Consider calling a token reset to make this task Ready.") if terminate_loop: - task.terminate_loop() + spiff_task.terminate_loop() - task.update_data(body) - processor.complete_task(task) + spiff_task.update_data(body) + processor.complete_task(spiff_task) processor.do_engine_steps() processor.save() diff --git a/src/spiffworkflow b/src/spiffworkflow new file mode 160000 index 00000000..49163a98 --- /dev/null +++ b/src/spiffworkflow @@ -0,0 +1 @@ +Subproject commit 49163a983b7d8b8e564079c79277b21e358a26ac From 0ca9b9624e003040dba31d245121168b090ed503 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Thu, 25 Jun 2020 12:44:34 -0400 Subject: [PATCH 034/101] Switching to STG-26 for the Spiff Library, and adding a test that demonstrates the failure Aaron ran into yesterday. --- Pipfile | 2 +- Pipfile.lock | 194 ++++++++++++------ .../test_workflow_processor_multi_instance.py | 20 +- 3 files changed, 145 insertions(+), 71 deletions(-) diff --git a/Pipfile b/Pipfile index 0079962c..d6da8498 100644 --- a/Pipfile +++ b/Pipfile @@ -25,7 +25,7 @@ pyjwt = "*" requests = "*" xlsxwriter = "*" webtest = "*" -spiffworkflow = {editable = true,git = "https://github.com/sartography/SpiffWorkflow.git",ref = "deploy"} +spiffworkflow = {editable = true,git = "https://github.com/sartography/SpiffWorkflow.git",ref = "STG-26"} alembic = "*" coverage = "*" sphinx = "*" diff --git a/Pipfile.lock b/Pipfile.lock index fb38d03c..52268cbc 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "6c89585086260ebcb41918b8ef3b1d9e189e1b492208d3ff000a138bc2f2fcee" + "sha256": "0453e28d7e408f683f7db19925416bdaf3e14c520977d09f7b07b2a9cbce5c03" }, "pipfile-spec": 6, "requires": { @@ -104,17 +104,17 @@ }, "celery": { "hashes": [ - "sha256:c3f4173f83ceb5a5c986c5fdaefb9456de3b0729a72a5776e46bd405fda7b647", - "sha256:d1762d6065522879f341c3d67c2b9fe4615eb79756d59acb1434601d4aca474b" + "sha256:ef17d7dffde7fc73ecab3a3b6389d93d3213bac53fa7f28e68e33647ad50b916", + "sha256:fd77e4248bb1b7af5f7922dd8e81156f540306e3a5c4b1c24167c1f5f06025da" ], - "version": "==4.4.5" + "version": "==4.4.6" }, "certifi": { "hashes": [ - "sha256:5ad7e9a056d25ffa5082862e36f119f7f7cec6457fa07ee2f8c339814b80c9b1", - "sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc" + "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", + "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" ], - "version": "==2020.4.5.2" + "version": "==2020.6.20" }, "cffi": { "hashes": [ @@ -394,10 +394,10 @@ }, "kombu": { "hashes": [ - "sha256:437b9cdea193cc2ed0b8044c85fd0f126bb3615ca2f4d4a35b39de7cacfa3c1a", - "sha256:dc282bb277197d723bccda1a9ba30a27a28c9672d0ab93e9e51bb05a37bd29c3" + "sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a", + "sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74" ], - "version": "==4.6.10" + "version": "==4.6.11" }, "ldap3": { "hashes": [ @@ -510,29 +510,34 @@ }, "numpy": { "hashes": [ - "sha256:0172304e7d8d40e9e49553901903dc5f5a49a703363ed756796f5808a06fc233", - "sha256:34e96e9dae65c4839bd80012023aadd6ee2ccb73ce7fdf3074c62f301e63120b", - "sha256:3676abe3d621fc467c4c1469ee11e395c82b2d6b5463a9454e37fe9da07cd0d7", - "sha256:3dd6823d3e04b5f223e3e265b4a1eae15f104f4366edd409e5a5e413a98f911f", - "sha256:4064f53d4cce69e9ac613256dc2162e56f20a4e2d2086b1956dd2fcf77b7fac5", - "sha256:4674f7d27a6c1c52a4d1aa5f0881f1eff840d2206989bae6acb1c7668c02ebfb", - "sha256:7d42ab8cedd175b5ebcb39b5208b25ba104842489ed59fbb29356f671ac93583", - "sha256:965df25449305092b23d5145b9bdaeb0149b6e41a77a7d728b1644b3c99277c1", - "sha256:9c9d6531bc1886454f44aa8f809268bc481295cf9740827254f53c30104f074a", - "sha256:a78e438db8ec26d5d9d0e584b27ef25c7afa5a182d1bf4d05e313d2d6d515271", - "sha256:a7acefddf994af1aeba05bbbafe4ba983a187079f125146dc5859e6d817df824", - "sha256:a87f59508c2b7ceb8631c20630118cc546f1f815e034193dc72390db038a5cb3", - "sha256:ac792b385d81151bae2a5a8adb2b88261ceb4976dbfaaad9ce3a200e036753dc", - "sha256:b03b2c0badeb606d1232e5f78852c102c0a7989d3a534b3129e7856a52f3d161", - "sha256:b39321f1a74d1f9183bf1638a745b4fd6fe80efbb1f6b32b932a588b4bc7695f", - "sha256:cae14a01a159b1ed91a324722d746523ec757357260c6804d11d6147a9e53e3f", - "sha256:cd49930af1d1e49a812d987c2620ee63965b619257bd76eaaa95870ca08837cf", - "sha256:e15b382603c58f24265c9c931c9a45eebf44fe2e6b4eaedbb0d025ab3255228b", - "sha256:e91d31b34fc7c2c8f756b4e902f901f856ae53a93399368d9a0dc7be17ed2ca0", - "sha256:ef627986941b5edd1ed74ba89ca43196ed197f1a206a3f18cc9faf2fb84fd675", - "sha256:f718a7949d1c4f622ff548c572e0c03440b49b9531ff00e4ed5738b459f011e8" + "sha256:13af0184177469192d80db9bd02619f6fa8b922f9f327e077d6f2a6acb1ce1c0", + "sha256:26a45798ca2a4e168d00de75d4a524abf5907949231512f372b217ede3429e98", + "sha256:26f509450db547e4dfa3ec739419b31edad646d21fb8d0ed0734188b35ff6b27", + "sha256:30a59fb41bb6b8c465ab50d60a1b298d1cd7b85274e71f38af5a75d6c475d2d2", + "sha256:33c623ef9ca5e19e05991f127c1be5aeb1ab5cdf30cb1c5cf3960752e58b599b", + "sha256:356f96c9fbec59974a592452ab6a036cd6f180822a60b529a975c9467fcd5f23", + "sha256:3c40c827d36c6d1c3cf413694d7dc843d50997ebffbc7c87d888a203ed6403a7", + "sha256:4d054f013a1983551254e2379385e359884e5af105e3efe00418977d02f634a7", + "sha256:63d971bb211ad3ca37b2adecdd5365f40f3b741a455beecba70fd0dde8b2a4cb", + "sha256:658624a11f6e1c252b2cd170d94bf28c8f9410acab9f2fd4369e11e1cd4e1aaf", + "sha256:76766cc80d6128750075378d3bb7812cf146415bd29b588616f72c943c00d598", + "sha256:7b57f26e5e6ee2f14f960db46bd58ffdca25ca06dd997729b1b179fddd35f5a3", + "sha256:7b852817800eb02e109ae4a9cef2beda8dd50d98b76b6cfb7b5c0099d27b52d4", + "sha256:8cde829f14bd38f6da7b2954be0f2837043e8b8d7a9110ec5e318ae6bf706610", + "sha256:a2e3a39f43f0ce95204beb8fe0831199542ccab1e0c6e486a0b4947256215632", + "sha256:a86c962e211f37edd61d6e11bb4df7eddc4a519a38a856e20a6498c319efa6b0", + "sha256:a8705c5073fe3fcc297fb8e0b31aa794e05af6a329e81b7ca4ffecab7f2b95ef", + "sha256:b6aaeadf1e4866ca0fdf7bb4eed25e521ae21a7947c59f78154b24fc7abbe1dd", + "sha256:be62aeff8f2f054eff7725f502f6228298891fd648dc2630e03e44bf63e8cee0", + "sha256:c2edbb783c841e36ca0fa159f0ae97a88ce8137fb3a6cd82eae77349ba4b607b", + "sha256:cbe326f6d364375a8e5a8ccb7e9cd73f4b2f6dc3b2ed205633a0db8243e2a96a", + "sha256:d34fbb98ad0d6b563b95de852a284074514331e6b9da0a9fc894fb1cdae7a79e", + "sha256:d97a86937cf9970453c3b62abb55a6475f173347b4cde7f8dcdb48c8e1b9952d", + "sha256:dd53d7c4a69e766e4900f29db5872f5824a06827d594427cf1a4aa542818b796", + "sha256:df1889701e2dfd8ba4dc9b1a010f0a60950077fb5242bb92c8b5c7f1a6f2668a", + "sha256:fa1fe75b4a9e18b66ae7f0b122543c42debcf800aaafa0212aaff3ad273c2596" ], - "version": "==1.18.5" + "version": "==1.19.0" }, "openapi-spec-validator": { "hashes": [ @@ -544,10 +549,10 @@ }, "openpyxl": { "hashes": [ - "sha256:547a9fc6aafcf44abe358b89ed4438d077e9d92e4f182c87e2dc294186dc4b64" + "sha256:6e62f058d19b09b95d20ebfbfb04857ad08d0833190516c1660675f699c6186f" ], "index": "pypi", - "version": "==3.0.3" + "version": "==3.0.4" }, "packaging": { "hashes": [ @@ -558,25 +563,25 @@ }, "pandas": { "hashes": [ - "sha256:034185bb615dc96d08fa13aacba8862949db19d5e7804d6ee242d086f07bcc46", - "sha256:0c9b7f1933e3226cc16129cf2093338d63ace5c85db7c9588e3e1ac5c1937ad5", - "sha256:1f6fcf0404626ca0475715da045a878c7062ed39bc859afc4ccf0ba0a586a0aa", - "sha256:1fc963ba33c299973e92d45466e576d11f28611f3549469aec4a35658ef9f4cc", - "sha256:29b4cfee5df2bc885607b8f016e901e63df7ffc8f00209000471778f46cc6678", - "sha256:2a8b6c28607e3f3c344fe3e9b3cd76d2bf9f59bc8c0f2e582e3728b80e1786dc", - "sha256:2bc2ff52091a6ac481cc75d514f06227dc1b10887df1eb72d535475e7b825e31", - "sha256:415e4d52fcfd68c3d8f1851cef4d947399232741cc994c8f6aa5e6a9f2e4b1d8", - "sha256:519678882fd0587410ece91e3ff7f73ad6ded60f6fcb8aa7bcc85c1dc20ecac6", - "sha256:51e0abe6e9f5096d246232b461649b0aa627f46de8f6344597ca908f2240cbaa", - "sha256:698e26372dba93f3aeb09cd7da2bb6dd6ade248338cfe423792c07116297f8f4", - "sha256:83af85c8e539a7876d23b78433d90f6a0e8aa913e37320785cf3888c946ee874", - "sha256:982cda36d1773076a415ec62766b3c0a21cdbae84525135bdb8f460c489bb5dd", - "sha256:a647e44ba1b3344ebc5991c8aafeb7cca2b930010923657a273b41d86ae225c4", - "sha256:b35d625282baa7b51e82e52622c300a1ca9f786711b2af7cbe64f1e6831f4126", - "sha256:bab51855f8b318ef39c2af2c11095f45a10b74cbab4e3c8199efcc5af314c648" + "sha256:02f1e8f71cd994ed7fcb9a35b6ddddeb4314822a0e09a9c5b2d278f8cb5d4096", + "sha256:13f75fb18486759da3ff40f5345d9dd20e7d78f2a39c5884d013456cec9876f0", + "sha256:35b670b0abcfed7cad76f2834041dcf7ae47fd9b22b63622d67cdc933d79f453", + "sha256:4c73f373b0800eb3062ffd13d4a7a2a6d522792fa6eb204d67a4fad0a40f03dc", + "sha256:5759edf0b686b6f25a5d4a447ea588983a33afc8a0081a0954184a4a87fd0dd7", + "sha256:5a7cf6044467c1356b2b49ef69e50bf4d231e773c3ca0558807cdba56b76820b", + "sha256:69c5d920a0b2a9838e677f78f4dde506b95ea8e4d30da25859db6469ded84fa8", + "sha256:8778a5cc5a8437a561e3276b85367412e10ae9fff07db1eed986e427d9a674f8", + "sha256:9871ef5ee17f388f1cb35f76dc6106d40cb8165c562d573470672f4cdefa59ef", + "sha256:9c31d52f1a7dd2bb4681d9f62646c7aa554f19e8e9addc17e8b1b20011d7522d", + "sha256:ab8173a8efe5418bbe50e43f321994ac6673afc5c7c4839014cf6401bbdd0705", + "sha256:ae961f1f0e270f1e4e2273f6a539b2ea33248e0e3a11ffb479d757918a5e03a9", + "sha256:b3c4f93fcb6e97d993bf87cdd917883b7dab7d20c627699f360a8fb49e9e0b91", + "sha256:c9410ce8a3dee77653bc0684cfa1535a7f9c291663bd7ad79e39f5ab58f67ab3", + "sha256:f69e0f7b7c09f1f612b1f8f59e2df72faa8a6b41c5a436dde5b615aaf948f107", + "sha256:faa42a78d1350b02a7d2f0dbe3c80791cf785663d6997891549d0f86dc49125e" ], "index": "pypi", - "version": "==1.0.4" + "version": "==1.0.5" }, "psycopg2-binary": { "hashes": [ @@ -678,6 +683,61 @@ ], "version": "==1.0.4" }, + "python-levenshtein-wheels": { + "hashes": [ + "sha256:0065529c8aec4c044468286177761857d36981ba6f7fdb62d7d5f7ffd143de5d", + "sha256:016924a59d689f9f47d5f7b26b70f31e309255e8dd72602c91e93ceb752b9f92", + "sha256:089d046ea7727e583233c71fef1046663ed67b96967063ae8ddc9f551e86a4fc", + "sha256:09f9faaaa8f65726f91b44c11d3d622fee0f1780cfbe2bf3f410dd0e7345adcb", + "sha256:0aea217eab612acd45dcc3424a2e8dbd977cc309f80359d0c01971f1e65b9a9b", + "sha256:0beb91ad80b1573829066e5af36b80190c367be6e0a65292f073353b0388c7fc", + "sha256:0ec1bc73f5ed3a1a06e02d13bb3cd22a0b32ebf65a9667bbccba106bfa0546f1", + "sha256:0fa2ca69ef803bc6037a8c919e2e8a17b55e94c9c9ffcb4c21befbb15a1d0f40", + "sha256:11c77d0d74ab7f46f89a58ae9c2d67349ebc1ae3e18636627f9939d810167c31", + "sha256:19a68716a322486ddffc8bf7e5cf44a82f7700b05a10658e6e7fc5c7ae92b13d", + "sha256:19a95a01d28d63b042438ba860c4ace90362906a038fa77962ba33325d377d10", + "sha256:1a61f3a51e00a3608659bbaabb3f27af37c9dbe84d843369061a3e45cf0d5103", + "sha256:1c50aebebab403fb2dd415d70355446ac364dece502b0e2737a1a085bb9a4aa4", + "sha256:1d2390d04f9b673391e5ce1a0b054d0565f2e00ea5d1187a044221dc5c02c3e6", + "sha256:1e51cdc123625a28709662d24ea0cb4cf6f991845e6054d9f803c78da1d6b08f", + "sha256:1eca6dc97dfcf588f53281fe48a6d5c423d4e14bdab658a1aa6efd447acc64e0", + "sha256:1f0056d3216b0fe38f25c6f8ebc84bd9f6d34c55a7a9414341b674fb98961399", + "sha256:228b59460e9a786e498bdfc8011838b89c6054650b115c86c9c819a055a793b0", + "sha256:23020f9ff2cb3457a926dcc470b84f9bd5b7646bd8b8e06b915bdbbc905cb23f", + "sha256:2b7b7cf0f43b677f818aa9a610464abf06106c19a51b9ac35bd051a439f337a5", + "sha256:3b591c9a7e91480f0d7bf2041d325f578b9b9c2f2d593304377cb28862e7f9a2", + "sha256:3ca9c70411ab587d071c1d8fc8b69d0558be8e4aa920f2595e2cb5eb229ccc4c", + "sha256:3e6bcca97a7ff4e720352b57ddc26380c0583dcdd4b791acef7b574ad58468a7", + "sha256:3ed88f9e638da57647149115c34e0e120cae6f3d35eee7d77e22cc9c1d8eced3", + "sha256:445bf7941cb1fa05d6c2a4a502ad4868a5cacd92e8eb77b2bd008cdda9d37c55", + "sha256:4ba5e147d76d7ee884fd6eae461438b080bcc9f2c6eb9b576811e1bcfe8f808e", + "sha256:4bb128b719c30f3b9feacfe71a338ae07d39dbffc077139416f3535c89f12362", + "sha256:4e951907b9b5d40c9f1b611c8bdfe46ff8cf8371877cebbd589bf5840feab662", + "sha256:53c0c9964390368fd64460b690f168221c669766b193b7e80ae3950c2b9551f8", + "sha256:57c4edef81611098d37176278f2b6a3712bf864eed313496d7d80504805896d1", + "sha256:5b36e406937c6463d1c1ef3dd82d3f771d9d845f21351e8a026fe4dd398ea8d0", + "sha256:7d0821dab24b430dfdc2cba70a06e6d7a45cb839d0dd0e6db97bb99e23c3d884", + "sha256:7f7283dfe50eac8a8cd9b777de9eb50b1edf7dbb46fc7cc9d9b0050d0c135021", + "sha256:7f9759095b3fc825464a72b1cae95125e610eba3c70f91557754c32a0bf32ea2", + "sha256:8005a4df455569c0d490ddfd9e5a163f21293477fd0ed4ea9effdd723ddd8eaa", + "sha256:86e865f29ad3dc3bb4733e5247220173d90f05ac8d2ad18e9689a220f90de55f", + "sha256:98727050ba70eb8d318ec8a8203531c20119347fc8f281102b097326812742ab", + "sha256:ac9cdf044dcb9481c7da782db01b50c1f0e7cdd78c8507b963b6d072829c0263", + "sha256:acfad8ffed96891fe7c583d92717cd8ec0c03b59a954c389fd4e26a5cdeac610", + "sha256:ad15f25abff8220e556d64e2a27c646241b08f00faf1bc02313655696cd3edfa", + "sha256:b679f951f842c38665aa54bea4d7403099131f71fac6d8584f893a731fe1266d", + "sha256:b8c183dc4aa4e95dc5c373eedc3d205c176805835611fcfec5d9050736c695c4", + "sha256:c097a6829967c76526a037ed34500a028f78f0d765c8e3dbd1a7717afd09fb92", + "sha256:c2c76f483d05eddec60a5cd89e92385adef565a4f243b1d9a6abe2f6bd2a7c0a", + "sha256:c388baa3c04272a7c585d3da24030c142353eb26eb531dd2681502e6be7d7a26", + "sha256:cb0f2a711db665b5bf8697b5af3b9884bb1139385c5c12c2e472e4bbee62da99", + "sha256:cbac984d7b36e75b440d1c8ff9d3425d778364a0cbc23f8943383d4decd35d5e", + "sha256:f55adf069be2d655f8d668594fe1be1b84d9dc8106d380a9ada06f34941c33c8", + "sha256:f9084ed3b8997ad4353d124b903f2860a9695b9e080663276d9e58c32e293244", + "sha256:fb7df3504222fcb1fa593f76623abbb54d6019eec15aac5d05cd07ad90ac016c" + ], + "version": "==0.13.1" + }, "pytz": { "hashes": [ "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", @@ -711,11 +771,11 @@ }, "requests": { "hashes": [ - "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", - "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" + "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", + "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" ], "index": "pypi", - "version": "==2.23.0" + "version": "==2.24.0" }, "sentry-sdk": { "extras": [ @@ -751,11 +811,11 @@ }, "sphinx": { "hashes": [ - "sha256:1c445320a3310baa5ccb8d957267ef4a0fc930dc1234db5098b3d7af14fbb242", - "sha256:7d3d5087e39ab5a031b75588e9859f011de70e213cd0080ccbc28079fb0786d1" + "sha256:74fbead182a611ce1444f50218a1c5fc70b6cc547f64948f5182fb30a2a20258", + "sha256:97c9e3bcce2f61d9f5edf131299ee9d1219630598d9f9a8791459a4d9e815be5" ], "index": "pypi", - "version": "==3.1.0" + "version": "==3.1.1" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -802,7 +862,7 @@ "spiffworkflow": { "editable": true, "git": "https://github.com/sartography/SpiffWorkflow.git", - "ref": "b8a064a0bb76c705a1be04ee9bb8ac7beee56eb0" + "ref": "49163a983b7d8b8e564079c79277b21e358a26ac" }, "sqlalchemy": { "hashes": [ @@ -932,10 +992,10 @@ }, "more-itertools": { "hashes": [ - "sha256:558bb897a2232f5e4f8e2399089e35aecb746e1f9191b6584a151647e89267be", - "sha256:7818f596b1e87be009031c7653d01acc46ed422e6656b394b0f765ce66ed4982" + "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", + "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" ], - "version": "==8.3.0" + "version": "==8.4.0" }, "packaging": { "hashes": [ @@ -961,10 +1021,10 @@ }, "py": { "hashes": [ - "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa", - "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0" + "sha256:a673fa23d7000440cc885c17dbd34fafcb7d7a6e230b29f6766400de36a33c44", + "sha256:f3b3a4c36512a4c4f024041ab51866f11761cc169670204b235f6b20523d4e6b" ], - "version": "==1.8.1" + "version": "==1.8.2" }, "pyparsing": { "hashes": [ @@ -990,10 +1050,10 @@ }, "wcwidth": { "hashes": [ - "sha256:79375666b9954d4a1a10739315816324c3e73110af9d0e102d906fdb0aec009f", - "sha256:8c6b5b6ee1360b842645f336d9e5d68c55817c26d3050f46b235ef2bc650e48f" + "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784", + "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83" ], - "version": "==0.2.4" + "version": "==0.2.5" }, "zipp": { "hashes": [ diff --git a/tests/test_workflow_processor_multi_instance.py b/tests/test_workflow_processor_multi_instance.py index a4c76dd0..a54b7eab 100644 --- a/tests/test_workflow_processor_multi_instance.py +++ b/tests/test_workflow_processor_multi_instance.py @@ -1,13 +1,13 @@ from unittest.mock import patch +from tests.base_test import BaseTest -from crc import session +from crc import session, db from crc.models.api_models import MultiInstanceType from crc.models.study import StudyModel -from crc.models.workflow import WorkflowStatus +from crc.models.workflow import WorkflowStatus, WorkflowModel from crc.services.study_service import StudyService from crc.services.workflow_processor import WorkflowProcessor from crc.services.workflow_service import WorkflowService -from tests.base_test import BaseTest class TestWorkflowProcessorMultiInstance(BaseTest): @@ -97,6 +97,12 @@ class TestWorkflowProcessorMultiInstance(BaseTest): self.assertEqual(WorkflowStatus.complete, processor.get_status()) + def refresh_processor(self, processor): + """Saves the processor, and returns a new one read in from the database""" + processor.save() + processor = WorkflowProcessor(processor.workflow_model) + return processor + @patch('crc.services.study_service.StudyService.get_investigators') def test_create_and_complete_workflow_parallel(self, mock_study_service): """Unlike the test above, the parallel task allows us to complete the items in any order.""" @@ -108,11 +114,15 @@ class TestWorkflowProcessorMultiInstance(BaseTest): workflow_spec_model = self.load_test_spec("multi_instance_parallel") study = session.query(StudyModel).first() processor = self.get_processor(study, workflow_spec_model) + processor = self.refresh_processor(processor) processor.bpmn_workflow.do_engine_steps() # In the Parallel instance, there should be three tasks, all of them in the ready state. next_user_tasks = processor.next_user_tasks() self.assertEqual(3, len(next_user_tasks)) + # There should be six tasks in the navigation: start event, the script task, end event, and three tasks + # for the three executions of hte multi-instance. + self.assertEquals(6, len(processor.bpmn_workflow.get_nav_list())) # We can complete the tasks out of order. task = next_user_tasks[2] @@ -125,6 +135,7 @@ class TestWorkflowProcessorMultiInstance(BaseTest): task.update_data({"investigator":{"email":"dhf8r@virginia.edu"}}) processor.complete_task(task) processor.do_engine_steps() + self.assertEquals(6, len(processor.bpmn_workflow.get_nav_list())) task = next_user_tasks[0] api_task = WorkflowService.spiff_task_to_api_task(task) @@ -132,6 +143,7 @@ class TestWorkflowProcessorMultiInstance(BaseTest): task.update_data({"investigator":{"email":"asd3v@virginia.edu"}}) processor.complete_task(task) processor.do_engine_steps() + self.assertEquals(6, len(processor.bpmn_workflow.get_nav_list())) task = next_user_tasks[1] api_task = WorkflowService.spiff_task_to_api_task(task) @@ -139,6 +151,7 @@ class TestWorkflowProcessorMultiInstance(BaseTest): task.update_data({"investigator":{"email":"asdf32@virginia.edu"}}) processor.complete_task(task) processor.do_engine_steps() + self.assertEquals(6, len(processor.bpmn_workflow.get_nav_list())) # Completing the tasks out of order, still provides the correct information. expected = self.mock_investigator_response @@ -149,3 +162,4 @@ class TestWorkflowProcessorMultiInstance(BaseTest): task.data['StudyInfo']['investigators']) self.assertEqual(WorkflowStatus.complete, processor.get_status()) + self.assertEquals(6, len(processor.bpmn_workflow.get_nav_list())) From 237abcdfed3d1fdb370df697fc791d49d5ed5afb Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Thu, 25 Jun 2020 13:11:42 -0400 Subject: [PATCH 035/101] Updates lock file and removes unnecessary src directory --- Pipfile.lock | 16 ++++++++-------- src/spiffworkflow | 1 - 2 files changed, 8 insertions(+), 9 deletions(-) delete mode 160000 src/spiffworkflow diff --git a/Pipfile.lock b/Pipfile.lock index baea6649..85a0fdbf 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -334,7 +334,7 @@ "hashes": [ "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.18.2" }, "gunicorn": { @@ -373,7 +373,7 @@ "sha256:0505dd08068cfec00f53a74a0ad927676d7757da81b7436a6eefe4c7cf75c545", "sha256:15ec6c0fd909e893e3a08b3a7c76ecb149122fb14b7efe1199ddd4c7c57ea958" ], - "markers": "python_version < '3.8' and python_version < '3.8'", + "markers": "python_version < '3.8'", "version": "==1.6.1" }, "inflection": { @@ -698,7 +698,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pyrsistent": { @@ -841,7 +841,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "snowballstemmer": { @@ -992,7 +992,7 @@ "sha256:a3c89a8e9ba0aeb17382836cdb73c516d0ecf6630ec40ec28288f3ed459ce87b", "sha256:aa3a917ed752ba3e0b242234b2a373f9c4e2a75d35291dcbe977649bd21fd108" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.8.6" }, "webtest": { @@ -1094,7 +1094,7 @@ "sha256:0505dd08068cfec00f53a74a0ad927676d7757da81b7436a6eefe4c7cf75c545", "sha256:15ec6c0fd909e893e3a08b3a7c76ecb149122fb14b7efe1199ddd4c7c57ea958" ], - "markers": "python_version < '3.8' and python_version < '3.8'", + "markers": "python_version < '3.8'", "version": "==1.6.1" }, "more-itertools": { @@ -1142,7 +1142,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pytest": { @@ -1158,7 +1158,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "wcwidth": { diff --git a/src/spiffworkflow b/src/spiffworkflow deleted file mode 160000 index 49163a98..00000000 --- a/src/spiffworkflow +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 49163a983b7d8b8e564079c79277b21e358a26ac From 7116b582e8dbea7684dfe4521212b630b2d5ad06 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Thu, 25 Jun 2020 12:01:24 -0600 Subject: [PATCH 036/101] Splitting commands properly without losing double quoted strings --- crc/scripts/email.py | 14 +------------- crc/services/workflow_processor.py | 5 ++++- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/crc/scripts/email.py b/crc/scripts/email.py index d3a64725..6f8244dd 100644 --- a/crc/scripts/email.py +++ b/crc/scripts/email.py @@ -73,19 +73,7 @@ Email Subject ApprvlApprvr1 PIComputingID message="Email script requires at least one subject argument. The " "name of the variable in the task data that contains subject" " to process. Multiple arguments are accepted.") - - subject_index = 0 - subject = args[subject_index] - if subject.startswith('"') and not subject.endswith('"'): - # Multi-word subject - subject_index += 1 - next_word = args[subject_index] - while not next_word.endswith('"'): - subject = ' '.join((subject, next_word)) - subject_index += 1 - next_word = args[subject_index] - subject = ' '.join((subject, next_word)) - subject = subject.replace('"', '') + subject = args[0] if not isinstance(subject, str): raise ApiError(code="invalid_argument", message="The Email script requires 1 argument. The " diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index dde14bb5..52aa5a33 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -1,4 +1,5 @@ import re +import shlex import xml.etree.ElementTree as ElementTree from datetime import datetime from typing import List @@ -36,7 +37,9 @@ class CustomBpmnScriptEngine(BpmnScriptEngine): This allows us to reference custom code from the BPMN diagram. """ - commands = script.split(" ") + # Shlex splits the whole string while respecting double quoted strings within + commands = shlex.split(script) + printable_comms = commands path_and_command = commands[0].rsplit(".", 1) if len(path_and_command) == 1: module_name = "crc.scripts." + self.camel_to_snake(path_and_command[0]) From c3ceda4c2fa08ef1f0d22a11faa3f4d3cf194e0c Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Thu, 25 Jun 2020 14:02:16 -0400 Subject: [PATCH 037/101] Replaces xml.etree with lxml.etree --- Pipfile | 39 +++++++++++++++--------------- Pipfile.lock | 6 ++--- crc/services/file_service.py | 8 +++--- crc/services/workflow_processor.py | 6 ++--- 4 files changed, 30 insertions(+), 29 deletions(-) diff --git a/Pipfile b/Pipfile index 96f8a748..5873efb4 100644 --- a/Pipfile +++ b/Pipfile @@ -9,39 +9,40 @@ pbr = "*" coverage = "*" [packages] +alembic = "*" connexion = {extras = ["swagger-ui"],version = "*"} -swagger-ui-bundle = "*" +coverage = "*" +docxtpl = "*" flask = "*" +flask-admin = "*" flask-bcrypt = "*" flask-cors = "*" +flask-mail = "*" flask-marshmallow = "*" flask-migrate = "*" flask-restful = "*" +gunicorn = "*" httpretty = "*" +ldap3 = "*" marshmallow = "*" marshmallow-enum = "*" marshmallow-sqlalchemy = "*" openpyxl = "*" -pyjwt = "*" -requests = "*" -xlsxwriter = "*" -webtest = "*" -spiffworkflow = {editable = true,git = "https://github.com/sartography/SpiffWorkflow.git",ref = "STG-26"} -alembic = "*" -coverage = "*" -sphinx = "*" -recommonmark = "*" -psycopg2-binary = "*" -docxtpl = "*" -python-dateutil = "*" pandas = "*" -xlrd = "*" -ldap3 = "*" -gunicorn = "*" -werkzeug = "*" +psycopg2-binary = "*" +pyjwt = "*" +python-dateutil = "*" +recommonmark = "*" +requests = "*" sentry-sdk = {extras = ["flask"],version = "==0.14.4"} -flask-mail = "*" -flask-admin = "*" +sphinx = "*" +spiffworkflow = {editable = true,git = "https://github.com/sartography/SpiffWorkflow.git",ref = "STG-26"} +swagger-ui-bundle = "*" +webtest = "*" +werkzeug = "*" +xlrd = "*" +xlsxwriter = "*" +lxml = "*" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index 85a0fdbf..ce008306 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "8d6d99bcacef0b12f29f3c402f7980799812f645c576767b5477445a1fc03062" + "sha256": "d82c06e080dbdd4c9da4e308d29ebefd9ef41be7a15caa72c6d6f9b7007d8910" }, "pipfile-spec": 6, "requires": { @@ -464,7 +464,7 @@ "sha256:f95d28193c3863132b1f55c1056036bf580b5a488d908f7d22a04ace8935a3a9", "sha256:fadd2a63a2bfd7fb604508e553d1cf68eca250b2fbdbd81213b5f6f2fbf23529" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "index": "pypi", "version": "==4.5.1" }, "mako": { @@ -918,7 +918,7 @@ "spiffworkflow": { "editable": true, "git": "https://github.com/sartography/SpiffWorkflow.git", - "ref": "49163a983b7d8b8e564079c79277b21e358a26ac" + "ref": "8ab8d98792ac46e0bac5b1b35a59ddfe28aa9760" }, "sqlalchemy": { "hashes": [ diff --git a/crc/services/file_service.py b/crc/services/file_service.py index ff234a79..fe8cb4e2 100644 --- a/crc/services/file_service.py +++ b/crc/services/file_service.py @@ -3,7 +3,7 @@ import json import os from datetime import datetime from uuid import UUID -from xml.etree import ElementTree +from lxml import etree import flask from SpiffWorkflow.bpmn.parser.ValidationException import ValidationException @@ -151,7 +151,7 @@ class FileService(object): # If this is a BPMN, extract the process id. if file_model.type == FileType.bpmn: - bpmn: ElementTree.Element = ElementTree.fromstring(binary_data) + bpmn: etree.Element = etree.fromstring(binary_data) file_model.primary_process_id = FileService.get_process_id(bpmn) new_file_data_model = FileDataModel( @@ -165,7 +165,7 @@ class FileService(object): return file_model @staticmethod - def get_process_id(et_root: ElementTree.Element): + def get_process_id(et_root: etree.Element): process_elements = [] for child in et_root: if child.tag.endswith('process') and child.attrib.get('isExecutable', False): @@ -179,7 +179,7 @@ class FileService(object): # Look for the element that has the startEvent in it for e in process_elements: - this_element: ElementTree.Element = e + this_element: etree.Element = e for child_element in list(this_element): if child_element.tag.endswith('startEvent'): return this_element.attrib['id'] diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index c84aa3fa..edb25770 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -1,5 +1,5 @@ import re -import xml.etree.ElementTree as ElementTree +from lxml import etree from datetime import datetime from typing import List @@ -266,12 +266,12 @@ class WorkflowProcessor(object): for file_data in file_data_models: if file_data.file_model.type == FileType.bpmn: - bpmn: ElementTree.Element = ElementTree.fromstring(file_data.data) + bpmn: etree.Element = etree.fromstring(file_data.data) if file_data.file_model.primary: process_id = FileService.get_process_id(bpmn) parser.add_bpmn_xml(bpmn, filename=file_data.file_model.name) elif file_data.file_model.type == FileType.dmn: - dmn: ElementTree.Element = ElementTree.fromstring(file_data.data) + dmn: etree.Element = etree.fromstring(file_data.data) parser.add_dmn_xml(dmn, filename=file_data.file_model.name) if process_id is None: raise (ApiError(code="no_primary_bpmn_error", From 665faaa175ee2c426831b344033947e6fdda8605 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Thu, 25 Jun 2020 16:18:42 -0600 Subject: [PATCH 038/101] Send emails from service --- crc/__init__.py | 5 +++-- crc/api/tools.py | 2 +- crc/services/email_service.py | 17 ++++++++++++++--- crc/services/mails.py | 17 +---------------- 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/crc/__init__.py b/crc/__init__.py index 59ffeac7..d169b547 100644 --- a/crc/__init__.py +++ b/crc/__init__.py @@ -34,6 +34,9 @@ db = SQLAlchemy(app) session = db.session """:type: sqlalchemy.orm.Session""" +# Mail settings +mail = Mail(app) + migrate = Migrate(app, db) ma = Marshmallow(app) @@ -58,8 +61,6 @@ if app.config['ENABLE_SENTRY']: # Jinja environment definition, used to render mail templates template_dir = os.getcwd() + '/crc/static/templates/mails' env = Environment(loader=FileSystemLoader(template_dir)) -# Mail settings -mail = Mail(app) print('=== USING THESE CONFIG SETTINGS: ===') print('APPLICATION_ROOT = ', app.config['APPLICATION_ROOT']) diff --git a/crc/api/tools.py b/crc/api/tools.py index fa969a1e..760d0d71 100644 --- a/crc/api/tools.py +++ b/crc/api/tools.py @@ -65,4 +65,4 @@ def send_email(address): """Just sends a quick test email to assure the system is working.""" if not address: address = "dan@sartography.com" - return send_test_email(address, [address]) \ No newline at end of file + return send_test_email(address, [address]) diff --git a/crc/services/email_service.py b/crc/services/email_service.py index 633f2102..3d78eada 100644 --- a/crc/services/email_service.py +++ b/crc/services/email_service.py @@ -1,8 +1,8 @@ from datetime import datetime - +from flask_mail import Message from sqlalchemy import desc -from crc import app, db, session +from crc import app, db, mail, session from crc.api.common import ApiError from crc.models.study import StudyModel @@ -25,7 +25,18 @@ class EmailService(object): email_model = EmailModel(subject=subject, sender=sender, recipients=str(recipients), content=content, content_html=content_html, study=study) - # TODO: Send email from here, not from caller functions + # Send mail + try: + msg = Message(subject, + sender=sender, + recipients=recipients) + + msg.body = content + msg.html = content_html + + mail.send(msg) + except Exception as e: + app.logger.error(str(e)) db.session.add(email_model) db.session.commit() diff --git a/crc/services/mails.py b/crc/services/mails.py index c4942a7d..a1570035 100644 --- a/crc/services/mails.py +++ b/crc/services/mails.py @@ -6,7 +6,6 @@ from flask_mail import Message from crc.services.email_service import EmailService -# TODO: Extract common mailing code into its own function def send_test_email(sender, recipients): try: msg = Message('Research Ramp-up Plan test', @@ -24,23 +23,9 @@ def send_test_email(sender, recipients): return str(e) def send_mail(subject, sender, recipients, content, content_html, study_id=None): - from crc import mail - try: - msg = Message(subject, - sender=sender, - recipients=recipients, - bcc=['rrt_emails@googlegroups.com']) - - msg.body = content - msg.html = content_html - - EmailService.add_email(subject=subject, sender=sender, recipients=recipients, + EmailService.add_email(subject=subject, sender=sender, recipients=recipients, content=content, content_html=content_html, study_id=study_id) - mail.send(msg) - except Exception as e: - return str(e) - def send_ramp_up_submission_email(sender, recipients, approver_1, approver_2=None): from crc import env subject = 'Research Ramp-up Plan Submitted' From f49e6905efdb850bd3929466f3a701fb1754fc01 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 26 Jun 2020 10:48:39 -0400 Subject: [PATCH 039/101] Updates Spiff --- Pipfile.lock | 72 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index d87f0411..e5b3808a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -35,6 +35,7 @@ "sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b", "sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.6.0" }, "aniso8601": { @@ -49,6 +50,7 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "babel": { @@ -56,6 +58,7 @@ "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.0" }, "bcrypt": { @@ -79,6 +82,7 @@ "sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7", "sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.1.7" }, "beautifulsoup4": { @@ -107,6 +111,7 @@ "sha256:ef17d7dffde7fc73ecab3a3b6389d93d3213bac53fa7f28e68e33647ad50b916", "sha256:fd77e4248bb1b7af5f7922dd8e81156f540306e3a5c4b1c24167c1f5f06025da" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.4.6" }, "certifi": { @@ -161,6 +166,7 @@ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "clickclick": { @@ -182,6 +188,7 @@ "sha256:2ca44140ee259b5e3d8aaf47c79c36a7ab0d5e94d70bd4105c03ede7a20ea5a1", "sha256:cffc044844040c7ce04e9acd1838b5f2e5fa3170182f6fda4d2ea8b0099dbadd" ], + "markers": "python_version >= '3.6'", "version": "==5.0.0" }, "connexion": { @@ -237,6 +244,7 @@ "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==0.16" }, "docxtpl": { @@ -319,12 +327,14 @@ "sha256:0b656fbf87c5f24109d859bafa791d29751fabbda2302b606881ae5485b557a5", "sha256:fcfe6df52cd2ed8a63008ca36b86a51fa7a4b70cef1c39e5625f722fca32308e" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.3" }, "future": { "hashes": [ "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.18.2" }, "gunicorn": { @@ -347,6 +357,7 @@ "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.9" }, "imagesize": { @@ -354,6 +365,7 @@ "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.2.0" }, "importlib-metadata": { @@ -369,6 +381,7 @@ "sha256:88b101b2668a1d81d6d72d4c2018e53bc6c7fc544c987849da1c7f77545c3bc9", "sha256:f576e85132d34f5bf7df5183c2c6f94cfb32e528f53065345cf71329ba0b8924" ], + "markers": "python_version >= '3.5'", "version": "==0.5.0" }, "itsdangerous": { @@ -376,6 +389,7 @@ "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.0" }, "jdcal": { @@ -390,6 +404,7 @@ "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.2" }, "jsonschema": { @@ -404,12 +419,17 @@ "sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a", "sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.6.11" }, "ldap3": { "hashes": [ + "sha256:4fd2db72d0412cc16ee86be01332095e86e361329c3579b314231eb2e56c7871", + "sha256:53aaae5bf14f3827c69600ddf4d61b88f49c055bb93060e9702c5bafd206c744", + "sha256:298769ab0232b3a3efa1e84881096c24526fe37911c83a11285f222fe4975efd", + "sha256:81df4ac8b6df10fb1f05b17c18d0cb8c4c344d5a03083c382824960ed959cf5b", "sha256:17f04298b70bf7ecaa5db8a7d8622b5a962ef7fc2b245b2eea705ac1c24338c0", - "sha256:81df4ac8b6df10fb1f05b17c18d0cb8c4c344d5a03083c382824960ed959cf5b" + "sha256:52ab557b3c4908db4a90bea16731aa714b1b54e039b54fd4c4b83994c6c48c0c" ], "index": "pypi", "version": "==2.7" @@ -452,6 +472,7 @@ "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27", "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.3" }, "markdown": { @@ -498,6 +519,7 @@ "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "marshmallow": { @@ -553,6 +575,7 @@ "sha256:df1889701e2dfd8ba4dc9b1a010f0a60950077fb5242bb92c8b5c7f1a6f2668a", "sha256:fa1fe75b4a9e18b66ae7f0b122543c42debcf800aaafa0212aaff3ad273c2596" ], + "markers": "python_version >= '3.6'", "version": "==1.19.0" }, "openapi-spec-validator": { @@ -575,6 +598,7 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pandas": { @@ -637,8 +661,19 @@ }, "pyasn1": { "hashes": [ + "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", + "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", + "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", + "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3", + "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", + "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", + "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", + "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", + "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", + "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba" + "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776" ], "version": "==0.4.8" }, @@ -647,6 +682,7 @@ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.20" }, "pygments": { @@ -654,6 +690,7 @@ "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" ], + "markers": "python_version >= '3.5'", "version": "==2.6.1" }, "pyjwt": { @@ -669,6 +706,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pyrsistent": { @@ -693,9 +731,11 @@ }, "python-editor": { "hashes": [ - "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", + "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8", "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", - "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8" + "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77", + "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522", + "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d" ], "version": "==1.0.4" }, @@ -809,6 +849,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "snowballstemmer": { @@ -823,6 +864,7 @@ "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" ], + "markers": "python_version >= '3.5'", "version": "==2.0.1" }, "sphinx": { @@ -838,6 +880,7 @@ "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" ], + "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-devhelp": { @@ -845,6 +888,7 @@ "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" ], + "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-htmlhelp": { @@ -852,6 +896,7 @@ "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" ], + "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-jsmath": { @@ -859,6 +904,7 @@ "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" ], + "markers": "python_version >= '3.5'", "version": "==1.0.1" }, "sphinxcontrib-qthelp": { @@ -866,6 +912,7 @@ "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" ], + "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-serializinghtml": { @@ -873,12 +920,13 @@ "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc", "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a" ], + "markers": "python_version >= '3.5'", "version": "==1.1.4" }, "spiffworkflow": { "editable": true, "git": "https://github.com/sartography/SpiffWorkflow.git", - "ref": "bd2af4ef61ad3adaf193635bbb21729d067f033b" + "ref": "62caf2c30d7932ac82ada0d1db84ef9fe9106c43" }, "sqlalchemy": { "hashes": [ @@ -911,6 +959,7 @@ "sha256:f57be5673e12763dd400fea568608700a63ce1c6bd5bdbc3cc3a2c5fdb045274", "sha256:fc728ece3d5c772c196fd338a99798e7efac7a04f9cb6416299a3638ee9a94cd" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.18" }, "swagger-ui-bundle": { @@ -927,6 +976,7 @@ "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==1.25.9" }, "vine": { @@ -934,6 +984,7 @@ "sha256:133ee6d7a9016f177ddeaf191c1f58421a1dcc6ee9a42c58b34bed40e1d2cd87", "sha256:ea4947cc56d1fd6f2095c8d543ee25dad966f78692528e68b4fada11ba3f98af" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.0" }, "waitress": { @@ -941,6 +992,7 @@ "sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261", "sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==1.4.4" }, "webob": { @@ -948,6 +1000,7 @@ "sha256:a3c89a8e9ba0aeb17382836cdb73c516d0ecf6630ec40ec28288f3ed459ce87b", "sha256:aa3a917ed752ba3e0b242234b2a373f9c4e2a75d35291dcbe977649bd21fd108" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.8.6" }, "webtest": { @@ -994,6 +1047,7 @@ "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" ], + "markers": "python_version >= '3.6'", "version": "==3.1.0" } }, @@ -1003,6 +1057,7 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "coverage": { @@ -1055,6 +1110,7 @@ "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" ], + "markers": "python_version >= '3.5'", "version": "==8.4.0" }, "packaging": { @@ -1062,6 +1118,7 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pbr": { @@ -1077,6 +1134,7 @@ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.13.1" }, "py": { @@ -1084,6 +1142,7 @@ "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.9.0" }, "pyparsing": { @@ -1091,6 +1150,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pytest": { @@ -1106,6 +1166,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "wcwidth": { @@ -1120,6 +1181,7 @@ "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" ], + "markers": "python_version >= '3.6'", "version": "==3.1.0" } } From 29b108673da798c9fe29ab3c9d7fe2b6140ca794 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 26 Jun 2020 11:51:27 -0400 Subject: [PATCH 040/101] Adds failing test exposing bug with getting data for multi-instance tasks --- .../test_workflow_processor_multi_instance.py | 58 ++++++++++++------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/tests/workflow/test_workflow_processor_multi_instance.py b/tests/workflow/test_workflow_processor_multi_instance.py index a54b7eab..a81eeac1 100644 --- a/tests/workflow/test_workflow_processor_multi_instance.py +++ b/tests/workflow/test_workflow_processor_multi_instance.py @@ -32,7 +32,6 @@ class TestWorkflowProcessorMultiInstance(BaseTest): 'error': 'Unable to locate a user with id asd3v in LDAP'}} def _populate_form_with_random_data(self, task): - WorkflowService.populate_form_with_random_data(task) def get_processor(self, study_model, spec_model): @@ -52,49 +51,64 @@ class TestWorkflowProcessorMultiInstance(BaseTest): self.assertIsNotNone(processor) self.assertEqual(WorkflowStatus.user_input_required, processor.get_status()) processor.bpmn_workflow.do_engine_steps() - next_user_tasks = processor.next_user_tasks() - self.assertEqual(1, len(next_user_tasks)) - - task = next_user_tasks[0] + workflow_api = WorkflowService.processor_to_workflow_api(processor) + self.assertIsNotNone(workflow_api) + self.assertIsNotNone(workflow_api.next_task) + # 1st investigator + api_task = workflow_api.next_task self.assertEqual(WorkflowStatus.user_input_required, processor.get_status()) - self.assertEqual("dhf8r", task.data["investigator"]["user_id"]) - - self.assertEqual("MutiInstanceTask", task.get_name()) - api_task = WorkflowService.spiff_task_to_api_task(task) - self.assertEqual(MultiInstanceType.sequential, api_task.multi_instance_type) + self.assertEqual("dhf8r", api_task.data["investigator"]["user_id"]) + self.assertEqual("MutiInstanceTask", api_task.name) self.assertEqual(3, api_task.multi_instance_count) self.assertEqual(1, api_task.multi_instance_index) - task.update_data({"investigator":{"email":"asd3v@virginia.edu"}}) + + task = processor.get_current_user_tasks()[0] + self.assertEqual(task.id, api_task.id) + task.update_data({"investigator": {"email": "asd3v@virginia.edu"}}) processor.complete_task(task) processor.do_engine_steps() + workflow_api = WorkflowService.processor_to_workflow_api(processor) - task = next_user_tasks[0] - api_task = WorkflowService.spiff_task_to_api_task(task) + # 2nd investigator + api_task = workflow_api.next_task + self.assertEqual(WorkflowStatus.user_input_required, processor.get_status()) + self.assertEqual(None, api_task.data["investigator"]["user_id"]) self.assertEqual("MutiInstanceTask", api_task.name) - task.update_data({"investigator":{"email":"asdf32@virginia.edu"}}) self.assertEqual(3, api_task.multi_instance_count) self.assertEqual(2, api_task.multi_instance_index) + + task = processor.get_current_user_tasks()[0] + self.assertEqual(task.id, api_task.id) + task.update_data({"investigator": {"email": "asdf32@virginia.edu"}}) processor.complete_task(task) processor.do_engine_steps() + workflow_api = WorkflowService.processor_to_workflow_api(processor) - task = next_user_tasks[0] - api_task = WorkflowService.spiff_task_to_api_task(task) - self.assertEqual("MutiInstanceTask", task.get_name()) - task.update_data({"investigator":{"email":"dhf8r@virginia.edu"}}) + # 3rd investigator + api_task = workflow_api.next_task + self.assertEqual(WorkflowStatus.user_input_required, processor.get_status()) + self.assertEqual("MutiInstanceTask", api_task.get_name()) + self.assertEqual("asd3v", api_task.data["investigator"]["user_id"]) self.assertEqual(3, api_task.multi_instance_count) self.assertEqual(3, api_task.multi_instance_index) + + task = processor.get_current_user_tasks()[0] + self.assertEqual(task.id, api_task.id) + task.update_data({"investigator": {"email": "dhf8r@virginia.edu"}}) processor.complete_task(task) processor.do_engine_steps() - task = processor.bpmn_workflow.last_task + workflow_api = WorkflowService.processor_to_workflow_api(processor) + + # Last task + api_task = workflow_api.next_task expected = self.mock_investigator_response expected['PI']['email'] = "asd3v@virginia.edu" expected['SC_I']['email'] = "asdf32@virginia.edu" expected['DC']['email'] = "dhf8r@virginia.edu" - self.assertEqual(expected, - task.data['StudyInfo']['investigators']) + self.assertEqual(expected, api_task.data['StudyInfo']['investigators']) self.assertEqual(WorkflowStatus.complete, processor.get_status()) def refresh_processor(self, processor): @@ -132,7 +146,7 @@ class TestWorkflowProcessorMultiInstance(BaseTest): api_task = WorkflowService.spiff_task_to_api_task(task) self.assertEqual(MultiInstanceType.parallel, api_task.multi_instance_type) - task.update_data({"investigator":{"email":"dhf8r@virginia.edu"}}) + task.update_data({"investigator": {"email": "dhf8r@virginia.edu"}}) processor.complete_task(task) processor.do_engine_steps() self.assertEquals(6, len(processor.bpmn_workflow.get_nav_list())) From fe61333b7b7f8b2194d07dfdb74f94b37546673d Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 26 Jun 2020 12:31:15 -0400 Subject: [PATCH 041/101] Fixes typo --- tests/data/multi_instance/multi_instance.bpmn | 8 ++++---- .../multi_instance_parallel.bpmn | 8 ++++---- tests/test_tasks_api.py | 2 +- .../workflow/test_workflow_processor_multi_instance.py | 10 +++++----- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/data/multi_instance/multi_instance.bpmn b/tests/data/multi_instance/multi_instance.bpmn index d53f7b17..28bda546 100644 --- a/tests/data/multi_instance/multi_instance.bpmn +++ b/tests/data/multi_instance/multi_instance.bpmn @@ -8,8 +8,8 @@ Flow_0ugjw69 - - + + # Please provide addtional information about: ## Investigator ID: {{investigator.NETBADGEID}} ## Role: {{investigator.INVESTIGATORTYPEFULL}} @@ -25,7 +25,7 @@ Flow_0ugjw69 - + Flow_0t6p1sb SequenceFlow_1p568pp @@ -58,7 +58,7 @@
- + diff --git a/tests/data/multi_instance_parallel/multi_instance_parallel.bpmn b/tests/data/multi_instance_parallel/multi_instance_parallel.bpmn index ba1fd76b..dd6215ed 100644 --- a/tests/data/multi_instance_parallel/multi_instance_parallel.bpmn +++ b/tests/data/multi_instance_parallel/multi_instance_parallel.bpmn @@ -8,8 +8,8 @@ Flow_0ugjw69 - - + + # Please provide addtional information about: ## Investigator ID: {{investigator.user_id}} ## Role: {{investigator.type_full}} @@ -22,7 +22,7 @@ Flow_0ugjw69 - + Flow_0t6p1sb SequenceFlow_1p568pp @@ -55,7 +55,7 @@ - + diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index 7288b5e4..c6b09dae 100644 --- a/tests/test_tasks_api.py +++ b/tests/test_tasks_api.py @@ -440,7 +440,7 @@ class TestTasksApi(BaseTest): self.assertEqual(9, len(ready_items)) self.assertEqual("UserTask", workflow_api.next_task.type) - self.assertEqual("MutiInstanceTask",workflow_api.next_task.name) + self.assertEqual("MultiInstanceTask",workflow_api.next_task.name) self.assertEqual("more information", workflow_api.next_task.title) for i in random.sample(range(9), 9): diff --git a/tests/workflow/test_workflow_processor_multi_instance.py b/tests/workflow/test_workflow_processor_multi_instance.py index a81eeac1..76821fed 100644 --- a/tests/workflow/test_workflow_processor_multi_instance.py +++ b/tests/workflow/test_workflow_processor_multi_instance.py @@ -59,7 +59,7 @@ class TestWorkflowProcessorMultiInstance(BaseTest): api_task = workflow_api.next_task self.assertEqual(WorkflowStatus.user_input_required, processor.get_status()) self.assertEqual("dhf8r", api_task.data["investigator"]["user_id"]) - self.assertEqual("MutiInstanceTask", api_task.name) + self.assertEqual("MultiInstanceTask", api_task.name) self.assertEqual(3, api_task.multi_instance_count) self.assertEqual(1, api_task.multi_instance_index) @@ -74,7 +74,7 @@ class TestWorkflowProcessorMultiInstance(BaseTest): api_task = workflow_api.next_task self.assertEqual(WorkflowStatus.user_input_required, processor.get_status()) self.assertEqual(None, api_task.data["investigator"]["user_id"]) - self.assertEqual("MutiInstanceTask", api_task.name) + self.assertEqual("MultiInstanceTask", api_task.name) self.assertEqual(3, api_task.multi_instance_count) self.assertEqual(2, api_task.multi_instance_index) @@ -88,8 +88,8 @@ class TestWorkflowProcessorMultiInstance(BaseTest): # 3rd investigator api_task = workflow_api.next_task self.assertEqual(WorkflowStatus.user_input_required, processor.get_status()) - self.assertEqual("MutiInstanceTask", api_task.get_name()) self.assertEqual("asd3v", api_task.data["investigator"]["user_id"]) + self.assertEqual("MultiInstanceTask", api_task.name) self.assertEqual(3, api_task.multi_instance_count) self.assertEqual(3, api_task.multi_instance_index) @@ -153,7 +153,7 @@ class TestWorkflowProcessorMultiInstance(BaseTest): task = next_user_tasks[0] api_task = WorkflowService.spiff_task_to_api_task(task) - self.assertEqual("MutiInstanceTask", api_task.name) + self.assertEqual("MultiInstanceTask", api_task.name) task.update_data({"investigator":{"email":"asd3v@virginia.edu"}}) processor.complete_task(task) processor.do_engine_steps() @@ -161,7 +161,7 @@ class TestWorkflowProcessorMultiInstance(BaseTest): task = next_user_tasks[1] api_task = WorkflowService.spiff_task_to_api_task(task) - self.assertEqual("MutiInstanceTask", task.get_name()) + self.assertEqual("MultiInstanceTask", task.get_name()) task.update_data({"investigator":{"email":"asdf32@virginia.edu"}}) processor.complete_task(task) processor.do_engine_steps() From f9f3003ef0b5d656cd535ce374e48179cabc7558 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 26 Jun 2020 12:31:37 -0400 Subject: [PATCH 042/101] Filters by multi-instance index, if applicable --- crc/services/workflow_service.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 8d81a908..f77d264b 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -242,10 +242,12 @@ class WorkflowService(object): @staticmethod def get_previously_submitted_data(workflow_id, task): """ If the user has completed this task previously, find the form data for the last submission.""" + mi_index = task.multi_instance_index if hasattr(task, 'multi_instance_index') else None; latest_event = db.session.query(TaskEventModel) \ .filter_by(workflow_id=workflow_id) \ .filter_by(task_name=task.task_spec.name) \ .filter_by(action=WorkflowService.TASK_ACTION_COMPLETE) \ + .filter_by(mi_index=mi_index) \ .order_by(TaskEventModel.date.desc()).first() if latest_event: if latest_event.form_data is not None: From 0ef52854a00fd582903e71f656ec9309af7a9235 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 26 Jun 2020 12:47:12 -0400 Subject: [PATCH 043/101] Updates packages --- Pipfile.lock | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index e5b3808a..9b79a526 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -424,12 +424,12 @@ }, "ldap3": { "hashes": [ - "sha256:4fd2db72d0412cc16ee86be01332095e86e361329c3579b314231eb2e56c7871", - "sha256:53aaae5bf14f3827c69600ddf4d61b88f49c055bb93060e9702c5bafd206c744", - "sha256:298769ab0232b3a3efa1e84881096c24526fe37911c83a11285f222fe4975efd", - "sha256:81df4ac8b6df10fb1f05b17c18d0cb8c4c344d5a03083c382824960ed959cf5b", "sha256:17f04298b70bf7ecaa5db8a7d8622b5a962ef7fc2b245b2eea705ac1c24338c0", - "sha256:52ab557b3c4908db4a90bea16731aa714b1b54e039b54fd4c4b83994c6c48c0c" + "sha256:298769ab0232b3a3efa1e84881096c24526fe37911c83a11285f222fe4975efd", + "sha256:4fd2db72d0412cc16ee86be01332095e86e361329c3579b314231eb2e56c7871", + "sha256:52ab557b3c4908db4a90bea16731aa714b1b54e039b54fd4c4b83994c6c48c0c", + "sha256:53aaae5bf14f3827c69600ddf4d61b88f49c055bb93060e9702c5bafd206c744", + "sha256:81df4ac8b6df10fb1f05b17c18d0cb8c4c344d5a03083c382824960ed959cf5b" ], "index": "pypi", "version": "==2.7" @@ -661,19 +661,19 @@ }, "pyasn1": { "hashes": [ - "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", - "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", - "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", - "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3", - "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", - "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", - "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", - "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", - "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", + "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", + "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", + "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776" + "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", + "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", + "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", + "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", + "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", + "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", + "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" ], "version": "==0.4.8" }, @@ -731,11 +731,11 @@ }, "python-editor": { "hashes": [ - "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8", + "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", + "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8", "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77", - "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522", - "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d" + "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522" ], "version": "==1.0.4" }, @@ -926,7 +926,7 @@ "spiffworkflow": { "editable": true, "git": "https://github.com/sartography/SpiffWorkflow.git", - "ref": "62caf2c30d7932ac82ada0d1db84ef9fe9106c43" + "ref": "599f41fcf9257196710806e16bef023c836735f4" }, "sqlalchemy": { "hashes": [ From 848ad563d31ab2844ef857d93fffd258bbb68b2d Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 26 Jun 2020 12:47:42 -0400 Subject: [PATCH 044/101] Only filters by mi_index if task has a mult_instance_index --- crc/services/workflow_service.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index f77d264b..2f4299a8 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -242,13 +242,15 @@ class WorkflowService(object): @staticmethod def get_previously_submitted_data(workflow_id, task): """ If the user has completed this task previously, find the form data for the last submission.""" - mi_index = task.multi_instance_index if hasattr(task, 'multi_instance_index') else None; - latest_event = db.session.query(TaskEventModel) \ + query = db.session.query(TaskEventModel) \ .filter_by(workflow_id=workflow_id) \ .filter_by(task_name=task.task_spec.name) \ - .filter_by(action=WorkflowService.TASK_ACTION_COMPLETE) \ - .filter_by(mi_index=mi_index) \ - .order_by(TaskEventModel.date.desc()).first() + .filter_by(action=WorkflowService.TASK_ACTION_COMPLETE) + + if hasattr(task, 'multi_instance_index'): + query = query.filter_by(mi_index=task.multi_instance_index) + + latest_event = query.order_by(TaskEventModel.date.desc()).first() if latest_event: if latest_event.form_data is not None: return latest_event.form_data From d4bcb9af65148308915835b65a173a0361c33ae8 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Sat, 27 Jun 2020 13:59:04 -0600 Subject: [PATCH 045/101] Proper error handling in csv/dumps for approvals --- crc/services/approval_service.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index eacac72c..e0cd5412 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -1,5 +1,6 @@ import json import pickle +import sys from base64 import b64decode from datetime import datetime, timedelta @@ -194,7 +195,7 @@ class ApprovalService(object): health_attesting_rows.append(record) except Exception as e: - app.logger.error("Error pulling data for workflow #%i: %s" % (approval.workflow_id, str(e))) + app.logger.error(f'Error pulling data for workflow {approval.workflow_id}', exc_info=True) return health_attesting_rows @@ -222,7 +223,13 @@ class ApprovalService(object): output.append(record) except Exception as e: - errors.append("Error pulling data for workflow #%i: %s" % (approval.workflow_id, str(e))) + errors.append( + f'Error pulling data for workflow #{approval.workflow_id} ' + f'(Approval status: {approval.status} - ' + f'More details in Sentry): {str(e)}' + ) + # Detailed information sent to Sentry + app.logger.error(f'Error pulling data for workflow {approval.workflow_id}', exc_info=True) return {"results": output, "errors": errors } @staticmethod From a996c815085074732ff93766f6bf98e3b4e6d6a6 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sun, 28 Jun 2020 11:35:35 -0400 Subject: [PATCH 046/101] Gets mi_index out of Spiff task internal data --- crc/services/workflow_service.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 2f4299a8..0faf3b76 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -240,24 +240,24 @@ class WorkflowService(object): return workflow_api @staticmethod - def get_previously_submitted_data(workflow_id, task): + def get_previously_submitted_data(workflow_id, spiff_task): """ If the user has completed this task previously, find the form data for the last submission.""" query = db.session.query(TaskEventModel) \ .filter_by(workflow_id=workflow_id) \ - .filter_by(task_name=task.task_spec.name) \ + .filter_by(task_name=spiff_task.task_spec.name) \ .filter_by(action=WorkflowService.TASK_ACTION_COMPLETE) - if hasattr(task, 'multi_instance_index'): - query = query.filter_by(mi_index=task.multi_instance_index) + if hasattr(spiff_task, 'internal_data') and 'runtimes' in spiff_task.internal_data: + query = query.filter_by(mi_index=spiff_task.internal_data['runtimes']) - latest_event = query.order_by(TaskEventModel.date.desc()).first() + latest_event = query.order_by(TaskEventModel.date.desc()).first() if latest_event: if latest_event.form_data is not None: return latest_event.form_data else: - app.logger.error("missing_form_dat", "We have lost data for workflow %i, task %s, it is not " - "in the task event model, " - "and it should be." % (workflow_id, task.task_spec.name)) + app.logger.error("missing_form_data", "We have lost data for workflow %i, " + "task %s, it is not in the task event model, " + "and it should be." % (workflow_id, spiff_task.task_spec.name)) return {} else: return {} @@ -294,8 +294,8 @@ class WorkflowService(object): props = {} if hasattr(spiff_task.task_spec, 'extensions'): - for id, val in spiff_task.task_spec.extensions.items(): - props[id] = val + for key, val in spiff_task.task_spec.extensions.items(): + props[key] = val task = Task(spiff_task.id, spiff_task.task_spec.name, From 939c9e67a8a0d08df34bc0d3dc2301eb97874452 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 29 Jun 2020 12:12:52 -0400 Subject: [PATCH 047/101] Moves back to Spiff deploy branch --- Pipfile | 2 +- Pipfile.lock | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Pipfile b/Pipfile index efce9513..2f23d488 100644 --- a/Pipfile +++ b/Pipfile @@ -38,7 +38,7 @@ recommonmark = "*" requests = "*" sentry-sdk = {extras = ["flask"],version = "==0.14.4"} sphinx = "*" -spiffworkflow = {editable = true,git = "https://github.com/sartography/SpiffWorkflow.git",ref = "STG-26"} +spiffworkflow = {editable = true,git = "https://github.com/sartography/SpiffWorkflow.git",ref = "deploy"} swagger-ui-bundle = "*" webtest = "*" werkzeug = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 9b79a526..3e015988 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "45ac71a0a66c2f55518be6fbc93a1b76e6a53ad3c7a557c3cb371d07781698b6" + "sha256": "b9fc0dbbd5869f00fd3ae69fec3c25070d925efe0033092613702748236651ed" }, "pipfile-spec": 6, "requires": { @@ -354,11 +354,11 @@ }, "idna": { "hashes": [ - "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", - "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" + "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", + "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.9" + "version": "==2.10" }, "imagesize": { "hashes": [ @@ -370,11 +370,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:0505dd08068cfec00f53a74a0ad927676d7757da81b7436a6eefe4c7cf75c545", - "sha256:15ec6c0fd909e893e3a08b3a7c76ecb149122fb14b7efe1199ddd4c7c57ea958" + "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83", + "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070" ], "markers": "python_version < '3.8'", - "version": "==1.6.1" + "version": "==1.7.0" }, "inflection": { "hashes": [ @@ -926,7 +926,7 @@ "spiffworkflow": { "editable": true, "git": "https://github.com/sartography/SpiffWorkflow.git", - "ref": "599f41fcf9257196710806e16bef023c836735f4" + "ref": "d8e911545826d2ea577dc28e05d0ecf95c148894" }, "sqlalchemy": { "hashes": [ @@ -1099,11 +1099,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:0505dd08068cfec00f53a74a0ad927676d7757da81b7436a6eefe4c7cf75c545", - "sha256:15ec6c0fd909e893e3a08b3a7c76ecb149122fb14b7efe1199ddd4c7c57ea958" + "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83", + "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070" ], "markers": "python_version < '3.8'", - "version": "==1.6.1" + "version": "==1.7.0" }, "more-itertools": { "hashes": [ From e650a46073a589f4976b72f6a0bea7511bb81a3f Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 29 Jun 2020 16:41:42 -0400 Subject: [PATCH 048/101] Fixes local testing bug where working directory was sometimes wrong. --- crc/__init__.py | 4 ---- crc/services/mails.py | 49 ++++++++++++++++++-------------------- tests/emails/test_mails.py | 8 ++----- 3 files changed, 25 insertions(+), 36 deletions(-) diff --git a/crc/__init__.py b/crc/__init__.py index d169b547..d56085d0 100644 --- a/crc/__init__.py +++ b/crc/__init__.py @@ -58,10 +58,6 @@ if app.config['ENABLE_SENTRY']: integrations=[FlaskIntegration()] ) -# Jinja environment definition, used to render mail templates -template_dir = os.getcwd() + '/crc/static/templates/mails' -env = Environment(loader=FileSystemLoader(template_dir)) - print('=== USING THESE CONFIG SETTINGS: ===') print('APPLICATION_ROOT = ', app.config['APPLICATION_ROOT']) print('CORS_ALLOW_ORIGINS = ', app.config['CORS_ALLOW_ORIGINS']) diff --git a/crc/services/mails.py b/crc/services/mails.py index a1570035..7bffacee 100644 --- a/crc/services/mails.py +++ b/crc/services/mails.py @@ -1,18 +1,20 @@ -import os - -from flask import render_template, render_template_string from flask_mail import Message +from jinja2 import Environment, FileSystemLoader +from crc import app, mail from crc.services.email_service import EmailService +# Jinja environment definition, used to render mail templates +template_dir = app.root_path + '/static/templates/mails' +env = Environment(loader=FileSystemLoader(template_dir)) + def send_test_email(sender, recipients): try: msg = Message('Research Ramp-up Plan test', - sender=sender, - recipients=recipients, - bcc=['rrt_emails@googlegroups.com']) - from crc import env, mail + sender=sender, + recipients=recipients, + bcc=['rrt_emails@googlegroups.com']) template = env.get_template('ramp_up_approval_request_first_review.txt') template_vars = {'primary_investigator': "test"} msg.body = template.render(template_vars) @@ -22,12 +24,13 @@ def send_test_email(sender, recipients): except Exception as e: return str(e) + def send_mail(subject, sender, recipients, content, content_html, study_id=None): EmailService.add_email(subject=subject, sender=sender, recipients=recipients, - content=content, content_html=content_html, study_id=study_id) + content=content, content_html=content_html, study_id=study_id) + def send_ramp_up_submission_email(sender, recipients, approver_1, approver_2=None): - from crc import env subject = 'Research Ramp-up Plan Submitted' template = env.get_template('ramp_up_submission.txt') @@ -36,11 +39,10 @@ def send_ramp_up_submission_email(sender, recipients, approver_1, approver_2=Non template = env.get_template('ramp_up_submission.html') content_html = template.render(template_vars) - result = send_mail(subject, sender, recipients, content, content_html) - return result + send_mail(subject, sender, recipients, content, content_html) + def send_ramp_up_approval_request_email(sender, recipients, primary_investigator): - from crc import env subject = 'Research Ramp-up Plan Approval Request' template = env.get_template('ramp_up_approval_request.txt') @@ -49,11 +51,10 @@ def send_ramp_up_approval_request_email(sender, recipients, primary_investigator template = env.get_template('ramp_up_approval_request.html') content_html = template.render(template_vars) - result = send_mail(subject, sender, recipients, content, content_html) - return result + send_mail(subject, sender, recipients, content, content_html) + def send_ramp_up_approval_request_first_review_email(sender, recipients, primary_investigator): - from crc import env subject = 'Research Ramp-up Plan Approval Request' template = env.get_template('ramp_up_approval_request_first_review.txt') @@ -62,11 +63,10 @@ def send_ramp_up_approval_request_first_review_email(sender, recipients, primary template = env.get_template('ramp_up_approval_request_first_review.html') content_html = template.render(template_vars) - result = send_mail(subject, sender, recipients, content, content_html) - return result + send_mail(subject, sender, recipients, content, content_html) + def send_ramp_up_approved_email(sender, recipients, approver_1, approver_2=None): - from crc import env subject = 'Research Ramp-up Plan Approved' template = env.get_template('ramp_up_approved.txt') @@ -75,11 +75,10 @@ def send_ramp_up_approved_email(sender, recipients, approver_1, approver_2=None) template = env.get_template('ramp_up_approved.html') content_html = template.render(template_vars) - result = send_mail(subject, sender, recipients, content, content_html) - return result + send_mail(subject, sender, recipients, content, content_html) + def send_ramp_up_denied_email(sender, recipients, approver): - from crc import env subject = 'Research Ramp-up Plan Denied' template = env.get_template('ramp_up_denied.txt') @@ -88,11 +87,10 @@ def send_ramp_up_denied_email(sender, recipients, approver): template = env.get_template('ramp_up_denied.html') content_html = template.render(template_vars) - result = send_mail(subject, sender, recipients, content, content_html) - return result + send_mail(subject, sender, recipients, content, content_html) + def send_ramp_up_denied_email_to_approver(sender, recipients, primary_investigator, approver_2): - from crc import env subject = 'Research Ramp-up Plan Denied' template = env.get_template('ramp_up_denied_first_approver.txt') @@ -101,5 +99,4 @@ def send_ramp_up_denied_email_to_approver(sender, recipients, primary_investigat template = env.get_template('ramp_up_denied_first_approver.html') content_html = template.render(template_vars) - result = send_mail(subject, sender, recipients, content, content_html) - return result + send_mail(subject, sender, recipients, content, content_html) diff --git a/tests/emails/test_mails.py b/tests/emails/test_mails.py index 0710e02e..e9320f4d 100644 --- a/tests/emails/test_mails.py +++ b/tests/emails/test_mails.py @@ -1,8 +1,4 @@ - -from tests.base_test import BaseTest - -from crc import mail, session -from crc.models.approval import ApprovalModel, ApprovalStatus +from crc import mail from crc.models.email import EmailModel from crc.services.mails import ( send_ramp_up_submission_email, @@ -12,6 +8,7 @@ from crc.services.mails import ( send_ramp_up_denied_email, send_ramp_up_denied_email_to_approver ) +from tests.base_test import BaseTest class TestMails(BaseTest): @@ -30,7 +27,6 @@ class TestMails(BaseTest): def test_send_ramp_up_submission_email(self): with mail.record_messages() as outbox: - send_ramp_up_submission_email(self.sender, self.recipients, self.approver_1) self.assertEqual(len(outbox), 1) self.assertEqual(outbox[0].subject, 'Research Ramp-up Plan Submitted') From 4ff47c030cc60b53a1987b87d5a75ca3fff5ea25 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Mon, 29 Jun 2020 17:03:57 -0400 Subject: [PATCH 049/101] Updaing to the latest spiffworkflow, and fixing README for running tests. --- Pipfile | 2 +- Pipfile.lock | 70 +++------------------------------------------------- README.md | 21 ++++++---------- 3 files changed, 12 insertions(+), 81 deletions(-) diff --git a/Pipfile b/Pipfile index 2f23d488..0e5e21dd 100644 --- a/Pipfile +++ b/Pipfile @@ -38,7 +38,7 @@ recommonmark = "*" requests = "*" sentry-sdk = {extras = ["flask"],version = "==0.14.4"} sphinx = "*" -spiffworkflow = {editable = true,git = "https://github.com/sartography/SpiffWorkflow.git",ref = "deploy"} +spiffworkflow = {editable = true,git = "https://github.com/sartography/SpiffWorkflow.git",ref = "master"} swagger-ui-bundle = "*" webtest = "*" werkzeug = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 3e015988..27dec886 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b9fc0dbbd5869f00fd3ae69fec3c25070d925efe0033092613702748236651ed" + "sha256": "97a15c4ade88db2b384d52436633889a4d9b0bdcaeea86b8a679ebda6f73fb59" }, "pipfile-spec": 6, "requires": { @@ -35,7 +35,6 @@ "sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b", "sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.6.0" }, "aniso8601": { @@ -50,7 +49,6 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "babel": { @@ -58,7 +56,6 @@ "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.0" }, "bcrypt": { @@ -82,7 +79,6 @@ "sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7", "sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.1.7" }, "beautifulsoup4": { @@ -111,7 +107,6 @@ "sha256:ef17d7dffde7fc73ecab3a3b6389d93d3213bac53fa7f28e68e33647ad50b916", "sha256:fd77e4248bb1b7af5f7922dd8e81156f540306e3a5c4b1c24167c1f5f06025da" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.4.6" }, "certifi": { @@ -166,7 +161,6 @@ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "clickclick": { @@ -188,7 +182,6 @@ "sha256:2ca44140ee259b5e3d8aaf47c79c36a7ab0d5e94d70bd4105c03ede7a20ea5a1", "sha256:cffc044844040c7ce04e9acd1838b5f2e5fa3170182f6fda4d2ea8b0099dbadd" ], - "markers": "python_version >= '3.6'", "version": "==5.0.0" }, "connexion": { @@ -244,7 +237,6 @@ "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==0.16" }, "docxtpl": { @@ -327,14 +319,12 @@ "sha256:0b656fbf87c5f24109d859bafa791d29751fabbda2302b606881ae5485b557a5", "sha256:fcfe6df52cd2ed8a63008ca36b86a51fa7a4b70cef1c39e5625f722fca32308e" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.3" }, "future": { "hashes": [ "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.18.2" }, "gunicorn": { @@ -357,7 +347,6 @@ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, "imagesize": { @@ -365,7 +354,6 @@ "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.2.0" }, "importlib-metadata": { @@ -381,7 +369,6 @@ "sha256:88b101b2668a1d81d6d72d4c2018e53bc6c7fc544c987849da1c7f77545c3bc9", "sha256:f576e85132d34f5bf7df5183c2c6f94cfb32e528f53065345cf71329ba0b8924" ], - "markers": "python_version >= '3.5'", "version": "==0.5.0" }, "itsdangerous": { @@ -389,7 +376,6 @@ "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.0" }, "jdcal": { @@ -404,7 +390,6 @@ "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.2" }, "jsonschema": { @@ -419,16 +404,11 @@ "sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a", "sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.6.11" }, "ldap3": { "hashes": [ "sha256:17f04298b70bf7ecaa5db8a7d8622b5a962ef7fc2b245b2eea705ac1c24338c0", - "sha256:298769ab0232b3a3efa1e84881096c24526fe37911c83a11285f222fe4975efd", - "sha256:4fd2db72d0412cc16ee86be01332095e86e361329c3579b314231eb2e56c7871", - "sha256:52ab557b3c4908db4a90bea16731aa714b1b54e039b54fd4c4b83994c6c48c0c", - "sha256:53aaae5bf14f3827c69600ddf4d61b88f49c055bb93060e9702c5bafd206c744", "sha256:81df4ac8b6df10fb1f05b17c18d0cb8c4c344d5a03083c382824960ed959cf5b" ], "index": "pypi", @@ -472,7 +452,6 @@ "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27", "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.3" }, "markdown": { @@ -519,7 +498,6 @@ "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "marshmallow": { @@ -575,7 +553,6 @@ "sha256:df1889701e2dfd8ba4dc9b1a010f0a60950077fb5242bb92c8b5c7f1a6f2668a", "sha256:fa1fe75b4a9e18b66ae7f0b122543c42debcf800aaafa0212aaff3ad273c2596" ], - "markers": "python_version >= '3.6'", "version": "==1.19.0" }, "openapi-spec-validator": { @@ -598,7 +575,6 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pandas": { @@ -661,19 +637,8 @@ }, "pyasn1": { "hashes": [ - "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", - "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", - "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", - "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", - "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", - "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", - "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", - "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", - "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", - "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba" ], "version": "==0.4.8" }, @@ -682,7 +647,6 @@ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.20" }, "pygments": { @@ -690,7 +654,6 @@ "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" ], - "markers": "python_version >= '3.5'", "version": "==2.6.1" }, "pyjwt": { @@ -706,7 +669,6 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pyrsistent": { @@ -733,9 +695,7 @@ "hashes": [ "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", - "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8", - "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77", - "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522" + "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8" ], "version": "==1.0.4" }, @@ -849,7 +809,6 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "snowballstemmer": { @@ -864,7 +823,6 @@ "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" ], - "markers": "python_version >= '3.5'", "version": "==2.0.1" }, "sphinx": { @@ -880,7 +838,6 @@ "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" ], - "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-devhelp": { @@ -888,7 +845,6 @@ "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" ], - "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-htmlhelp": { @@ -896,7 +852,6 @@ "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" ], - "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-jsmath": { @@ -904,7 +859,6 @@ "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" ], - "markers": "python_version >= '3.5'", "version": "==1.0.1" }, "sphinxcontrib-qthelp": { @@ -912,7 +866,6 @@ "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" ], - "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-serializinghtml": { @@ -920,13 +873,12 @@ "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc", "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a" ], - "markers": "python_version >= '3.5'", "version": "==1.1.4" }, "spiffworkflow": { "editable": true, "git": "https://github.com/sartography/SpiffWorkflow.git", - "ref": "d8e911545826d2ea577dc28e05d0ecf95c148894" + "ref": "4d16fe9727bf2033d6f651ed0dece20693d54025" }, "sqlalchemy": { "hashes": [ @@ -959,7 +911,6 @@ "sha256:f57be5673e12763dd400fea568608700a63ce1c6bd5bdbc3cc3a2c5fdb045274", "sha256:fc728ece3d5c772c196fd338a99798e7efac7a04f9cb6416299a3638ee9a94cd" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.18" }, "swagger-ui-bundle": { @@ -976,7 +927,6 @@ "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==1.25.9" }, "vine": { @@ -984,7 +934,6 @@ "sha256:133ee6d7a9016f177ddeaf191c1f58421a1dcc6ee9a42c58b34bed40e1d2cd87", "sha256:ea4947cc56d1fd6f2095c8d543ee25dad966f78692528e68b4fada11ba3f98af" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.0" }, "waitress": { @@ -992,7 +941,6 @@ "sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261", "sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==1.4.4" }, "webob": { @@ -1000,7 +948,6 @@ "sha256:a3c89a8e9ba0aeb17382836cdb73c516d0ecf6630ec40ec28288f3ed459ce87b", "sha256:aa3a917ed752ba3e0b242234b2a373f9c4e2a75d35291dcbe977649bd21fd108" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.8.6" }, "webtest": { @@ -1047,7 +994,6 @@ "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" ], - "markers": "python_version >= '3.6'", "version": "==3.1.0" } }, @@ -1057,7 +1003,6 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "coverage": { @@ -1110,7 +1055,6 @@ "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" ], - "markers": "python_version >= '3.5'", "version": "==8.4.0" }, "packaging": { @@ -1118,7 +1062,6 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pbr": { @@ -1134,7 +1077,6 @@ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.13.1" }, "py": { @@ -1142,7 +1084,6 @@ "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.9.0" }, "pyparsing": { @@ -1150,7 +1091,6 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pytest": { @@ -1166,7 +1106,6 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "wcwidth": { @@ -1181,7 +1120,6 @@ "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" ], - "markers": "python_version >= '3.6'", "version": "==3.1.0" } } diff --git a/README.md b/README.md index a8191a67..47789892 100644 --- a/README.md +++ b/README.md @@ -47,22 +47,15 @@ run configuration so it doesn't go away.) : Just click the "Play" button next to RUN in the top right corner of the screen. The Swagger based view of the API will be avialable at http://0.0.0.0:5000/v1.0/ui/ -### Testing from the Shell -This app includes a command line interface that will read in BPMN files and let you -play with it at the command line. To run it right click on app/command_line/joke.py and -click run. Type "?" to get a list of commands. -So far the joke system will work a little, when you file it up try these commands -in this order: -```bash -> engine (this will run all tasks up to first user task and should print a joke) -> answer clock (this is the correct answer) -> next (this completes the user task) -> engine (this runs the rest of the tasks, and should tell you that you got the question right) +### Running Tests +We use pytest to execute tests. You can run this from the command line with: ``` +pipenv run coverage run -m pytest +``` +To run the tests within PyCharm set up a run configuration using pytest (Go to Run, configurations, click the +plus icon, select Python Tests, and under this select pytest, defaults should work good-a-plenty with no +additional edits required.) -You can try re-running this and getting the question wrong. -You might open up the Joke bpmn diagram so you can see what this looks like to -draw out. ## Documentation Additional Documentation is available on [ReadTheDocs](https://cr-connect-workflow.readthedocs.io/en/latest/#) From d3ce1af1ce2e98191ff6086222a08f3530602e50 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 30 Jun 2020 10:00:22 -0400 Subject: [PATCH 050/101] Provides some basic tools for getting additional information about a lookup field. Adds an optional 'id' parameter to the lookup endpoint so you can find a specific entry in the lookup table. Makes sure the data attribute returned on a lookup model is a dictionary, and not a string. Fixes a previous bug that would crop up in double spaces were used when performing a search. --- crc/api.yml | 7 ++++ crc/api/workflow.py | 9 +++--- crc/models/file.py | 1 + crc/services/lookup_service.py | 59 ++++++++++++++++++---------------- tests/test_lookup_service.py | 12 +++++++ tests/test_tasks_api.py | 24 ++++++++++++++ 6 files changed, 79 insertions(+), 33 deletions(-) diff --git a/crc/api.yml b/crc/api.yml index 3f72bf7f..0bf8bd6f 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -709,6 +709,13 @@ paths: description: The total number of records to return, defaults to 10. schema: type: integer + - name: id + in: query + required: false + description: Rather than supplying a query, you can specify a speicific id to get data back on a lookup field. + schema: + type: string + get: operationId: crc.api.workflow.lookup summary: Provides type-ahead search against a lookup table associted with a form field. diff --git a/crc/api/workflow.py b/crc/api/workflow.py index 68b84f56..cee2f4cf 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -41,7 +41,6 @@ def get_workflow_specification(spec_id): def validate_workflow_specification(spec_id): - errors = [] try: WorkflowService.test_spec(spec_id) @@ -57,7 +56,6 @@ def validate_workflow_specification(spec_id): return ApiErrorSchema(many=True).dump(errors) - def update_workflow_specification(spec_id, body): if spec_id is None: raise ApiError('unknown_spec', 'Please provide a valid Workflow Spec ID.') @@ -200,7 +198,7 @@ def delete_workflow_spec_category(cat_id): session.commit() -def lookup(workflow_id, field_id, query, limit): +def lookup(workflow_id, field_id, query=None, limit=10, id=None): """ given a field in a task, attempts to find the lookup table or function associated with that field and runs a full-text query against it to locate the values and @@ -208,14 +206,15 @@ def lookup(workflow_id, field_id, query, limit): Tries to be fast, but first runs will be very slow. """ workflow = session.query(WorkflowModel).filter(WorkflowModel.id == workflow_id).first() - lookup_data = LookupService.lookup(workflow, field_id, query, limit) + lookup_data = LookupService.lookup(workflow, field_id, query, limit, id) return LookupDataSchema(many=True).dump(lookup_data) def __get_user_uid(user_uid): if 'user' in g: if g.user.uid not in app.config['ADMIN_UIDS'] and user_uid != g.user.uid: - raise ApiError("permission_denied", "You are not authorized to edit the task data for this workflow.", status_code=403) + raise ApiError("permission_denied", "You are not authorized to edit the task data for this workflow.", + status_code=403) else: return g.user.uid diff --git a/crc/models/file.py b/crc/models/file.py index 15a48709..ccfbdc56 100644 --- a/crc/models/file.py +++ b/crc/models/file.py @@ -153,6 +153,7 @@ class LookupFileModel(db.Model): file_data_model_id = db.Column(db.Integer, db.ForeignKey('file_data.id')) dependencies = db.relationship("LookupDataModel", lazy="select", backref="lookup_file_model", cascade="all, delete, delete-orphan") + class LookupDataModel(db.Model): __tablename__ = 'lookup_data' id = db.Column(db.Integer, primary_key=True) diff --git a/crc/services/lookup_service.py b/crc/services/lookup_service.py index b3e0bddc..876fda80 100644 --- a/crc/services/lookup_service.py +++ b/crc/services/lookup_service.py @@ -1,7 +1,8 @@ import logging import re -from pandas import ExcelFile +import pandas as pd +from pandas import ExcelFile, np from sqlalchemy import func, desc from sqlalchemy.sql.functions import GenericFunction @@ -62,14 +63,14 @@ class LookupService(object): return lookup_model @staticmethod - def lookup(workflow, field_id, query, limit): + def lookup(workflow, field_id, query, limit, id = None): lookup_model = LookupService.__get_lookup_model(workflow, field_id) if lookup_model.is_ldap: return LookupService._run_ldap_query(query, limit) else: - return LookupService._run_lookup_query(lookup_model, query, limit) + return LookupService._run_lookup_query(lookup_model, query, limit, id) @@ -130,6 +131,7 @@ class LookupService(object): changed. """ xls = ExcelFile(data_model.data) df = xls.parse(xls.sheet_names[0]) # Currently we only look at the fist sheet. + df = pd.DataFrame(df).replace({np.nan: None}) if value_column not in df: raise ApiError("invalid_emum", "The file %s does not contain a column named % s" % (data_model.file_model.name, @@ -149,39 +151,40 @@ class LookupService(object): lookup_data = LookupDataModel(lookup_file_model=lookup_model, value=row[value_column], label=row[label_column], - data=row.to_json()) + data=row.to_dict()) db.session.add(lookup_data) db.session.commit() return lookup_model @staticmethod - def _run_lookup_query(lookup_file_model, query, limit): + def _run_lookup_query(lookup_file_model, query, limit, lookup_id): db_query = LookupDataModel.query.filter(LookupDataModel.lookup_file_model == lookup_file_model) + if lookup_id is not None: # Then just find the model with that id. + db_query = db_query.filter(LookupDataModel.id == lookup_id) + else: + # Build a full text query that takes all the terms provided and executes each term as a prefix query, and + # OR's those queries together. The order of the results is handled as a standard "Like" on the original + # string which seems to work intuitively for most entries. + query = re.sub('[^A-Za-z0-9 ]+', '', query) # Strip out non ascii characters. + query = re.sub(' {2,}', ' ', query) # Convert multiple spaces to just one space, as we split on spaces. + print("Query: " + query) + query = query.strip() + if len(query) > 0: + if ' ' in query: + terms = query.split(' ') + new_terms = ["'%s'" % query] + for t in terms: + new_terms.append("%s:*" % t) + new_query = ' | '.join(new_terms) + else: + new_query = "%s:*" % query - query = re.sub('[^A-Za-z0-9 ]+', '', query) - print("Query: " + query) - query = query.strip() - if len(query) > 0: - if ' ' in query: - terms = query.split(' ') - new_terms = ["'%s'" % query] - for t in terms: - new_terms.append("%s:*" % t) - new_query = ' | '.join(new_terms) - else: - new_query = "%s:*" % query + # Run the full text query + db_query = db_query.filter(LookupDataModel.label.match(new_query)) + # But hackishly order by like, which does a good job of + # pulling more relevant matches to the top. + db_query = db_query.order_by(desc(LookupDataModel.label.like("%" + query + "%"))) - # Run the full text query - db_query = db_query.filter(LookupDataModel.label.match(new_query)) - # But hackishly order by like, which does a good job of - # pulling more relevant matches to the top. - db_query = db_query.order_by(desc(LookupDataModel.label.like("%" + query + "%"))) - #ORDER BY name LIKE concat('%', ticker, '%') desc, rank DESC - -# db_query = db_query.order_by(desc(func.full_text.ts_rank( -# func.to_tsvector(LookupDataModel.label), -# func.to_tsquery(query)))) - from sqlalchemy.dialects import postgresql logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) result = db_query.limit(limit).all() logging.getLogger('sqlalchemy.engine').setLevel(logging.ERROR) diff --git a/tests/test_lookup_service.py b/tests/test_lookup_service.py index b61e20e2..4b7c180d 100644 --- a/tests/test_lookup_service.py +++ b/tests/test_lookup_service.py @@ -61,6 +61,15 @@ class TestLookupService(BaseTest): lookup_data = session.query(LookupDataModel).filter(LookupDataModel.lookup_file_model == lookup_record).all() self.assertEqual(4, len(lookup_data)) + def test_lookup_based_on_id(self): + spec = BaseTest.load_test_spec('enum_options_from_file') + workflow = self.create_workflow('enum_options_from_file') + processor = WorkflowProcessor(workflow) + processor.do_engine_steps() + results = LookupService.lookup(workflow, "AllTheNames", "", id=1, limit=10) + self.assertEqual(1, len(results), "It is possible to find an item based on the id, rather than as a search") + self.assertIsNotNone(results[0].data) + self.assertIsInstance(results[0].data, dict) def test_some_full_text_queries(self): @@ -114,6 +123,9 @@ class TestLookupService(BaseTest): results = LookupService.lookup(workflow, "AllTheNames", "1 (!-Something", limit=10) self.assertEqual("1 Something", results[0].label, "special characters don't flake out") + results = LookupService.lookup(workflow, "AllTheNames", "1 Something", limit=10) + self.assertEqual("1 Something", results[0].label, "double spaces should not be an issue.") + # 1018 10000 Something Industry diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index c6b09dae..11be07fd 100644 --- a/tests/test_tasks_api.py +++ b/tests/test_tasks_api.py @@ -343,6 +343,30 @@ class TestTasksApi(BaseTest): results = json.loads(rv.get_data(as_text=True)) self.assertEqual(5, len(results)) + def test_lookup_endpoint_for_task_field_using_lookup_entry_id(self): + self.load_example_data() + workflow = self.create_workflow('enum_options_with_search') + # 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'] + rv = self.app.get('/v1.0/workflow/%i/lookup/%s?query=%s&limit=5' % + (workflow.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) + results = json.loads(rv.get_data(as_text=True)) + self.assertEqual(5, len(results)) + rv = self.app.get('/v1.0/workflow/%i/lookup/%s?id=%i' % + (workflow.id, field_id, results[0]['id']), # All records with a word that starts with 'c' + headers=self.logged_in_headers(), + content_type="application/json") + results = json.loads(rv.get_data(as_text=True)) + self.assertEqual(1, len(results)) + self.assertIsInstance(results[0]['data'], dict) + + + def test_lookup_endpoint_for_task_ldap_field_lookup(self): self.load_example_data() workflow = self.create_workflow('ldap_lookup') From f183e12fe56a7a81aef4c4d5fe39ac969b5bce05 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 30 Jun 2020 10:34:16 -0400 Subject: [PATCH 051/101] Provides some basic tools for getting additional information about a lookup field. Adds an optional 'value' parameter to the lookup endpoint so you can find a specific entry in the lookup table. Makes sure the data attribute returned on a lookup model is a dictionary, and not a string. Fixes a previous bug that would crop up if double spaces were used when performing a search. --- crc/api.yml | 12 ++++++------ crc/api/workflow.py | 4 ++-- crc/models/file.py | 1 + crc/services/lookup_service.py | 10 +++++----- tests/test_tasks_api.py | 27 ++++++++++++++++++++++++--- 5 files changed, 38 insertions(+), 16 deletions(-) diff --git a/crc/api.yml b/crc/api.yml index 0bf8bd6f..213e8d15 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -703,18 +703,18 @@ paths: description: The string to search for in the Value column of the lookup table. schema: type: string + - name: value + in: query + required: false + description: An alternative to query, this accepts the specific value or id selected in a dropdown list or auto-complete, and will return the one matching record. Useful for getting additional details about an item selected in a dropdown. + schema: + type: string - name: limit in: query required: false description: The total number of records to return, defaults to 10. schema: type: integer - - name: id - in: query - required: false - description: Rather than supplying a query, you can specify a speicific id to get data back on a lookup field. - schema: - type: string get: operationId: crc.api.workflow.lookup diff --git a/crc/api/workflow.py b/crc/api/workflow.py index cee2f4cf..dc86ac9e 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -198,7 +198,7 @@ def delete_workflow_spec_category(cat_id): session.commit() -def lookup(workflow_id, field_id, query=None, limit=10, id=None): +def lookup(workflow_id, field_id, query=None, value=None, limit=10): """ given a field in a task, attempts to find the lookup table or function associated with that field and runs a full-text query against it to locate the values and @@ -206,7 +206,7 @@ def lookup(workflow_id, field_id, query=None, limit=10, id=None): Tries to be fast, but first runs will be very slow. """ workflow = session.query(WorkflowModel).filter(WorkflowModel.id == workflow_id).first() - lookup_data = LookupService.lookup(workflow, field_id, query, limit, id) + lookup_data = LookupService.lookup(workflow, field_id, query, value, limit) return LookupDataSchema(many=True).dump(lookup_data) diff --git a/crc/models/file.py b/crc/models/file.py index ccfbdc56..5eb50d4e 100644 --- a/crc/models/file.py +++ b/crc/models/file.py @@ -182,6 +182,7 @@ class LookupDataSchema(SQLAlchemyAutoSchema): load_instance = True include_relationships = False include_fk = False # Includes foreign keys + exclude = ['id'] # Do not include the id field, it should never be used via the API. class SimpleFileSchema(ma.Schema): diff --git a/crc/services/lookup_service.py b/crc/services/lookup_service.py index 876fda80..97c9824b 100644 --- a/crc/services/lookup_service.py +++ b/crc/services/lookup_service.py @@ -63,14 +63,14 @@ class LookupService(object): return lookup_model @staticmethod - def lookup(workflow, field_id, query, limit, id = None): + def lookup(workflow, field_id, query, value, limit): lookup_model = LookupService.__get_lookup_model(workflow, field_id) if lookup_model.is_ldap: return LookupService._run_ldap_query(query, limit) else: - return LookupService._run_lookup_query(lookup_model, query, limit, id) + return LookupService._run_lookup_query(lookup_model, query, value, limit) @@ -157,10 +157,10 @@ class LookupService(object): return lookup_model @staticmethod - def _run_lookup_query(lookup_file_model, query, limit, lookup_id): + def _run_lookup_query(lookup_file_model, query, value, limit): db_query = LookupDataModel.query.filter(LookupDataModel.lookup_file_model == lookup_file_model) - if lookup_id is not None: # Then just find the model with that id. - db_query = db_query.filter(LookupDataModel.id == lookup_id) + if value is not None: # Then just find the model with that value + db_query = db_query.filter(LookupDataModel.value == value) else: # Build a full text query that takes all the terms provided and executes each term as a prefix query, and # OR's those queries together. The order of the results is handled as a standard "Like" on the original diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index 11be07fd..236defdc 100644 --- a/tests/test_tasks_api.py +++ b/tests/test_tasks_api.py @@ -357,15 +357,36 @@ class TestTasksApi(BaseTest): self.assert_success(rv) results = json.loads(rv.get_data(as_text=True)) self.assertEqual(5, len(results)) - rv = self.app.get('/v1.0/workflow/%i/lookup/%s?id=%i' % - (workflow.id, field_id, results[0]['id']), # All records with a word that starts with 'c' + rv = self.app.get('/v1.0/workflow/%i/lookup/%s?value=%s' % + (workflow.id, field_id, results[0]['value']), # All records with a word that starts with 'c' headers=self.logged_in_headers(), content_type="application/json") results = json.loads(rv.get_data(as_text=True)) self.assertEqual(1, len(results)) self.assertIsInstance(results[0]['data'], dict) + self.assertNotIn('id', results[0], "Don't include the internal id, that can be very confusing, and should not be used.") - + def test_lookup_endpoint_also_works_for_enum(self): + # Naming here get's a little confusing. fields can be marked as enum or autocomplete. + # In the event of an auto-complete it's a type-ahead search field, for an enum the + # the key/values from the spreadsheet are added directly to the form and it shows up as + # a dropdown. This tests the case of wanting to get additional data when a user selects + # something from a drodown. + self.load_example_data() + workflow = self.create_workflow('enum_options_from_file') + # 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'] + option_id = task.form['fields'][0]['options'][0]['id'] + rv = self.app.get('/v1.0/workflow/%i/lookup/%s?value=%s' % + (workflow.id, field_id, option_id), # All records with a word that starts with 'c' + headers=self.logged_in_headers(), + content_type="application/json") + self.assert_success(rv) + results = json.loads(rv.get_data(as_text=True)) + self.assertEqual(1, len(results)) + self.assertIsInstance(results[0]['data'], dict) def test_lookup_endpoint_for_task_ldap_field_lookup(self): self.load_example_data() From 93bf46354bf43f7412078ad7fa3dbbf43f1ae66a Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 30 Jun 2020 11:12:28 -0400 Subject: [PATCH 052/101] A last minute change to make the API a little clearer and cleaner broke some tests. --- crc/services/lookup_service.py | 22 ++++++++++------------ tests/test_lookup_service.py | 2 +- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/crc/services/lookup_service.py b/crc/services/lookup_service.py index 97c9824b..e9852315 100644 --- a/crc/services/lookup_service.py +++ b/crc/services/lookup_service.py @@ -20,8 +20,8 @@ class TSRank(GenericFunction): package = 'full_text' name = 'ts_rank' -class LookupService(object): +class LookupService(object): """Provides tools for doing lookups for auto-complete fields. This can currently take two forms: 1) Lookup from spreadsheet data associated with a workflow specification. @@ -51,7 +51,7 @@ class LookupService(object): # if not, we need to rebuild the lookup table. is_current = False if lookup_model: - is_current = db.session.query(WorkflowSpecDependencyFile).\ + is_current = db.session.query(WorkflowSpecDependencyFile). \ filter(WorkflowSpecDependencyFile.file_data_id == lookup_model.file_data_model_id).count() if not is_current: @@ -63,7 +63,7 @@ class LookupService(object): return lookup_model @staticmethod - def lookup(workflow, field_id, query, value, limit): + def lookup(workflow, field_id, query, value=None, limit=10): lookup_model = LookupService.__get_lookup_model(workflow, field_id) @@ -72,8 +72,6 @@ class LookupService(object): else: return LookupService._run_lookup_query(lookup_model, query, value, limit) - - @staticmethod def create_lookup_model(workflow_model, field_id): """ @@ -117,8 +115,8 @@ class LookupService(object): is_ldap=True) else: raise ApiError("unknown_lookup_option", - "Lookup supports using spreadsheet options or ldap options, and neither " - "was provided.") + "Lookup supports using spreadsheet options or ldap options, and neither " + "was provided.") db.session.add(lookup_model) db.session.commit() return lookup_model @@ -199,8 +197,8 @@ class LookupService(object): we return a lookup data model.""" user_list = [] for user in users: - user_list.append( {"value": user['uid'], - "label": user['display_name'] + " (" + user['uid'] + ")", - "data": user - }) - return user_list \ No newline at end of file + user_list.append({"value": user['uid'], + "label": user['display_name'] + " (" + user['uid'] + ")", + "data": user + }) + return user_list diff --git a/tests/test_lookup_service.py b/tests/test_lookup_service.py index 4b7c180d..a27427f4 100644 --- a/tests/test_lookup_service.py +++ b/tests/test_lookup_service.py @@ -66,7 +66,7 @@ class TestLookupService(BaseTest): workflow = self.create_workflow('enum_options_from_file') processor = WorkflowProcessor(workflow) processor.do_engine_steps() - results = LookupService.lookup(workflow, "AllTheNames", "", id=1, limit=10) + results = LookupService.lookup(workflow, "AllTheNames", "", value="1000", limit=10) self.assertEqual(1, len(results), "It is possible to find an item based on the id, rather than as a search") self.assertIsNotNone(results[0].data) self.assertIsInstance(results[0].data, dict) From 84973d23512028931b83915f0e16c3e986d23095 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 30 Jun 2020 12:24:48 -0400 Subject: [PATCH 053/101] resolving comments from pull request. --- crc/services/lookup_service.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crc/services/lookup_service.py b/crc/services/lookup_service.py index e9852315..47424ae8 100644 --- a/crc/services/lookup_service.py +++ b/crc/services/lookup_service.py @@ -1,5 +1,6 @@ import logging import re +from collections import OrderedDict import pandas as pd from pandas import ExcelFile, np @@ -149,7 +150,7 @@ class LookupService(object): lookup_data = LookupDataModel(lookup_file_model=lookup_model, value=row[value_column], label=row[label_column], - data=row.to_dict()) + data=row.to_dict(OrderedDict)) db.session.add(lookup_data) db.session.commit() return lookup_model @@ -164,7 +165,7 @@ class LookupService(object): # OR's those queries together. The order of the results is handled as a standard "Like" on the original # string which seems to work intuitively for most entries. query = re.sub('[^A-Za-z0-9 ]+', '', query) # Strip out non ascii characters. - query = re.sub(' {2,}', ' ', query) # Convert multiple spaces to just one space, as we split on spaces. + query = re.sub(r'\s+', ' ', query) # Convert multiple space like characters to just one space, as we split on spaces. print("Query: " + query) query = query.strip() if len(query) > 0: From 340ae68eedac99d86c3cb72b7b5071afcf58d362 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Wed, 1 Jul 2020 11:01:36 -0400 Subject: [PATCH 054/101] Sets PB status to Active by default --- crc/models/study.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crc/models/study.py b/crc/models/study.py index 540ee018..47d4eb8f 100644 --- a/crc/models/study.py +++ b/crc/models/study.py @@ -31,10 +31,8 @@ class StudyModel(db.Model): self.title = pbs.TITLE self.user_uid = pbs.NETBADGEID self.last_updated = pbs.DATE_MODIFIED - self.protocol_builder_status = ProtocolBuilderStatus.INCOMPLETE - if pbs.Q_COMPLETE: - self.protocol_builder_status = ProtocolBuilderStatus.ACTIVE + self.protocol_builder_status = ProtocolBuilderStatus.ACTIVE if pbs.HSRNUMBER: self.protocol_builder_status = ProtocolBuilderStatus.OPEN if self.on_hold: From 817333f26e0105250d1a410af9237bc1fbe1960d Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Thu, 2 Jul 2020 16:10:33 -0600 Subject: [PATCH 055/101] Adding proper exception information --- crc/models/approval.py | 2 +- crc/scripts/email.py | 3 +-- crc/services/approval_service.py | 10 +++++----- crc/services/email_service.py | 1 + crc/services/study_service.py | 2 +- crc/services/workflow_service.py | 11 +++++++---- tests/study/test_study_api.py | 5 +---- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/crc/models/approval.py b/crc/models/approval.py index 0592fbd1..be83ba30 100644 --- a/crc/models/approval.py +++ b/crc/models/approval.py @@ -78,7 +78,7 @@ class Approval(object): instance.approver = LdapService.user_info(model.approver_uid) instance.primary_investigator = LdapService.user_info(model.study.primary_investigator_id) except ApiError as ae: - app.logger.error("Ldap lookup failed for approval record %i" % model.id) + app.logger.error(f'Ldap lookup failed for approval record {model.id}', exc_info=True) doc_dictionary = FileService.get_doc_dictionary() instance.associated_files = [] diff --git a/crc/scripts/email.py b/crc/scripts/email.py index 6f8244dd..855ec8a4 100644 --- a/crc/scripts/email.py +++ b/crc/scripts/email.py @@ -52,8 +52,7 @@ Email Subject ApprvlApprvr1 PIComputingID try: uid = task.workflow.script_engine.evaluate_expression(task, arg) except Exception as e: - app.logger.error(f'Workflow engines could not parse {arg}') - app.logger.error(str(e)) + app.logger.error(f'Workflow engines could not parse {arg}', exc_info=True) continue user_info = LdapService.user_info(uid) email = user_info.email_address diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index e0cd5412..19912207 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -258,7 +258,7 @@ class ApprovalService(object): f'{approver_info.display_name} - ({approver_info.uid})' ) if mail_result: - app.logger.error(mail_result) + app.logger.error(mail_result, exc_info=True) elif status == ApprovalStatus.DECLINED.value: ldap_service = LdapService() pi_user_info = ldap_service.user_info(db_approval.study.primary_investigator_id) @@ -270,7 +270,7 @@ class ApprovalService(object): f'{approver_info.display_name} - ({approver_info.uid})' ) if mail_result: - app.logger.error(mail_result) + app.logger.error(mail_result, exc_info=True) first_approval = ApprovalModel().query.filter_by( study_id=db_approval.study_id, workflow_id=db_approval.workflow_id, status=ApprovalStatus.APPROVED.value, version=db_approval.version).first() @@ -286,7 +286,7 @@ class ApprovalService(object): f'{approver_info.display_name} - ({approver_info.uid})' ) if mail_result: - app.logger.error(mail_result) + app.logger.error(mail_result, exc_info=True) # TODO: Log update action by approver_uid - maybe ? return db_approval @@ -357,7 +357,7 @@ class ApprovalService(object): f'{approver_info.display_name} - ({approver_info.uid})' ) if mail_result: - app.logger.error(mail_result) + app.logger.error(mail_result, exc_info=True) # send rrp approval request for first approver # enhance the second part in case it bombs approver_email = [approver_info.email_address] if approver_info.email_address else app.config['FALLBACK_EMAILS'] @@ -367,7 +367,7 @@ class ApprovalService(object): f'{pi_user_info.display_name} - ({pi_user_info.uid})' ) if mail_result: - app.logger.error(mail_result) + app.logger.error(mail_result, exc_info=True) @staticmethod def _create_approval_files(workflow_data_files, approval): diff --git a/crc/services/email_service.py b/crc/services/email_service.py index 3d78eada..51886e3b 100644 --- a/crc/services/email_service.py +++ b/crc/services/email_service.py @@ -36,6 +36,7 @@ class EmailService(object): mail.send(msg) except Exception as e: + app.logger.error('An exception happened in EmailService', exc_info=True) app.logger.error(str(e)) db.session.add(email_model) diff --git a/crc/services/study_service.py b/crc/services/study_service.py index 142d6166..7cdd40a1 100644 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -137,7 +137,7 @@ class StudyService(object): try: pb_docs = ProtocolBuilderService.get_required_docs(study_id=study_id) except requests.exceptions.ConnectionError as ce: - app.logger.error("Failed to connect to the Protocol Builder - %s" % str(ce)) + app.logger.error(f'Failed to connect to the Protocol Builder - {str(ce)}', exc_info=True) pb_docs = [] else: pb_docs = [] diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 0faf3b76..b7de5bcd 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -255,9 +255,12 @@ class WorkflowService(object): if latest_event.form_data is not None: return latest_event.form_data else: - app.logger.error("missing_form_data", "We have lost data for workflow %i, " - "task %s, it is not in the task event model, " - "and it should be." % (workflow_id, spiff_task.task_spec.name)) + missing_form_error = ( + f'We have lost data for workflow {workflow_id}, ' + f'task {spiff_task.task_spec.name}, it is not in the task event model, ' + f'and it should be.' + ) + app.logger.error("missing_form_data", missing_form_error, exc_info=True) return {} else: return {} @@ -347,7 +350,7 @@ class WorkflowService(object): 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)) + app.logger.error(f'Failed to process task property {str(ue)}', exc_info=True) return props @staticmethod diff --git a/tests/study/test_study_api.py b/tests/study/test_study_api.py index cdae21c5..d034005b 100644 --- a/tests/study/test_study_api.py +++ b/tests/study/test_study_api.py @@ -168,8 +168,6 @@ class TestStudyApi(BaseTest): num_open = 0 for study in json_data: - if study['protocol_builder_status'] == 'INCOMPLETE': # One study in user_studies.json is not q_complete - num_incomplete += 1 if study['protocol_builder_status'] == 'ABANDONED': # One study does not exist in user_studies.json num_abandoned += 1 if study['protocol_builder_status'] == 'ACTIVE': # One study is marked complete without HSR Number @@ -182,8 +180,7 @@ class TestStudyApi(BaseTest): self.assertGreater(num_db_studies_after, num_db_studies_before) self.assertEqual(num_abandoned, 1) self.assertEqual(num_open, 1) - self.assertEqual(num_active, 1) - self.assertEqual(num_incomplete, 1) + self.assertEqual(num_active, 2) self.assertEqual(len(json_data), num_db_studies_after) self.assertEqual(num_open + num_active + num_incomplete + num_abandoned, num_db_studies_after) From f344fe60c0a6f0c728f7da44d28211b381395c39 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 3 Jul 2020 12:20:10 -0400 Subject: [PATCH 056/101] WIP: Adds enum field lookup option data --- crc/models/api_models.py | 10 +-- crc/services/workflow_service.py | 9 +- crc/static/bpmn/group_test/group_test.bpmn | 82 ++++++++++++++++++ crc/static/bpmn/group_test/lookup.xlsx | Bin 0 -> 9125 bytes .../bpmn/sponsor_funding_source/sponsors.xls | Bin 501760 -> 501760 bytes example_data.py | 6 ++ 6 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 crc/static/bpmn/group_test/group_test.bpmn create mode 100644 crc/static/bpmn/group_test/lookup.xlsx diff --git a/crc/models/api_models.py b/crc/models/api_models.py index 361b9183..bb99eebb 100644 --- a/crc/models/api_models.py +++ b/crc/models/api_models.py @@ -61,7 +61,7 @@ class Task(object): class OptionSchema(ma.Schema): class Meta: - fields = ["id", "name"] + fields = ["id", "name", "data"] class ValidationSchema(ma.Schema): @@ -71,15 +71,11 @@ class ValidationSchema(ma.Schema): class FormFieldPropertySchema(ma.Schema): class Meta: - fields = [ - "id", "value" - ] + fields = ["id", "value"] class FormFieldSchema(ma.Schema): class Meta: - fields = [ - "id", "type", "label", "default_value", "options", "validation", "properties", "value" - ] + fields = ["id", "type", "label", "default_value", "options", "validation", "properties", "value"] default_value = marshmallow.fields.String(required=False, allow_none=True) options = marshmallow.fields.List(marshmallow.fields.Nested(OptionSchema)) diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 0faf3b76..e81b9e3c 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -1,4 +1,5 @@ import copy +import json import string from datetime import datetime import random @@ -319,8 +320,8 @@ class WorkflowService(object): 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) + for i, field in enumerate(task.form.fields): + task.form.fields[i] = 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 @@ -391,7 +392,9 @@ class WorkflowService(object): if not hasattr(field, 'options'): field.options = [] for d in data: - field.options.append({"id": d.value, "name": d.label}) + field.options.append({"id": d.value, "name": d.label, "data": d.data}) + + return field @staticmethod def log_task_action(user_uid, workflow_model, spiff_task, action, version): diff --git a/crc/static/bpmn/group_test/group_test.bpmn b/crc/static/bpmn/group_test/group_test.bpmn new file mode 100644 index 00000000..aed16ffd --- /dev/null +++ b/crc/static/bpmn/group_test/group_test.bpmn @@ -0,0 +1,82 @@ + + + + + Flow_1gnws9u + + + + Flow_1xria19 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Flow_1gnws9u + Flow_0zud2rb + + + + lookup_dropdown: {{ lookup_dropdown }} +lookup_checkbox: {{ lookup_checkbox }} +lookup_radio: {{ lookup_radio }} + + + Flow_0zud2rb + Flow_1xria19 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crc/static/bpmn/group_test/lookup.xlsx b/crc/static/bpmn/group_test/lookup.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..2861a655b8cd7b7e04573dba52b47678cb75f1cc GIT binary patch literal 9125 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 delta 76 zcmZp;AlGm~ZbL2$>$l_ICrEBCV%f&X$IZatn3Gze;E`HXl%F@*ku7|302_Dn47TYt61jNih%mT!$K+Lv%1{-_ELI5{88V>*f diff --git a/example_data.py b/example_data.py index 98746c50..80df124d 100644 --- a/example_data.py +++ b/example_data.py @@ -69,6 +69,12 @@ class ExampleDataLoader: db.session.commit() # Pass IRB Review + self.create_spec(id="group_test", + name="group_test", + display_name="Group Test", + description="TBD", + category_id=0, + display_order=0) self.create_spec(id="irb_api_personnel", name="irb_api_personnel", display_name="Personnel", From 73f1c75156b8664860759f9fa5a34c499c69f02c Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sun, 5 Jul 2020 10:25:50 -0400 Subject: [PATCH 057/101] Updates package hashes --- Pipfile.lock | 69 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 27dec886..e091e18b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -35,6 +35,7 @@ "sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b", "sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.6.0" }, "aniso8601": { @@ -49,6 +50,7 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "babel": { @@ -56,6 +58,7 @@ "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.0" }, "bcrypt": { @@ -79,6 +82,7 @@ "sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7", "sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.1.7" }, "beautifulsoup4": { @@ -107,6 +111,7 @@ "sha256:ef17d7dffde7fc73ecab3a3b6389d93d3213bac53fa7f28e68e33647ad50b916", "sha256:fd77e4248bb1b7af5f7922dd8e81156f540306e3a5c4b1c24167c1f5f06025da" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.4.6" }, "certifi": { @@ -161,6 +166,7 @@ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "clickclick": { @@ -182,6 +188,7 @@ "sha256:2ca44140ee259b5e3d8aaf47c79c36a7ab0d5e94d70bd4105c03ede7a20ea5a1", "sha256:cffc044844040c7ce04e9acd1838b5f2e5fa3170182f6fda4d2ea8b0099dbadd" ], + "markers": "python_version >= '3.6'", "version": "==5.0.0" }, "connexion": { @@ -237,6 +244,7 @@ "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==0.16" }, "docxtpl": { @@ -319,12 +327,14 @@ "sha256:0b656fbf87c5f24109d859bafa791d29751fabbda2302b606881ae5485b557a5", "sha256:fcfe6df52cd2ed8a63008ca36b86a51fa7a4b70cef1c39e5625f722fca32308e" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.3" }, "future": { "hashes": [ "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.18.2" }, "gunicorn": { @@ -347,6 +357,7 @@ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, "imagesize": { @@ -354,6 +365,7 @@ "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.2.0" }, "importlib-metadata": { @@ -369,6 +381,7 @@ "sha256:88b101b2668a1d81d6d72d4c2018e53bc6c7fc544c987849da1c7f77545c3bc9", "sha256:f576e85132d34f5bf7df5183c2c6f94cfb32e528f53065345cf71329ba0b8924" ], + "markers": "python_version >= '3.5'", "version": "==0.5.0" }, "itsdangerous": { @@ -376,6 +389,7 @@ "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.0" }, "jdcal": { @@ -390,6 +404,7 @@ "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.2" }, "jsonschema": { @@ -404,11 +419,16 @@ "sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a", "sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.6.11" }, "ldap3": { "hashes": [ "sha256:17f04298b70bf7ecaa5db8a7d8622b5a962ef7fc2b245b2eea705ac1c24338c0", + "sha256:298769ab0232b3a3efa1e84881096c24526fe37911c83a11285f222fe4975efd", + "sha256:4fd2db72d0412cc16ee86be01332095e86e361329c3579b314231eb2e56c7871", + "sha256:52ab557b3c4908db4a90bea16731aa714b1b54e039b54fd4c4b83994c6c48c0c", + "sha256:53aaae5bf14f3827c69600ddf4d61b88f49c055bb93060e9702c5bafd206c744", "sha256:81df4ac8b6df10fb1f05b17c18d0cb8c4c344d5a03083c382824960ed959cf5b" ], "index": "pypi", @@ -452,6 +472,7 @@ "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27", "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.3" }, "markdown": { @@ -498,6 +519,7 @@ "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "marshmallow": { @@ -553,6 +575,7 @@ "sha256:df1889701e2dfd8ba4dc9b1a010f0a60950077fb5242bb92c8b5c7f1a6f2668a", "sha256:fa1fe75b4a9e18b66ae7f0b122543c42debcf800aaafa0212aaff3ad273c2596" ], + "markers": "python_version >= '3.6'", "version": "==1.19.0" }, "openapi-spec-validator": { @@ -565,7 +588,8 @@ }, "openpyxl": { "hashes": [ - "sha256:6e62f058d19b09b95d20ebfbfb04857ad08d0833190516c1660675f699c6186f" + "sha256:6e62f058d19b09b95d20ebfbfb04857ad08d0833190516c1660675f699c6186f", + "sha256:d88dd1480668019684c66cfff3e52a5de4ed41e9df5dd52e008cbf27af0dbf87" ], "index": "pypi", "version": "==3.0.4" @@ -575,6 +599,7 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pandas": { @@ -637,8 +662,19 @@ }, "pyasn1": { "hashes": [ + "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", + "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", + "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", + "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba" + "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", + "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", + "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", + "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", + "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", + "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", + "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" ], "version": "==0.4.8" }, @@ -647,6 +683,7 @@ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.20" }, "pygments": { @@ -654,6 +691,7 @@ "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" ], + "markers": "python_version >= '3.5'", "version": "==2.6.1" }, "pyjwt": { @@ -669,6 +707,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pyrsistent": { @@ -695,7 +734,9 @@ "hashes": [ "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", - "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8" + "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8", + "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77", + "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522" ], "version": "==1.0.4" }, @@ -809,6 +850,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "snowballstemmer": { @@ -823,6 +865,7 @@ "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" ], + "markers": "python_version >= '3.5'", "version": "==2.0.1" }, "sphinx": { @@ -838,6 +881,7 @@ "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" ], + "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-devhelp": { @@ -845,6 +889,7 @@ "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" ], + "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-htmlhelp": { @@ -852,6 +897,7 @@ "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" ], + "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-jsmath": { @@ -859,6 +905,7 @@ "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" ], + "markers": "python_version >= '3.5'", "version": "==1.0.1" }, "sphinxcontrib-qthelp": { @@ -866,6 +913,7 @@ "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" ], + "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-serializinghtml": { @@ -873,6 +921,7 @@ "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc", "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a" ], + "markers": "python_version >= '3.5'", "version": "==1.1.4" }, "spiffworkflow": { @@ -911,6 +960,7 @@ "sha256:f57be5673e12763dd400fea568608700a63ce1c6bd5bdbc3cc3a2c5fdb045274", "sha256:fc728ece3d5c772c196fd338a99798e7efac7a04f9cb6416299a3638ee9a94cd" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.18" }, "swagger-ui-bundle": { @@ -927,6 +977,7 @@ "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==1.25.9" }, "vine": { @@ -934,6 +985,7 @@ "sha256:133ee6d7a9016f177ddeaf191c1f58421a1dcc6ee9a42c58b34bed40e1d2cd87", "sha256:ea4947cc56d1fd6f2095c8d543ee25dad966f78692528e68b4fada11ba3f98af" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.0" }, "waitress": { @@ -941,6 +993,7 @@ "sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261", "sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==1.4.4" }, "webob": { @@ -948,6 +1001,7 @@ "sha256:a3c89a8e9ba0aeb17382836cdb73c516d0ecf6630ec40ec28288f3ed459ce87b", "sha256:aa3a917ed752ba3e0b242234b2a373f9c4e2a75d35291dcbe977649bd21fd108" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.8.6" }, "webtest": { @@ -994,6 +1048,7 @@ "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" ], + "markers": "python_version >= '3.6'", "version": "==3.1.0" } }, @@ -1003,6 +1058,7 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "coverage": { @@ -1055,6 +1111,7 @@ "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" ], + "markers": "python_version >= '3.5'", "version": "==8.4.0" }, "packaging": { @@ -1062,6 +1119,7 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pbr": { @@ -1077,6 +1135,7 @@ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.13.1" }, "py": { @@ -1084,6 +1143,7 @@ "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.9.0" }, "pyparsing": { @@ -1091,6 +1151,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pytest": { @@ -1106,6 +1167,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "wcwidth": { @@ -1120,6 +1182,7 @@ "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" ], + "markers": "python_version >= '3.6'", "version": "==3.1.0" } } From ecff2d79213d71dc93ff03f190e73c58903388dd Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sun, 5 Jul 2020 10:26:01 -0400 Subject: [PATCH 058/101] Fixes some typos --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 47789892..6bd7dd67 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# CrConnectFrontend +# sartography/cr-connect-workflow [![Build Status](https://travis-ci.com/sartography/cr-connect-workflow.svg?branch=master)](https://travis-ci.com/sartography/cr-connect-workflow) @@ -27,7 +27,7 @@ Make sure all of the following are properly installed on your system: - Select the directory where you cloned this repository and click `Ok`. - Expand the `Project Interpreter` section. - Select the `New environment using` radio button and choose `Pipenv` in the dropdown. - - Under `Base interpreter`, select `Python 3.6` + - Under `Base interpreter`, select `Python 3.7` - In the `Pipenv executable` field, enter `/home/your_username_goes_here/.local/bin/pipenv` - Click `Create` ![Project Interpreter](readme_images/new_project.png) From a5cef8775e23c4092ca70f57147b585aab2a2364 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Mon, 6 Jul 2020 12:09:21 -0400 Subject: [PATCH 059/101] * Modifying the StudyInfo script to return both "investigators" and "roles", the investigators argument will return only the current active investigators according to the protocol builder. The "roles" argument returns all possible roles - it is what "investigators" use to be. * The Task.title returned to the front end will now attempt to process the "display_name" property for dot-notation syntax, making it possible to use this for multi-instance tasks, but will work in all cases where we want he title to change based on values in the data model. * Fixing a bug the test_study_api where it wasn't updated when we made recent changes to the different states of a study. * --- crc/scripts/study_info.py | 21 +++++++++++++++-- crc/services/study_service.py | 4 +++- crc/services/workflow_service.py | 8 +++++-- .../multi_instance_parallel.bpmn | 3 +++ tests/study/test_study_api.py | 4 ++-- tests/study/test_study_service.py | 23 +++++++++++++++++-- tests/test_tasks_api.py | 18 ++++++++++----- .../test_workflow_processor_multi_instance.py | 5 ++++ 8 files changed, 71 insertions(+), 15 deletions(-) diff --git a/crc/scripts/study_info.py b/crc/scripts/study_info.py index e336685d..94e35249 100644 --- a/crc/scripts/study_info.py +++ b/crc/scripts/study_info.py @@ -14,7 +14,7 @@ class StudyInfo(Script): """Please see the detailed description that is provided below. """ pb = ProtocolBuilderService() - type_options = ['info', 'investigators', 'details', 'approvals', 'documents', 'protocol'] + type_options = ['info', 'investigators', 'roles', 'details', 'approvals', 'documents', 'protocol'] # This is used for test/workflow validation, as well as documentation. example_data = { @@ -106,11 +106,20 @@ Returns the basic information such as the id and title ### Investigators ### Returns detailed information about related personnel. The order returned is guaranteed to match the order provided in the investigators.xslx reference file. -If possible, detailed information is added in from LDAP about each personnel based on their user_id. +Detailed information is added in from LDAP about each personnel based on their user_id. ``` {investigators_example} ``` +### Investigator Roles ### +Returns a list of all investigator roles, populating any roles with additional information available from +the Protocol Builder and LDAP. Its basically just like Investigators, but it includes all the roles, rather +that just those that were set in Protocol Builder. +``` +{investigators_example} +``` + + ### Details ### Returns detailed information about variable keys read in from the Protocol Builder. @@ -161,6 +170,12 @@ Returns information specific to the protocol. "INVESTIGATORTYPEFULL": "Primary Investigator", "NETBADGEID": "dhf8r" }, + "roles": + { + "INVESTIGATORTYPE": "PI", + "INVESTIGATORTYPEFULL": "Primary Investigator", + "NETBADGEID": "dhf8r" + }, "details": { "IS_IND": 0, @@ -198,6 +213,8 @@ Returns information specific to the protocol. self.add_data_to_task(task, {cmd: schema.dump(study)}) if cmd == 'investigators': self.add_data_to_task(task, {cmd: StudyService().get_investigators(study_id)}) + if cmd == 'roles': + self.add_data_to_task(task, {cmd: StudyService().get_investigators(study_id, all=True)}) if cmd == 'details': self.add_data_to_task(task, {cmd: self.pb.get_study_details(study_id)}) if cmd == 'approvals': diff --git a/crc/services/study_service.py b/crc/services/study_service.py index 142d6166..1f7429cb 100644 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -182,7 +182,7 @@ class StudyService(object): return documents @staticmethod - def get_investigators(study_id): + def get_investigators(study_id, all=False): # Loop through all known investigator types as set in the reference file inv_dictionary = FileService.get_reference_data(FileService.INVESTIGATOR_LIST, 'code') @@ -199,6 +199,8 @@ class StudyService(object): else: inv_dictionary[i_type]['user_id'] = None + if not all: + inv_dictionary = dict(filter(lambda elem: elem[1]['user_id'] is not None, inv_dictionary.items())) return inv_dictionary @staticmethod diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 0faf3b76..09368610 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -207,8 +207,10 @@ class WorkflowService(object): 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 @@ -333,10 +335,12 @@ class WorkflowService(object): # 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'] + try: + task.title = spiff_task.workflow.script_engine.evaluate_expression(spiff_task, task.properties['display_name']) + except Exception as e: + app.logger.info("Failed to set title on task due to type error." + str(e)) elif task.title and ' ' in task.title: task.title = task.title.partition(' ')[2] - return task @staticmethod diff --git a/tests/data/multi_instance_parallel/multi_instance_parallel.bpmn b/tests/data/multi_instance_parallel/multi_instance_parallel.bpmn index dd6215ed..9e53323f 100644 --- a/tests/data/multi_instance_parallel/multi_instance_parallel.bpmn +++ b/tests/data/multi_instance_parallel/multi_instance_parallel.bpmn @@ -17,6 +17,9 @@ + + + SequenceFlow_1p568pp Flow_0ugjw69 diff --git a/tests/study/test_study_api.py b/tests/study/test_study_api.py index cdae21c5..fdf64239 100644 --- a/tests/study/test_study_api.py +++ b/tests/study/test_study_api.py @@ -182,8 +182,8 @@ class TestStudyApi(BaseTest): self.assertGreater(num_db_studies_after, num_db_studies_before) self.assertEqual(num_abandoned, 1) self.assertEqual(num_open, 1) - self.assertEqual(num_active, 1) - self.assertEqual(num_incomplete, 1) + self.assertEqual(num_active, 2) + self.assertEqual(num_incomplete, 0) self.assertEqual(len(json_data), num_db_studies_after) self.assertEqual(num_open + num_active + num_incomplete + num_abandoned, num_db_studies_after) diff --git a/tests/study/test_study_service.py b/tests/study/test_study_service.py index 1c482bcb..1eb020fa 100644 --- a/tests/study/test_study_service.py +++ b/tests/study/test_study_service.py @@ -183,7 +183,7 @@ class TestStudyService(BaseTest): @patch('crc.services.protocol_builder.ProtocolBuilderService.get_investigators') # mock_docs - def test_get_personnel(self, mock_docs): + def test_get_personnel_roles(self, mock_docs): self.load_example_data() # mock out the protocol builder @@ -191,7 +191,7 @@ class TestStudyService(BaseTest): mock_docs.return_value = json.loads(docs_response) workflow = self.create_workflow('docx') # The workflow really doesnt matter in this case. - investigators = StudyService().get_investigators(workflow.study_id) + investigators = StudyService().get_investigators(workflow.study_id, all=True) self.assertEqual(9, len(investigators)) @@ -207,3 +207,22 @@ class TestStudyService(BaseTest): # No value is provided for Department Chair self.assertIsNone(investigators['DEPT_CH']['user_id']) + + @patch('crc.services.protocol_builder.ProtocolBuilderService.get_investigators') # mock_docs + def test_get_study_personnel(self, mock_docs): + self.load_example_data() + + # mock out the protocol builder + docs_response = self.protocol_builder_response('investigators.json') + mock_docs.return_value = json.loads(docs_response) + + workflow = self.create_workflow('docx') # The workflow really doesnt matter in this case. + investigators = StudyService().get_investigators(workflow.study_id, all=False) + + self.assertEqual(3, len(investigators)) + + # dhf8r is in the ldap mock data. + self.assertEqual("dhf8r", investigators['PI']['user_id']) + self.assertEqual("Dan Funk", investigators['PI']['display_name']) # Data from ldap + self.assertEqual("Primary Investigator", investigators['PI']['label']) # Data from xls file. + self.assertEqual("Always", investigators['PI']['display']) # Data from xls file. diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index 236defdc..abbf8707 100644 --- a/tests/test_tasks_api.py +++ b/tests/test_tasks_api.py @@ -322,7 +322,7 @@ class TestTasksApi(BaseTest): self.assertEqual(4, len(navigation)) # Start task, form_task, multi_task, end task self.assertEqual("UserTask", workflow.next_task.type) self.assertEqual(MultiInstanceType.sequential.value, workflow.next_task.multi_instance_type) - self.assertEqual(9, workflow.next_task.multi_instance_count) + self.assertEqual(3, workflow.next_task.multi_instance_count) # Assure that the names for each task are properly updated, so they aren't all the same. self.assertEqual("Primary Investigator", workflow.next_task.properties['display_name']) @@ -480,17 +480,23 @@ class TestTasksApi(BaseTest): workflow = self.create_workflow('multi_instance_parallel') workflow_api = self.get_workflow_api(workflow) - self.assertEqual(12, len(workflow_api.navigation)) + self.assertEqual(6, len(workflow_api.navigation)) ready_items = [nav for nav in workflow_api.navigation if nav['state'] == "READY"] - self.assertEqual(9, len(ready_items)) + self.assertEqual(3, len(ready_items)) self.assertEqual("UserTask", workflow_api.next_task.type) self.assertEqual("MultiInstanceTask",workflow_api.next_task.name) - self.assertEqual("more information", workflow_api.next_task.title) + self.assertEqual("Primary Investigator", workflow_api.next_task.title) - for i in random.sample(range(9), 9): + for i in random.sample(range(3), 3): task = TaskSchema().load(ready_items[i]['task']) - data = workflow_api.next_task.data + rv = self.app.put('/v1.0/workflow/%i/task/%s/set_token' % (workflow.id, 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) + data = workflow.next_task.data data['investigator']['email'] = "dhf8r@virginia.edu" self.complete_form(workflow, task, data) #tasks = self.get_workflow_api(workflow).user_tasks diff --git a/tests/workflow/test_workflow_processor_multi_instance.py b/tests/workflow/test_workflow_processor_multi_instance.py index 76821fed..e1011223 100644 --- a/tests/workflow/test_workflow_processor_multi_instance.py +++ b/tests/workflow/test_workflow_processor_multi_instance.py @@ -146,6 +146,11 @@ class TestWorkflowProcessorMultiInstance(BaseTest): api_task = WorkflowService.spiff_task_to_api_task(task) self.assertEqual(MultiInstanceType.parallel, api_task.multi_instance_type) + + # Assure navigation picks up the label of the current element variable. + nav = WorkflowService.processor_to_workflow_api(processor, task).navigation + self.assertEquals("Primary Investigator", nav[2].title) + task.update_data({"investigator": {"email": "dhf8r@virginia.edu"}}) processor.complete_task(task) processor.do_engine_steps() From 8a661281898b2e66f5f651f62cbed176ca3f35d1 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Mon, 6 Jul 2020 15:34:24 -0400 Subject: [PATCH 060/101] Bug fixes after bumping the version of Spiffworlflow to the latest - which has fixes for sequential multi-instance. --- Pipfile.lock | 145 +++++++++--------- crc/services/workflow_processor.py | 4 +- tests/workflow/test_workflow_processor.py | 2 +- .../test_workflow_processor_multi_instance.py | 6 +- 4 files changed, 83 insertions(+), 74 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 27dec886..91fd4437 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -197,40 +197,43 @@ }, "coverage": { "hashes": [ - "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a", - "sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355", - "sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65", - "sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7", - "sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9", - "sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1", - "sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0", - "sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55", - "sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c", - "sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6", - "sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef", - "sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019", - "sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e", - "sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0", - "sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf", - "sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24", - "sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2", - "sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c", - "sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4", - "sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0", - "sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd", - "sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04", - "sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e", - "sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730", - "sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2", - "sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768", - "sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796", - "sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7", - "sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a", - "sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489", - "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052" + "sha256:0fc4e0d91350d6f43ef6a61f64a48e917637e1dcfcba4b4b7d543c628ef82c2d", + "sha256:10f2a618a6e75adf64329f828a6a5b40244c1c50f5ef4ce4109e904e69c71bd2", + "sha256:12eaccd86d9a373aea59869bc9cfa0ab6ba8b1477752110cb4c10d165474f703", + "sha256:1874bdc943654ba46d28f179c1846f5710eda3aeb265ff029e0ac2b52daae404", + "sha256:1dcebae667b73fd4aa69237e6afb39abc2f27520f2358590c1b13dd90e32abe7", + "sha256:1e58fca3d9ec1a423f1b7f2aa34af4f733cbfa9020c8fe39ca451b6071237405", + "sha256:214eb2110217f2636a9329bc766507ab71a3a06a8ea30cdeebb47c24dce5972d", + "sha256:25fe74b5b2f1b4abb11e103bb7984daca8f8292683957d0738cd692f6a7cc64c", + "sha256:32ecee61a43be509b91a526819717d5e5650e009a8d5eda8631a59c721d5f3b6", + "sha256:3740b796015b889e46c260ff18b84683fa2e30f0f75a171fb10d2bf9fb91fc70", + "sha256:3b2c34690f613525672697910894b60d15800ac7e779fbd0fccf532486c1ba40", + "sha256:41d88736c42f4a22c494c32cc48a05828236e37c991bd9760f8923415e3169e4", + "sha256:42fa45a29f1059eda4d3c7b509589cc0343cd6bbf083d6118216830cd1a51613", + "sha256:4bb385a747e6ae8a65290b3df60d6c8a692a5599dc66c9fa3520e667886f2e10", + "sha256:509294f3e76d3f26b35083973fbc952e01e1727656d979b11182f273f08aa80b", + "sha256:5c74c5b6045969b07c9fb36b665c9cac84d6c174a809fc1b21bdc06c7836d9a0", + "sha256:60a3d36297b65c7f78329b80120f72947140f45b5c7a017ea730f9112b40f2ec", + "sha256:6f91b4492c5cde83bfe462f5b2b997cdf96a138f7c58b1140f05de5751623cf1", + "sha256:7403675df5e27745571aba1c957c7da2dacb537c21e14007ec3a417bf31f7f3d", + "sha256:87bdc8135b8ee739840eee19b184804e5d57f518578ffc797f5afa2c3c297913", + "sha256:8a3decd12e7934d0254939e2bf434bf04a5890c5bf91a982685021786a08087e", + "sha256:9702e2cb1c6dec01fb8e1a64c015817c0800a6eca287552c47a5ee0ebddccf62", + "sha256:a4d511012beb967a39580ba7d2549edf1e6865a33e5fe51e4dce550522b3ac0e", + "sha256:bbb387811f7a18bdc61a2ea3d102be0c7e239b0db9c83be7bfa50f095db5b92a", + "sha256:bfcc811883699ed49afc58b1ed9f80428a18eb9166422bce3c31a53dba00fd1d", + "sha256:c32aa13cc3fe86b0f744dfe35a7f879ee33ac0a560684fef0f3e1580352b818f", + "sha256:ca63dae130a2e788f2b249200f01d7fa240f24da0596501d387a50e57aa7075e", + "sha256:d54d7ea74cc00482a2410d63bf10aa34ebe1c49ac50779652106c867f9986d6b", + "sha256:d67599521dff98ec8c34cd9652cbcfe16ed076a2209625fca9dc7419b6370e5c", + "sha256:d82db1b9a92cb5c67661ca6616bdca6ff931deceebb98eecbd328812dab52032", + "sha256:d9ad0a988ae20face62520785ec3595a5e64f35a21762a57d115dae0b8fb894a", + "sha256:ebf2431b2d457ae5217f3a1179533c456f3272ded16f8ed0b32961a6d90e38ee", + "sha256:ed9a21502e9223f563e071759f769c3d6a2e1ba5328c31e86830368e8d78bc9c", + "sha256:f50632ef2d749f541ca8e6c07c9928a37f87505ce3a9f20c8446ad310f1aa87b" ], "index": "pypi", - "version": "==5.1" + "version": "==5.2" }, "docutils": { "hashes": [ @@ -565,7 +568,8 @@ }, "openpyxl": { "hashes": [ - "sha256:6e62f058d19b09b95d20ebfbfb04857ad08d0833190516c1660675f699c6186f" + "sha256:6e62f058d19b09b95d20ebfbfb04857ad08d0833190516c1660675f699c6186f", + "sha256:d88dd1480668019684c66cfff3e52a5de4ed41e9df5dd52e008cbf27af0dbf87" ], "index": "pypi", "version": "==3.0.4" @@ -827,11 +831,11 @@ }, "sphinx": { "hashes": [ - "sha256:74fbead182a611ce1444f50218a1c5fc70b6cc547f64948f5182fb30a2a20258", - "sha256:97c9e3bcce2f61d9f5edf131299ee9d1219630598d9f9a8791459a4d9e815be5" + "sha256:97dbf2e31fc5684bb805104b8ad34434ed70e6c588f6896991b2fdfd2bef8c00", + "sha256:b9daeb9b39aa1ffefc2809b43604109825300300b987a24f45976c001ba1a8fd" ], "index": "pypi", - "version": "==3.1.1" + "version": "==3.1.2" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -878,7 +882,7 @@ "spiffworkflow": { "editable": true, "git": "https://github.com/sartography/SpiffWorkflow.git", - "ref": "4d16fe9727bf2033d6f651ed0dece20693d54025" + "ref": "e47dbce4147f2475f50ef705eab32a1426540613" }, "sqlalchemy": { "hashes": [ @@ -1007,40 +1011,43 @@ }, "coverage": { "hashes": [ - "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a", - "sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355", - "sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65", - "sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7", - "sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9", - "sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1", - "sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0", - "sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55", - "sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c", - "sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6", - "sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef", - "sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019", - "sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e", - "sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0", - "sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf", - "sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24", - "sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2", - "sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c", - "sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4", - "sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0", - "sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd", - "sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04", - "sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e", - "sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730", - "sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2", - "sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768", - "sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796", - "sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7", - "sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a", - "sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489", - "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052" + "sha256:0fc4e0d91350d6f43ef6a61f64a48e917637e1dcfcba4b4b7d543c628ef82c2d", + "sha256:10f2a618a6e75adf64329f828a6a5b40244c1c50f5ef4ce4109e904e69c71bd2", + "sha256:12eaccd86d9a373aea59869bc9cfa0ab6ba8b1477752110cb4c10d165474f703", + "sha256:1874bdc943654ba46d28f179c1846f5710eda3aeb265ff029e0ac2b52daae404", + "sha256:1dcebae667b73fd4aa69237e6afb39abc2f27520f2358590c1b13dd90e32abe7", + "sha256:1e58fca3d9ec1a423f1b7f2aa34af4f733cbfa9020c8fe39ca451b6071237405", + "sha256:214eb2110217f2636a9329bc766507ab71a3a06a8ea30cdeebb47c24dce5972d", + "sha256:25fe74b5b2f1b4abb11e103bb7984daca8f8292683957d0738cd692f6a7cc64c", + "sha256:32ecee61a43be509b91a526819717d5e5650e009a8d5eda8631a59c721d5f3b6", + "sha256:3740b796015b889e46c260ff18b84683fa2e30f0f75a171fb10d2bf9fb91fc70", + "sha256:3b2c34690f613525672697910894b60d15800ac7e779fbd0fccf532486c1ba40", + "sha256:41d88736c42f4a22c494c32cc48a05828236e37c991bd9760f8923415e3169e4", + "sha256:42fa45a29f1059eda4d3c7b509589cc0343cd6bbf083d6118216830cd1a51613", + "sha256:4bb385a747e6ae8a65290b3df60d6c8a692a5599dc66c9fa3520e667886f2e10", + "sha256:509294f3e76d3f26b35083973fbc952e01e1727656d979b11182f273f08aa80b", + "sha256:5c74c5b6045969b07c9fb36b665c9cac84d6c174a809fc1b21bdc06c7836d9a0", + "sha256:60a3d36297b65c7f78329b80120f72947140f45b5c7a017ea730f9112b40f2ec", + "sha256:6f91b4492c5cde83bfe462f5b2b997cdf96a138f7c58b1140f05de5751623cf1", + "sha256:7403675df5e27745571aba1c957c7da2dacb537c21e14007ec3a417bf31f7f3d", + "sha256:87bdc8135b8ee739840eee19b184804e5d57f518578ffc797f5afa2c3c297913", + "sha256:8a3decd12e7934d0254939e2bf434bf04a5890c5bf91a982685021786a08087e", + "sha256:9702e2cb1c6dec01fb8e1a64c015817c0800a6eca287552c47a5ee0ebddccf62", + "sha256:a4d511012beb967a39580ba7d2549edf1e6865a33e5fe51e4dce550522b3ac0e", + "sha256:bbb387811f7a18bdc61a2ea3d102be0c7e239b0db9c83be7bfa50f095db5b92a", + "sha256:bfcc811883699ed49afc58b1ed9f80428a18eb9166422bce3c31a53dba00fd1d", + "sha256:c32aa13cc3fe86b0f744dfe35a7f879ee33ac0a560684fef0f3e1580352b818f", + "sha256:ca63dae130a2e788f2b249200f01d7fa240f24da0596501d387a50e57aa7075e", + "sha256:d54d7ea74cc00482a2410d63bf10aa34ebe1c49ac50779652106c867f9986d6b", + "sha256:d67599521dff98ec8c34cd9652cbcfe16ed076a2209625fca9dc7419b6370e5c", + "sha256:d82db1b9a92cb5c67661ca6616bdca6ff931deceebb98eecbd328812dab52032", + "sha256:d9ad0a988ae20face62520785ec3595a5e64f35a21762a57d115dae0b8fb894a", + "sha256:ebf2431b2d457ae5217f3a1179533c456f3272ded16f8ed0b32961a6d90e38ee", + "sha256:ed9a21502e9223f563e071759f769c3d6a2e1ba5328c31e86830368e8d78bc9c", + "sha256:f50632ef2d749f541ca8e6c07c9928a37f87505ce3a9f20c8446ad310f1aa87b" ], "index": "pypi", - "version": "==5.1" + "version": "==5.2" }, "importlib-metadata": { "hashes": [ diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index 0af63ff9..50736e5f 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -1,4 +1,6 @@ import re + +from SpiffWorkflow.serializer.exceptions import MissingSpecError from lxml import etree import shlex from datetime import datetime @@ -138,7 +140,7 @@ class WorkflowProcessor(object): workflow_model.bpmn_workflow_json = WorkflowProcessor._serializer.serialize_workflow(self.bpmn_workflow) self.save() - except KeyError as ke: + except MissingSpecError as ke: raise ApiError(code="unexpected_workflow_structure", message="Failed to deserialize workflow" " '%s' version %s, due to a mis-placed or missing task '%s'" % diff --git a/tests/workflow/test_workflow_processor.py b/tests/workflow/test_workflow_processor.py index 30f9150b..44d90cf3 100644 --- a/tests/workflow/test_workflow_processor.py +++ b/tests/workflow/test_workflow_processor.py @@ -187,7 +187,7 @@ 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) - # Attemping a soft update on a structural change should raise a sensible error. + # Attempting a soft update on a structural change should raise a sensible error. with self.assertRaises(ApiError) as context: processor3 = WorkflowProcessor(processor.workflow_model, soft_reset=True) self.assertEqual("unexpected_workflow_structure", context.exception.code) diff --git a/tests/workflow/test_workflow_processor_multi_instance.py b/tests/workflow/test_workflow_processor_multi_instance.py index e1011223..a67cae7f 100644 --- a/tests/workflow/test_workflow_processor_multi_instance.py +++ b/tests/workflow/test_workflow_processor_multi_instance.py @@ -59,7 +59,7 @@ class TestWorkflowProcessorMultiInstance(BaseTest): api_task = workflow_api.next_task self.assertEqual(WorkflowStatus.user_input_required, processor.get_status()) self.assertEqual("dhf8r", api_task.data["investigator"]["user_id"]) - self.assertEqual("MultiInstanceTask", api_task.name) + self.assertTrue(api_task.name.startswith("MultiInstanceTask")) self.assertEqual(3, api_task.multi_instance_count) self.assertEqual(1, api_task.multi_instance_index) @@ -74,7 +74,7 @@ class TestWorkflowProcessorMultiInstance(BaseTest): api_task = workflow_api.next_task self.assertEqual(WorkflowStatus.user_input_required, processor.get_status()) self.assertEqual(None, api_task.data["investigator"]["user_id"]) - self.assertEqual("MultiInstanceTask", api_task.name) + self.assertTrue(api_task.name.startswith("MultiInstanceTask")) self.assertEqual(3, api_task.multi_instance_count) self.assertEqual(2, api_task.multi_instance_index) @@ -89,7 +89,7 @@ class TestWorkflowProcessorMultiInstance(BaseTest): api_task = workflow_api.next_task self.assertEqual(WorkflowStatus.user_input_required, processor.get_status()) self.assertEqual("asd3v", api_task.data["investigator"]["user_id"]) - self.assertEqual("MultiInstanceTask", api_task.name) + self.assertTrue(api_task.name.startswith("MultiInstanceTask")) self.assertEqual(3, api_task.multi_instance_count) self.assertEqual(3, api_task.multi_instance_index) From bb4000ff6dc442e8d98e7ec5de21c6496e1bab59 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Mon, 6 Jul 2020 16:01:43 -0400 Subject: [PATCH 061/101] Don't attept to load up the workflows for abandoned studies. --- crc/services/study_service.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crc/services/study_service.py b/crc/services/study_service.py index 90331f54..f07c82c8 100644 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -64,13 +64,15 @@ class StudyService(object): study.files = list(files) # Calling this line repeatedly is very very slow. It creates the - # master spec and runs it. - status = StudyService.__get_study_status(study_model) - study.warnings = StudyService.__update_status_of_workflow_meta(workflow_metas, status) + # master spec and runs it. Don't execute this for Abandoned studies, as + # we don't have the information to process them. + if study.protocol_builder_status != ProtocolBuilderStatus.ABANDONED: + status = StudyService.__get_study_status(study_model) + study.warnings = StudyService.__update_status_of_workflow_meta(workflow_metas, status) - # Group the workflows into their categories. - for category in study.categories: - category.workflows = {w for w in workflow_metas if w.category_id == category.id} + # Group the workflows into their categories. + for category in study.categories: + category.workflows = {w for w in workflow_metas if w.category_id == category.id} return study From c7b864f9c78118f8e1d96cd3ee1b3037185d7906 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 29 Jun 2020 08:38:38 -0600 Subject: [PATCH 062/101] Cleaning up old notes --- crc/models/approval.py | 18 +++--------------- crc/services/email_service.py | 2 +- tests/emails/test_email_service.py | 13 ++++++++++++- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/crc/models/approval.py b/crc/models/approval.py index be83ba30..df433fac 100644 --- a/crc/models/approval.py +++ b/crc/models/approval.py @@ -57,23 +57,11 @@ class Approval(object): @classmethod def from_model(cls, model: ApprovalModel): - # TODO: Reduce the code by iterating over model's dict keys - instance = cls() - instance.id = model.id - instance.study_id = model.study_id - instance.workflow_id = model.workflow_id - instance.version = model.version - instance.approver_uid = model.approver_uid - instance.status = model.status - instance.message = model.message - instance.date_created = model.date_created - instance.date_approved = model.date_approved - instance.version = model.version - instance.title = '' + args = dict((k, v) for k, v in model.__dict__.items() if not k.startswith('_')) + instance = cls(**args) instance.related_approvals = [] + instance.title = model.study.title if model.study else '' - if model.study: - instance.title = model.study.title try: instance.approver = LdapService.user_info(model.approver_uid) instance.primary_investigator = LdapService.user_info(model.study.primary_investigator_id) diff --git a/crc/services/email_service.py b/crc/services/email_service.py index 51886e3b..f800d900 100644 --- a/crc/services/email_service.py +++ b/crc/services/email_service.py @@ -13,7 +13,7 @@ class EmailService(object): """Provides common tools for working with an Email""" @staticmethod - def add_email(subject, sender, recipients, content, content_html, study_id): + def add_email(subject, sender, recipients, content, content_html, study_id=None): """We will receive all data related to an email and store it""" # Find corresponding study - if any diff --git a/tests/emails/test_email_service.py b/tests/emails/test_email_service.py index e2bcd139..174dca13 100644 --- a/tests/emails/test_email_service.py +++ b/tests/emails/test_email_service.py @@ -31,4 +31,15 @@ class TestEmailService(BaseTest): self.assertEqual(email_model.content_html, content_html) self.assertEqual(email_model.study, study) - # TODO: Create email model without study + subject = 'Email Subject - Empty study' + EmailService.add_email(subject=subject, sender=sender, recipients=recipients, + content=content, content_html=content_html) + + email_model = EmailModel.query.order_by(EmailModel.id.desc()).first() + + self.assertEqual(email_model.subject, subject) + self.assertEqual(email_model.sender, sender) + self.assertEqual(email_model.recipients, str(recipients)) + self.assertEqual(email_model.content, content) + self.assertEqual(email_model.content_html, content_html) + self.assertEqual(email_model.study, None) From a3022088906cac1ba1e65575abc634f0a002c873 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 6 Jul 2020 13:06:50 -0600 Subject: [PATCH 063/101] Updating test for study with approvals --- crc/services/workflow_service.py | 3 ++- tests/approval/test_approvals_api.py | 21 --------------------- tests/base_test.py | 23 +++++++++++++++++++++++ tests/study/test_study_api.py | 18 ++++++++++++++++-- 4 files changed, 41 insertions(+), 24 deletions(-) diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index dfd7b653..e4e873aa 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -384,7 +384,8 @@ class WorkflowService(object): except TypeError as te: raise ApiError.from_task(code="template_error", message="Error processing template for task %s: %s" % (spiff_task.task_spec.name, str(te)), task=spiff_task) - # TODO: Catch additional errors and report back. + except Exception as e: + app.logger.error(str(e), exc_info=True) @staticmethod def process_options(spiff_task, field): diff --git a/tests/approval/test_approvals_api.py b/tests/approval/test_approvals_api.py index ed0f7c5d..03cd8622 100644 --- a/tests/approval/test_approvals_api.py +++ b/tests/approval/test_approvals_api.py @@ -217,27 +217,6 @@ class TestApprovals(BaseTest): total_counts = sum(counts[status] for status in statuses) self.assertEqual(total_counts, len(approvals), 'Total approval counts for user should match number of approvals for user') - def _create_study_workflow_approvals(self, user_uid, title, primary_investigator_id, approver_uids, statuses, - workflow_spec_name="random_fact"): - study = self.create_study(uid=user_uid, title=title, primary_investigator_id=primary_investigator_id) - workflow = self.create_workflow(workflow_name=workflow_spec_name, study=study) - approvals = [] - - for i in range(len(approver_uids)): - approvals.append(self.create_approval( - study=study, - workflow=workflow, - approver_uid=approver_uids[i], - status=statuses[i], - version=1 - )) - - return { - 'study': study, - 'workflow': workflow, - 'approvals': approvals, - } - def _add_lots_of_random_approvals(self, n=100, workflow_spec_name="random_fact"): num_studies_before = db.session.query(StudyModel).count() statuses = [name for name, value in ApprovalStatus.__members__.items()] diff --git a/tests/base_test.py b/tests/base_test.py index 3bdae053..116df5a2 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -240,6 +240,29 @@ class BaseTest(unittest.TestCase): db.session.commit() return study + def _create_study_workflow_approvals(self, user_uid, title, primary_investigator_id, approver_uids, statuses, + workflow_spec_name="random_fact"): + study = self.create_study(uid=user_uid, title=title, primary_investigator_id=primary_investigator_id) + workflow = self.create_workflow(workflow_name=workflow_spec_name, study=study) + approvals = [] + + for i in range(len(approver_uids)): + approvals.append(self.create_approval( + study=study, + workflow=workflow, + approver_uid=approver_uids[i], + status=statuses[i], + version=1 + )) + + full_study = { + 'study': study, + 'workflow': workflow, + 'approvals': approvals, + } + + return full_study + def create_workflow(self, workflow_name, study=None, category_id=None): db.session.flush() spec = db.session.query(WorkflowSpecModel).filter(WorkflowSpecModel.name == workflow_name).first() diff --git a/tests/study/test_study_api.py b/tests/study/test_study_api.py index d3a76673..ea5a86e6 100644 --- a/tests/study/test_study_api.py +++ b/tests/study/test_study_api.py @@ -7,6 +7,7 @@ from unittest.mock import patch from crc import session, app from crc.models.protocol_builder import ProtocolBuilderStatus, \ ProtocolBuilderStudySchema +from crc.models.approval import ApprovalStatus from crc.models.stats import TaskEventModel from crc.models.study import StudyModel, StudySchema from crc.models.workflow import WorkflowSpecModel, WorkflowModel @@ -95,8 +96,21 @@ class TestStudyApi(BaseTest): # TODO: WRITE A TEST FOR STUDY FILES def test_get_study_has_details_about_approvals(self): - # TODO: WRITE A TEST FOR STUDY APPROVALS - pass + self.load_example_data() + full_study = self._create_study_workflow_approvals( + user_uid="dhf8r", title="first study", primary_investigator_id="lb3dp", + approver_uids=["lb3dp", "dhf8r"], statuses=[ApprovalStatus.PENDING.value, ApprovalStatus.PENDING.value] + ) + + api_response = self.app.get('/v1.0/study/%i' % full_study['study'].id, + headers=self.logged_in_headers(), content_type="application/json") + self.assert_success(api_response) + study = StudySchema().loads(api_response.get_data(as_text=True)) + + self.assertEqual(len(study.approvals), 2) + + for approval in study.approvals: + self.assertEqual(full_study['study'].title, approval['title']) def test_add_study(self): self.load_example_data() From b4e127b3b7a164de65cbcd2d4613f3a598c52403 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 6 Jul 2020 18:52:02 -0600 Subject: [PATCH 064/101] Cleaning properly previous versions --- crc/services/approval_service.py | 25 ++++++++++++-------- tests/approval/test_approvals_service.py | 30 +++++++++++++++++++++++- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index 19912207..56206c9c 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -299,11 +299,12 @@ class ApprovalService(object): pending approvals and create a new approval for the latest version of the workflow.""" - # Find any existing approvals for this workflow and approver. - latest_approval_request = db.session.query(ApprovalModel). \ + # Find any existing approvals for this workflow. + latest_approval_requests = db.session.query(ApprovalModel). \ filter(ApprovalModel.workflow_id == workflow_id). \ - filter(ApprovalModel.approver_uid == approver_uid). \ - order_by(desc(ApprovalModel.version)).first() + order_by(desc(ApprovalModel.version)) + + latest_approver_request = latest_approval_requests.filter(ApprovalModel.approver_uid == approver_uid).first() # Construct as hash of the latest files to see if things have changed since # the last approval. @@ -318,16 +319,20 @@ class ApprovalService(object): # If an existing approval request exists and no changes were made, do nothing. # If there is an existing approval request for a previous version of the workflow # then add a new request, and cancel any waiting/pending requests. - if latest_approval_request: - request_file_ids = list(file.file_data_id for file in latest_approval_request.approval_files) + if latest_approver_request: + request_file_ids = list(file.file_data_id for file in latest_approver_request.approval_files) current_data_file_ids.sort() request_file_ids.sort() + other_approver = latest_approval_requests.filter(ApprovalModel.approver_uid != approver_uid).first() if current_data_file_ids == request_file_ids: - return # This approval already exists. + return # This approval already exists or we're updating other approver. else: - latest_approval_request.status = ApprovalStatus.CANCELED.value - db.session.add(latest_approval_request) - version = latest_approval_request.version + 1 + for approval_request in latest_approval_requests: + if (approval_request.version == latest_approver_request.version and + approval_request.status != ApprovalStatus.CANCELED.value): + approval_request.status = ApprovalStatus.CANCELED.value + db.session.add(approval_request) + version = latest_approver_request.version + 1 else: version = 1 diff --git a/tests/approval/test_approvals_service.py b/tests/approval/test_approvals_service.py index 34871fec..dae15eee 100644 --- a/tests/approval/test_approvals_service.py +++ b/tests/approval/test_approvals_service.py @@ -1,7 +1,7 @@ from tests.base_test import BaseTest from crc import db from crc.models.approval import ApprovalModel -from crc.services.approval_service import ApprovalService +from crc.services.approval_service import ApprovalService, ApprovalStatus from crc.services.file_service import FileService from crc.services.workflow_processor import WorkflowProcessor @@ -83,6 +83,34 @@ class TestApprovalsService(BaseTest): self.assertEqual(len(records), 2) + def test_new_approval_cancels_all_previous_approvals(self): + self.create_reference_document() + workflow = self.create_workflow("empty_workflow") + FileService.add_workflow_file(workflow_id=workflow.id, + name="anything.png", content_type="text", + binary_data=b'5678', irb_doc_code="UVACompl_PRCAppr" ) + ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r") + ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="lb3dp") + + current_count = ApprovalModel.query.count() + self.assertTrue(current_count, 2) + + FileService.add_workflow_file(workflow_id=workflow.id, + name="borderline.png", content_type="text", + binary_data=b'906090', irb_doc_code="AD_CoCAppr" ) + + ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r") + + current_count = ApprovalModel.query.count() + canceled_count = ApprovalModel.query.filter(ApprovalModel.status == ApprovalStatus.CANCELED.value) + self.assertTrue(current_count, 2) + self.assertTrue(current_count, 3) + + ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="lb3dp") + + current_count = ApprovalModel.query.count() + self.assertTrue(current_count, 4) + def test_new_approval_sends_proper_emails(self): self.assertEqual(1, 1) From 64f9bd2ca7425da75fac00c996f50ffaba26067a Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Tue, 7 Jul 2020 08:38:33 -0600 Subject: [PATCH 065/101] Tiny leftover --- crc/services/approval_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index 56206c9c..28b97b6b 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -287,7 +287,7 @@ class ApprovalService(object): ) if mail_result: app.logger.error(mail_result, exc_info=True) - # TODO: Log update action by approver_uid - maybe ? + return db_approval @staticmethod From 6a79fb3581a8d29825392299bf4d4b4d2e5f0700 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 7 Jul 2020 17:16:33 -0400 Subject: [PATCH 066/101] There may be multiple investigators of the same type that come back from the protocol builder, adding some tests and additional code to handle this, but still keep the list flat, currently appends a number to the investigator type when there is more than one. --- crc/services/study_service.py | 30 ++++++++++++++-------- tests/data/pb_responses/investigators.json | 12 ++++++++- tests/study/test_study_service.py | 8 ++++-- tests/test_protocol_builder.py | 2 +- tests/test_tasks_api.py | 8 +++--- 5 files changed, 42 insertions(+), 18 deletions(-) diff --git a/crc/services/study_service.py b/crc/services/study_service.py index f07c82c8..ce283cfe 100644 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -1,3 +1,4 @@ +from copy import copy from datetime import datetime import json from typing import List @@ -185,6 +186,7 @@ class StudyService(object): @staticmethod def get_investigators(study_id, all=False): + """Convert array of investigators from protocol builder into a dictionary keyed on the type. """ # Loop through all known investigator types as set in the reference file inv_dictionary = FileService.get_reference_data(FileService.INVESTIGATOR_LIST, 'code') @@ -192,18 +194,26 @@ class StudyService(object): # Get PB required docs pb_investigators = ProtocolBuilderService.get_investigators(study_id=study_id) - """Convert array of investigators from protocol builder into a dictionary keyed on the type""" + # It is possible for the same type to show up more than once in some circumstances, in those events + # append a counter to the name. + investigators = {} for i_type in inv_dictionary: - pb_data = next((item for item in pb_investigators if item['INVESTIGATORTYPE'] == i_type), None) - if pb_data: - inv_dictionary[i_type]['user_id'] = pb_data["NETBADGEID"] - inv_dictionary[i_type].update(StudyService.get_ldap_dict_if_available(pb_data["NETBADGEID"])) - else: - inv_dictionary[i_type]['user_id'] = None - + pb_data_entries = list(item for item in pb_investigators if item['INVESTIGATORTYPE'] == i_type) + entry_count = 0 + investigators[i_type] = copy(inv_dictionary[i_type]) + investigators[i_type]['user_id'] = None + for pb_data in pb_data_entries: + entry_count += 1 + if entry_count == 1: + t = i_type + else: + t = i_type + "_" + str(entry_count) + investigators[t] = copy(inv_dictionary[i_type]) + investigators[t]['user_id'] = pb_data["NETBADGEID"] + investigators[t].update(StudyService.get_ldap_dict_if_available(pb_data["NETBADGEID"])) if not all: - inv_dictionary = dict(filter(lambda elem: elem[1]['user_id'] is not None, inv_dictionary.items())) - return inv_dictionary + investigators = dict(filter(lambda elem: elem[1]['user_id'] is not None, investigators.items())) + return investigators @staticmethod def get_ldap_dict_if_available(user_id): diff --git a/tests/data/pb_responses/investigators.json b/tests/data/pb_responses/investigators.json index b0c1c38f..e476c453 100644 --- a/tests/data/pb_responses/investigators.json +++ b/tests/data/pb_responses/investigators.json @@ -13,5 +13,15 @@ "INVESTIGATORTYPE": "PI", "INVESTIGATORTYPEFULL": "Primary Investigator", "NETBADGEID": "dhf8r" + }, + { + "INVESTIGATORTYPE": "SI", + "INVESTIGATORTYPEFULL": "Sub Investigator", + "NETBADGEID": "ajl2j" + }, + { + "INVESTIGATORTYPE": "SI", + "INVESTIGATORTYPEFULL": "Sub Investigator", + "NETBADGEID": "cah3us" } -] \ No newline at end of file +] diff --git a/tests/study/test_study_service.py b/tests/study/test_study_service.py index 1eb020fa..b436835f 100644 --- a/tests/study/test_study_service.py +++ b/tests/study/test_study_service.py @@ -193,7 +193,7 @@ class TestStudyService(BaseTest): workflow = self.create_workflow('docx') # The workflow really doesnt matter in this case. investigators = StudyService().get_investigators(workflow.study_id, all=True) - self.assertEqual(9, len(investigators)) + self.assertEqual(10, len(investigators)) # dhf8r is in the ldap mock data. self.assertEqual("dhf8r", investigators['PI']['user_id']) @@ -219,10 +219,14 @@ class TestStudyService(BaseTest): workflow = self.create_workflow('docx') # The workflow really doesnt matter in this case. investigators = StudyService().get_investigators(workflow.study_id, all=False) - self.assertEqual(3, len(investigators)) + self.assertEqual(5, len(investigators)) # dhf8r is in the ldap mock data. self.assertEqual("dhf8r", investigators['PI']['user_id']) self.assertEqual("Dan Funk", investigators['PI']['display_name']) # Data from ldap self.assertEqual("Primary Investigator", investigators['PI']['label']) # Data from xls file. self.assertEqual("Always", investigators['PI']['display']) # Data from xls file. + + # Both Alex and Aaron are SI, and both should be returned. + self.assertEqual("ajl2j", investigators['SI']['user_id']) + self.assertEqual("cah3us", investigators['SI_2']['user_id']) diff --git a/tests/test_protocol_builder.py b/tests/test_protocol_builder.py index e5b75632..2a77ec05 100644 --- a/tests/test_protocol_builder.py +++ b/tests/test_protocol_builder.py @@ -24,7 +24,7 @@ class TestProtocolBuilder(BaseTest): mock_get.return_value.text = self.protocol_builder_response('investigators.json') response = ProtocolBuilderService.get_investigators(self.test_study_id) self.assertIsNotNone(response) - self.assertEqual(3, len(response)) + self.assertEqual(5, len(response)) self.assertEqual("DC", response[0]["INVESTIGATORTYPE"]) self.assertEqual("Department Contact", response[0]["INVESTIGATORTYPEFULL"]) self.assertEqual("asd3v", response[0]["NETBADGEID"]) diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index abbf8707..7a7f3c76 100644 --- a/tests/test_tasks_api.py +++ b/tests/test_tasks_api.py @@ -322,7 +322,7 @@ class TestTasksApi(BaseTest): self.assertEqual(4, len(navigation)) # Start task, form_task, multi_task, end task self.assertEqual("UserTask", workflow.next_task.type) self.assertEqual(MultiInstanceType.sequential.value, workflow.next_task.multi_instance_type) - self.assertEqual(3, workflow.next_task.multi_instance_count) + self.assertEqual(5, workflow.next_task.multi_instance_count) # Assure that the names for each task are properly updated, so they aren't all the same. self.assertEqual("Primary Investigator", workflow.next_task.properties['display_name']) @@ -480,15 +480,15 @@ class TestTasksApi(BaseTest): workflow = self.create_workflow('multi_instance_parallel') workflow_api = self.get_workflow_api(workflow) - self.assertEqual(6, len(workflow_api.navigation)) + self.assertEqual(8, len(workflow_api.navigation)) ready_items = [nav for nav in workflow_api.navigation if nav['state'] == "READY"] - self.assertEqual(3, len(ready_items)) + self.assertEqual(5, len(ready_items)) self.assertEqual("UserTask", workflow_api.next_task.type) self.assertEqual("MultiInstanceTask",workflow_api.next_task.name) self.assertEqual("Primary Investigator", workflow_api.next_task.title) - for i in random.sample(range(3), 3): + for i in random.sample(range(5), 5): task = TaskSchema().load(ready_items[i]['task']) rv = self.app.put('/v1.0/workflow/%i/task/%s/set_token' % (workflow.id, task.id), headers=self.logged_in_headers(), From 4cfaa62a8ea5f90565c9a3f15ae5c8fc2f498e16 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 10 Jul 2020 10:27:14 -0400 Subject: [PATCH 067/101] Switches image to cr-connect-db --- postgres/docker-compose.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/postgres/docker-compose.yml b/postgres/docker-compose.yml index 2ed67073..31116b85 100644 --- a/postgres/docker-compose.yml +++ b/postgres/docker-compose.yml @@ -1,9 +1,8 @@ version: "3.7" services: db: - image: postgres + image: sartography/cr-connect-db volumes: - - ./pg-init-scripts/initdb.sh:/docker-entrypoint-initdb.d/initdb.sh - $HOME/docker/volumes/postgres:/var/lib/postgresql/data ports: - 5432:5432 From 1f454536e3a47f5208f6258716ad5c7ea44c9009 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 10 Jul 2020 11:26:15 -0400 Subject: [PATCH 068/101] Renames TOKEN_AUTH_SECRET_KEY to SECRET_KEY --- config/default.py | 2 +- config/testing.py | 2 +- crc/models/user.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/default.py b/config/default.py index ed44e6fe..5c8f8c51 100644 --- a/config/default.py +++ b/config/default.py @@ -30,7 +30,7 @@ SQLALCHEMY_DATABASE_URI = environ.get( default="postgresql://%s:%s@%s:%s/%s" % (DB_USER, DB_PASSWORD, DB_HOST, DB_PORT, DB_NAME) ) TOKEN_AUTH_TTL_HOURS = float(environ.get('TOKEN_AUTH_TTL_HOURS', default=24)) -TOKEN_AUTH_SECRET_KEY = environ.get('TOKEN_AUTH_SECRET_KEY', default="Shhhh!!! This is secret! And better darn well not show up in prod.") +SECRET_KEY = environ.get('SECRET_KEY', default="Shhhh!!! This is secret! And better darn well not show up in prod.") FRONTEND_AUTH_CALLBACK = environ.get('FRONTEND_AUTH_CALLBACK', default="http://localhost:4200/session") SWAGGER_AUTH_KEY = environ.get('SWAGGER_AUTH_KEY', default="SWAGGER") diff --git a/config/testing.py b/config/testing.py index c7a777ad..5b03cc41 100644 --- a/config/testing.py +++ b/config/testing.py @@ -5,7 +5,7 @@ basedir = os.path.abspath(os.path.dirname(__file__)) NAME = "CR Connect Workflow" TESTING = True -TOKEN_AUTH_SECRET_KEY = "Shhhh!!! This is secret! And better darn well not show up in prod." +SECRET_KEY = "Shhhh!!! This is secret! And better darn well not show up in prod." # This is here, for when we are running the E2E Tests in the frontend code bases. # which will set the TESTING envronment to true, causing this to execute, but we need diff --git a/crc/models/user.py b/crc/models/user.py index 55bba35f..221176bc 100644 --- a/crc/models/user.py +++ b/crc/models/user.py @@ -35,7 +35,7 @@ class UserModel(db.Model): } return jwt.encode( payload, - app.config.get('TOKEN_AUTH_SECRET_KEY'), + app.config.get('SECRET_KEY'), algorithm='HS256', ) @@ -47,7 +47,7 @@ class UserModel(db.Model): :return: integer|string """ try: - payload = jwt.decode(auth_token, app.config.get('TOKEN_AUTH_SECRET_KEY'), algorithms='HS256') + payload = jwt.decode(auth_token, app.config.get('SECRET_KEY'), algorithms='HS256') return payload except jwt.ExpiredSignatureError: raise ApiError('token_expired', 'The Authentication token you provided expired and must be renewed.') From b7920989edba70eeeb8f5587dcdb472d75f5b90e Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 10 Jul 2020 14:48:38 -0400 Subject: [PATCH 069/101] WIP: Adds Camunda property for retrieving enum field options from task data. --- crc/models/api_models.py | 29 ++++++++++++++++--- crc/services/lookup_service.py | 49 +++++++++++++++++++++----------- crc/services/workflow_service.py | 4 +-- 3 files changed, 60 insertions(+), 22 deletions(-) diff --git a/crc/models/api_models.py b/crc/models/api_models.py index bb99eebb..4dec1a74 100644 --- a/crc/models/api_models.py +++ b/crc/models/api_models.py @@ -32,15 +32,36 @@ class NavigationItem(object): class Task(object): + ########################################################################## + # Custom properties and validations defined in Camunda form fields # + ########################################################################## + + # Repeating form section PROP_OPTIONS_REPEAT = "repeat" - PROP_OPTIONS_FILE = "spreadsheet.name" - PROP_OPTIONS_VALUE_COLUMN = "spreadsheet.value.column" - PROP_OPTIONS_LABEL_COL = "spreadsheet.label.column" + + # Read-only field PROP_OPTIONS_READ_ONLY = "read_only" + + # LDAP lookup PROP_LDAP_LOOKUP = "ldap.lookup" - VALIDATION_REQUIRED = "required" + + # Autocomplete field FIELD_TYPE_AUTO_COMPLETE = "autocomplete" + # Required field + VALIDATION_REQUIRED = "required" + + # Enum field options values pulled from a spreadsheet + PROP_OPTIONS_FILE_NAME = "spreadsheet.name" + PROP_OPTIONS_FILE_VALUE_COLUMN = "spreadsheet.value.column" + PROP_OPTIONS_FILE_LABEL_COLUMN = "spreadsheet.label.column" + + # Enum field options values pulled from task data + PROP_OPTIONS_DATA_NAME = "data.name" + PROP_OPTIONS_DATA_VALUE_COLUMN = "data.value.column" + PROP_OPTIONS_DATA_LABEL_COLUMN = "data.label.column" + + ########################################################################## def __init__(self, id, name, title, type, state, form, documentation, data, multi_instance_type, multi_instance_count, multi_instance_index, process_name, properties): diff --git a/crc/services/lookup_service.py b/crc/services/lookup_service.py index 47424ae8..7681c81f 100644 --- a/crc/services/lookup_service.py +++ b/crc/services/lookup_service.py @@ -76,29 +76,30 @@ class LookupService(object): @staticmethod def create_lookup_model(workflow_model, field_id): """ - This is all really expensive, but should happen just once (per file change). - Checks to see if the options are provided in a separate lookup table associated with the - workflow, and if so, assures that data exists in the database, and return a model than can be used - to locate that data. - Returns: an array of LookupData, suitable for returning to the api. + This is all really expensive, but should happen just once (per file change). + + Checks to see if the options are provided in a separate lookup table associated with the workflow, and if so, + assures that data exists in the database, and return a model than can be used to locate that data. + + Returns: an array of LookupData, suitable for returning to the API. """ processor = WorkflowProcessor(workflow_model) # VERY expensive, Ludicrous for lookup / type ahead spiff_task, field = processor.find_task_and_field_by_field_id(field_id) - if field.has_property(Task.PROP_OPTIONS_FILE): - if not field.has_property(Task.PROP_OPTIONS_VALUE_COLUMN) or \ - not field.has_property(Task.PROP_OPTIONS_LABEL_COL): + if field.has_property(Task.PROP_OPTIONS_FILE_NAME): + if not (field.has_property(Task.PROP_OPTIONS_FILE_VALUE_COLUMN) or + field.has_property(Task.PROP_OPTIONS_FILE_LABEL_COLUMN)): raise ApiError.from_task("invalid_emum", "For enumerations based on an xls file, you must include 3 properties: %s, " - "%s, and %s" % (Task.PROP_OPTIONS_FILE, - Task.PROP_OPTIONS_VALUE_COLUMN, - Task.PROP_OPTIONS_LABEL_COL), + "%s, and %s" % (Task.PROP_OPTIONS_FILE_NAME, + Task.PROP_OPTIONS_FILE_VALUE_COLUMN, + Task.PROP_OPTIONS_FILE_LABEL_COLUMN), task=spiff_task) # Get the file data from the File Service - file_name = field.get_property(Task.PROP_OPTIONS_FILE) - value_column = field.get_property(Task.PROP_OPTIONS_VALUE_COLUMN) - label_column = field.get_property(Task.PROP_OPTIONS_LABEL_COL) + file_name = field.get_property(Task.PROP_OPTIONS_FILE_NAME) + value_column = field.get_property(Task.PROP_OPTIONS_FILE_VALUE_COLUMN) + label_column = field.get_property(Task.PROP_OPTIONS_FILE_LABEL_COLUMN) latest_files = FileService.get_spec_data_files(workflow_spec_id=workflow_model.workflow_spec_id, workflow_id=workflow_model.id, name=file_name) @@ -110,14 +111,30 @@ class LookupService(object): lookup_model = LookupService.build_lookup_table(data_model, value_column, label_column, workflow_model.workflow_spec_id, field_id) + elif field.has_property(Task.PROP_OPTIONS_DATA_NAME): + if not (field.has_property(Task.PROP_OPTIONS_DATA_VALUE_COLUMN) or + field.has_property(Task.PROP_OPTIONS_DATA_LABEL_COLUMN)): + raise ApiError.from_task("invalid_emum", + "For enumerations based on task data, you must include 3 properties: %s, " + "%s, and %s" % (Task.PROP_OPTIONS_DATA_NAME, + Task.PROP_OPTIONS_DATA_VALUE_COLUMN, + Task.PROP_OPTIONS_DATA_LABEL_COLUMN), + task=spiff_task) + + # Get the enum options from the task data + data_model = spiff_task.data.__getattribute__(Task.PROP_OPTIONS_DATA_NAME) + value_column = field.get_property(Task.PROP_OPTIONS_DATA_VALUE_COLUMN) + label_column = field.get_property(Task.PROP_OPTIONS_DATA_LABEL_COLUMN) + lookup_model = LookupService.build_lookup_table(data_model, value_column, label_column, + workflow_model.workflow_spec_id, field_id) + elif field.has_property(Task.PROP_LDAP_LOOKUP): lookup_model = LookupFileModel(workflow_spec_id=workflow_model.workflow_spec_id, field_id=field_id, is_ldap=True) else: raise ApiError("unknown_lookup_option", - "Lookup supports using spreadsheet options or ldap options, and neither " - "was provided.") + "Lookup supports using spreadsheet options or ldap options, and neither was provided.") db.session.add(lookup_model) db.session.commit() return lookup_model diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 157c4c13..04781f52 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -394,7 +394,7 @@ class WorkflowService(object): # If this is an auto-complete field, do not populate options, a lookup will happen later. if field.type == Task.FIELD_TYPE_AUTO_COMPLETE: pass - elif field.has_property(Task.PROP_OPTIONS_FILE): + elif field.has_property(Task.PROP_OPTIONS_FILE_NAME): lookup_model = LookupService.get_lookup_model(spiff_task, field) data = db.session.query(LookupDataModel).filter(LookupDataModel.lookup_file_model == lookup_model).all() if not hasattr(field, 'options'): @@ -467,7 +467,7 @@ class WorkflowService(object): @staticmethod def extract_form_data(latest_data, task): - """Removes data from latest_data that would be added by the child task or any of it's children.""" + """Removes data from latest_data that would be added by the child task or any of its children.""" data = {} if hasattr(task.task_spec, 'form'): From aea7b23aabc9d5b8a78bd538d0c34016af3b5c0e Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 10 Jul 2020 14:52:08 -0400 Subject: [PATCH 070/101] Removes unnecessary test workflow spec --- crc/static/bpmn/group_test/group_test.bpmn | 82 --------------------- crc/static/bpmn/group_test/lookup.xlsx | Bin 9125 -> 0 bytes example_data.py | 6 -- 3 files changed, 88 deletions(-) delete mode 100644 crc/static/bpmn/group_test/group_test.bpmn delete mode 100644 crc/static/bpmn/group_test/lookup.xlsx diff --git a/crc/static/bpmn/group_test/group_test.bpmn b/crc/static/bpmn/group_test/group_test.bpmn deleted file mode 100644 index aed16ffd..00000000 --- a/crc/static/bpmn/group_test/group_test.bpmn +++ /dev/null @@ -1,82 +0,0 @@ - - - - - Flow_1gnws9u - - - - Flow_1xria19 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Flow_1gnws9u - Flow_0zud2rb - - - - lookup_dropdown: {{ lookup_dropdown }} -lookup_checkbox: {{ lookup_checkbox }} -lookup_radio: {{ lookup_radio }} - - - Flow_0zud2rb - Flow_1xria19 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/crc/static/bpmn/group_test/lookup.xlsx b/crc/static/bpmn/group_test/lookup.xlsx deleted file mode 100644 index 2861a655b8cd7b7e04573dba52b47678cb75f1cc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9125 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 Date: Fri, 10 Jul 2020 16:17:49 -0400 Subject: [PATCH 071/101] Fixes some syntax issues --- .../ids_full_submission.bpmn | 122 +++++++++--------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/crc/static/bpmn/ids_full_submission/ids_full_submission.bpmn b/crc/static/bpmn/ids_full_submission/ids_full_submission.bpmn index 719b3257..498c1215 100644 --- a/crc/static/bpmn/ids_full_submission/ids_full_submission.bpmn +++ b/crc/static/bpmn/ids_full_submission/ids_full_submission.bpmn @@ -1,5 +1,5 @@ - + SequenceFlow_1dexemq @@ -100,7 +100,7 @@ Protocol Owner: **(need to insert value here)** - + @@ -123,7 +123,7 @@ Protocol Owner: **(need to insert value here)** - + @@ -159,13 +159,13 @@ Protocol Owner: **(need to insert value here)** - + - + @@ -223,122 +223,122 @@ Protocol Owner: **(need to insert value here)** - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - + + + - - - + + + - - - + + + - - - + + + - - + + - - + + - - - + + + - - - + + + - - - + + + - - - + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + From ccb2addeb58ad508074a74da85e21da6634c5925 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 10 Jul 2020 16:27:09 -0400 Subject: [PATCH 072/101] Fixes another one --- crc/static/bpmn/ids_full_submission/ids_full_submission.bpmn | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crc/static/bpmn/ids_full_submission/ids_full_submission.bpmn b/crc/static/bpmn/ids_full_submission/ids_full_submission.bpmn index 498c1215..72fece25 100644 --- a/crc/static/bpmn/ids_full_submission/ids_full_submission.bpmn +++ b/crc/static/bpmn/ids_full_submission/ids_full_submission.bpmn @@ -206,7 +206,7 @@ Protocol Owner: **(need to insert value here)** - + SequenceFlow_0lixqzs From 3c8e1e5c375d4ffd695e2d20bee95f80ba9eca00 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 13 Jul 2020 11:02:29 -0400 Subject: [PATCH 073/101] Tests for presence of data property in enum options --- tests/test_tasks_api.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index 7a7f3c76..ebe99d93 100644 --- a/tests/test_tasks_api.py +++ b/tests/test_tasks_api.py @@ -83,6 +83,17 @@ class TestTasksApi(BaseTest): workflow = WorkflowApiSchema().load(json_data) return workflow + def assert_options_populated(self, results, lookup_data_keys): + option_keys = ['value', 'label', 'data'] + self.assertIsInstance(results, list) + for result in results: + for option_key in option_keys: + self.assertTrue(option_key in result, 'should have value, label, and data properties populated') + self.assertIsNotNone(result[option_key], '%s should not be None' % option_key) + + self.assertIsInstance(result['data'], dict) + for lookup_data_key in lookup_data_keys: + self.assertTrue(lookup_data_key in result['data'], 'should have all lookup data columns populated') def test_get_current_user_tasks(self): self.load_example_data() @@ -342,6 +353,7 @@ class TestTasksApi(BaseTest): self.assert_success(rv) results = json.loads(rv.get_data(as_text=True)) self.assertEqual(5, len(results)) + self.assert_options_populated(results, ['CUSTOMER_NUMBER', 'CUSTOMER_NAME', 'CUSTOMER_CLASS_MEANING']) def test_lookup_endpoint_for_task_field_using_lookup_entry_id(self): self.load_example_data() @@ -357,13 +369,15 @@ class TestTasksApi(BaseTest): self.assert_success(rv) results = json.loads(rv.get_data(as_text=True)) self.assertEqual(5, len(results)) + self.assert_options_populated(results, ['CUSTOMER_NUMBER', 'CUSTOMER_NAME', 'CUSTOMER_CLASS_MEANING']) + rv = self.app.get('/v1.0/workflow/%i/lookup/%s?value=%s' % (workflow.id, field_id, results[0]['value']), # All records with a word that starts with 'c' headers=self.logged_in_headers(), content_type="application/json") results = json.loads(rv.get_data(as_text=True)) self.assertEqual(1, len(results)) - self.assertIsInstance(results[0]['data'], dict) + self.assert_options_populated(results, ['CUSTOMER_NUMBER', 'CUSTOMER_NAME', 'CUSTOMER_CLASS_MEANING']) self.assertNotIn('id', results[0], "Don't include the internal id, that can be very confusing, and should not be used.") def test_lookup_endpoint_also_works_for_enum(self): @@ -371,7 +385,7 @@ class TestTasksApi(BaseTest): # In the event of an auto-complete it's a type-ahead search field, for an enum the # the key/values from the spreadsheet are added directly to the form and it shows up as # a dropdown. This tests the case of wanting to get additional data when a user selects - # something from a drodown. + # something from a dropdown. self.load_example_data() workflow = self.create_workflow('enum_options_from_file') # get the first form in the two form workflow. @@ -386,6 +400,7 @@ class TestTasksApi(BaseTest): self.assert_success(rv) results = json.loads(rv.get_data(as_text=True)) self.assertEqual(1, len(results)) + self.assert_options_populated(results, ['CUSTOMER_NUMBER', 'CUSTOMER_NAME', 'CUSTOMER_CLASS_MEANING']) self.assertIsInstance(results[0]['data'], dict) def test_lookup_endpoint_for_task_ldap_field_lookup(self): @@ -402,6 +417,9 @@ class TestTasksApi(BaseTest): content_type="application/json") self.assert_success(rv) results = json.loads(rv.get_data(as_text=True)) + self.assert_options_populated(results, ['telephone_number', 'affiliation', 'uid', 'title', + 'given_name', 'department', 'date_cached', 'sponsor_type', + 'display_name', 'email_address']) self.assertEqual(1, len(results)) def test_sub_process(self): From 9e29a4378584691f264d2cfba0bf218854e6fcbf Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Mon, 13 Jul 2020 12:45:51 -0400 Subject: [PATCH 074/101] Correct for a race condition where multiple lookup tables are built for the same field and workflow specification, causing it to appear that the models are not updating correctly. --- crc/services/lookup_service.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crc/services/lookup_service.py b/crc/services/lookup_service.py index 47424ae8..8e849085 100644 --- a/crc/services/lookup_service.py +++ b/crc/services/lookup_service.py @@ -46,18 +46,18 @@ class LookupService(object): def __get_lookup_model(workflow, field_id): lookup_model = db.session.query(LookupFileModel) \ .filter(LookupFileModel.workflow_spec_id == workflow.workflow_spec_id) \ - .filter(LookupFileModel.field_id == field_id).first() + .filter(LookupFileModel.field_id == field_id) \ + .order_by(desc(LookupFileModel.id)).first() # one more quick query, to see if the lookup file is still related to this workflow. # if not, we need to rebuild the lookup table. is_current = False if lookup_model: is_current = db.session.query(WorkflowSpecDependencyFile). \ - filter(WorkflowSpecDependencyFile.file_data_id == lookup_model.file_data_model_id).count() + filter(WorkflowSpecDependencyFile.file_data_id == lookup_model.file_data_model_id).\ + filter(WorkflowSpecDependencyFile.workflow_id == workflow.id).count() if not is_current: - if lookup_model: - db.session.delete(lookup_model) # Very very very expensive, but we don't know need this till we do. lookup_model = LookupService.create_lookup_model(workflow, field_id) @@ -85,6 +85,14 @@ class LookupService(object): processor = WorkflowProcessor(workflow_model) # VERY expensive, Ludicrous for lookup / type ahead spiff_task, field = processor.find_task_and_field_by_field_id(field_id) + # Clear out all existing lookup models for this workflow and field. + existing_models = db.session.query(LookupFileModel) \ + .filter(LookupFileModel.workflow_spec_id == workflow_model.workflow_spec_id) \ + .filter(LookupFileModel.field_id == field_id).all() + for model in existing_models: # Do it one at a time to cause the required cascade of deletes. + db.session.delete(model) + + if field.has_property(Task.PROP_OPTIONS_FILE): if not field.has_property(Task.PROP_OPTIONS_VALUE_COLUMN) or \ not field.has_property(Task.PROP_OPTIONS_LABEL_COL): From 07066b8a165c409d8b33dedba1a0cce5963cbda4 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 13 Jul 2020 17:46:28 -0400 Subject: [PATCH 075/101] Looks up enum options from task data --- crc/services/lookup_service.py | 36 +++++-- crc/services/workflow_service.py | 2 +- .../enum_options_from_task_data.bpmn | 100 ++++++++++++++++++ tests/test_tasks_api.py | 36 +++++++ 4 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 tests/data/enum_options_from_task_data/enum_options_from_task_data.bpmn diff --git a/crc/services/lookup_service.py b/crc/services/lookup_service.py index 7681c81f..067a26a2 100644 --- a/crc/services/lookup_service.py +++ b/crc/services/lookup_service.py @@ -86,10 +86,11 @@ class LookupService(object): processor = WorkflowProcessor(workflow_model) # VERY expensive, Ludicrous for lookup / type ahead spiff_task, field = processor.find_task_and_field_by_field_id(field_id) + # Use the contents of a file to populate enum field options if field.has_property(Task.PROP_OPTIONS_FILE_NAME): if not (field.has_property(Task.PROP_OPTIONS_FILE_VALUE_COLUMN) or field.has_property(Task.PROP_OPTIONS_FILE_LABEL_COLUMN)): - raise ApiError.from_task("invalid_emum", + raise ApiError.from_task("invalid_enum", "For enumerations based on an xls file, you must include 3 properties: %s, " "%s, and %s" % (Task.PROP_OPTIONS_FILE_NAME, Task.PROP_OPTIONS_FILE_VALUE_COLUMN, @@ -111,30 +112,49 @@ class LookupService(object): lookup_model = LookupService.build_lookup_table(data_model, value_column, label_column, workflow_model.workflow_spec_id, field_id) + # Use the value of some other task data field to populate enum field options elif field.has_property(Task.PROP_OPTIONS_DATA_NAME): if not (field.has_property(Task.PROP_OPTIONS_DATA_VALUE_COLUMN) or field.has_property(Task.PROP_OPTIONS_DATA_LABEL_COLUMN)): - raise ApiError.from_task("invalid_emum", + raise ApiError.from_task("invalid_enum", "For enumerations based on task data, you must include 3 properties: %s, " "%s, and %s" % (Task.PROP_OPTIONS_DATA_NAME, Task.PROP_OPTIONS_DATA_VALUE_COLUMN, Task.PROP_OPTIONS_DATA_LABEL_COLUMN), task=spiff_task) + prop = field.get_property(Task.PROP_OPTIONS_DATA_NAME) + if prop not in spiff_task.data: + raise ApiError.from_task("invalid_enum", "For enumerations based on task data, task data must have " + "a property called '%s'" % prop, + task=spiff_task) + # Get the enum options from the task data - data_model = spiff_task.data.__getattribute__(Task.PROP_OPTIONS_DATA_NAME) + data_model = spiff_task.data[prop] value_column = field.get_property(Task.PROP_OPTIONS_DATA_VALUE_COLUMN) label_column = field.get_property(Task.PROP_OPTIONS_DATA_LABEL_COLUMN) - lookup_model = LookupService.build_lookup_table(data_model, value_column, label_column, - workflow_model.workflow_spec_id, field_id) + lookup_model = LookupFileModel(workflow_spec_id=workflow_model.workflow_spec_id, + field_id=field_id, + is_ldap=False) + db.session.add(lookup_model) + items = data_model.items() if isinstance(data_model, dict) else data_model + for item in items: + lookup_data = LookupDataModel(lookup_file_model=lookup_model, + value=item[value_column], + label=item[label_column], + data=item) + db.session.add(lookup_data) + db.session.commit() + # Use the results of an LDAP request to populate enum field options elif field.has_property(Task.PROP_LDAP_LOOKUP): lookup_model = LookupFileModel(workflow_spec_id=workflow_model.workflow_spec_id, field_id=field_id, is_ldap=True) else: raise ApiError("unknown_lookup_option", - "Lookup supports using spreadsheet options or ldap options, and neither was provided.") + "Lookup supports using spreadsheet, task data, or LDAP options, " + "and none of those was provided.") db.session.add(lookup_model) db.session.commit() return lookup_model @@ -149,11 +169,11 @@ class LookupService(object): df = xls.parse(xls.sheet_names[0]) # Currently we only look at the fist sheet. df = pd.DataFrame(df).replace({np.nan: None}) if value_column not in df: - raise ApiError("invalid_emum", + raise ApiError("invalid_enum", "The file %s does not contain a column named % s" % (data_model.file_model.name, value_column)) if label_column not in df: - raise ApiError("invalid_emum", + raise ApiError("invalid_enum", "The file %s does not contain a column named % s" % (data_model.file_model.name, label_column)) diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 04781f52..3a82aa24 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -394,7 +394,7 @@ class WorkflowService(object): # If this is an auto-complete field, do not populate options, a lookup will happen later. if field.type == Task.FIELD_TYPE_AUTO_COMPLETE: pass - elif field.has_property(Task.PROP_OPTIONS_FILE_NAME): + elif field.has_property(Task.PROP_OPTIONS_FILE_NAME) or field.has_property(Task.PROP_OPTIONS_DATA_NAME): lookup_model = LookupService.get_lookup_model(spiff_task, field) data = db.session.query(LookupDataModel).filter(LookupDataModel.lookup_file_model == lookup_model).all() if not hasattr(field, 'options'): diff --git a/tests/data/enum_options_from_task_data/enum_options_from_task_data.bpmn b/tests/data/enum_options_from_task_data/enum_options_from_task_data.bpmn new file mode 100644 index 00000000..5be4401a --- /dev/null +++ b/tests/data/enum_options_from_task_data/enum_options_from_task_data.bpmn @@ -0,0 +1,100 @@ + + + + + SequenceFlow_0lvudp8 + + + + SequenceFlow_02vev7n + + + + + + + + + + + + + + + Flow_1yet4a9 + SequenceFlow_02vev7n + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SequenceFlow_0lvudp8 + Flow_1yet4a9 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index ebe99d93..7f5cc4da 100644 --- a/tests/test_tasks_api.py +++ b/tests/test_tasks_api.py @@ -403,6 +403,42 @@ class TestTasksApi(BaseTest): self.assert_options_populated(results, ['CUSTOMER_NUMBER', 'CUSTOMER_NAME', 'CUSTOMER_CLASS_MEANING']) self.assertIsInstance(results[0]['data'], dict) + def test_lookup_endpoint_enum_in_task_data(self): + self.load_example_data() + workflow = self.create_workflow('enum_options_from_task_data') + # get the first form in the two form workflow. + workflow_api = self.get_workflow_api(workflow) + task = workflow_api.next_task + + workflow_api = self.complete_form(workflow, task, {'invitees': [ + {'first_name': 'Alistair', 'last_name': 'Aardvark', 'age': 43, 'likes_pie': True, 'num_lumps': 21, 'secret_id': 'Antimony', 'display_name': 'Professor Alistair A. Aardvark'}, + {'first_name': 'Berthilda', 'last_name': 'Binturong', 'age': 12, 'likes_pie': False, 'num_lumps': 34, 'secret_id': 'Beryllium', 'display_name': 'Dr. Berthilda B. Binturong'}, + {'first_name': 'Chesterfield', 'last_name': 'Capybara', 'age': 32, 'likes_pie': True, 'num_lumps': 1, 'secret_id': 'Cadmium', 'display_name': 'The Honorable C. C. Capybara'}, + ]}) + task = workflow_api.next_task + + field_id = task.form['fields'][0]['id'] + options = task.form['fields'][0]['options'] + self.assertEqual(3, len(options)) + option_id = options[0]['id'] + self.assertEqual('Professor Alistair A. Aardvark', options[0]['name']) + self.assertEqual('Dr. Berthilda B. Binturong', options[1]['name']) + self.assertEqual('The Honorable C. C. Capybara', options[2]['name']) + self.assertEqual('Alistair', options[0]['data']['first_name']) + self.assertEqual('Berthilda', options[1]['data']['first_name']) + self.assertEqual('Chesterfield', options[2]['data']['first_name']) + + rv = self.app.get('/v1.0/workflow/%i/lookup/%s?value=%s' % + (workflow.id, field_id, option_id), + headers=self.logged_in_headers(), + content_type="application/json") + self.assert_success(rv) + results = json.loads(rv.get_data(as_text=True)) + self.assertEqual(1, len(results)) + self.assert_options_populated(results, ['first_name', 'last_name', 'age', 'likes_pie', 'num_lumps', + 'secret_id', 'display_name']) + self.assertIsInstance(results[0]['data'], dict) + def test_lookup_endpoint_for_task_ldap_field_lookup(self): self.load_example_data() workflow = self.create_workflow('ldap_lookup') From f83995a69253b925a325ed2570a9124c6b3e4942 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 14 Jul 2020 09:01:29 -0400 Subject: [PATCH 076/101] Partial work towards roles and lane support --- tests/test_user_roles.py | 101 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/test_user_roles.py diff --git a/tests/test_user_roles.py b/tests/test_user_roles.py new file mode 100644 index 00000000..794008c8 --- /dev/null +++ b/tests/test_user_roles.py @@ -0,0 +1,101 @@ +import json + +from crc.models.api_models import WorkflowApiSchema +from crc.models.stats import TaskEventModel +from tests.base_test import BaseTest +from crc import db +from crc.api.common import ApiError +from crc.services.workflow_service import WorkflowService + + +class TestTasksApi(BaseTest): + + def test_raise_error_if_role_does_not_exist_in_data(self): + workflow = self.create_workflow('roles', as_user="lje5u") + workflow_api = self.get_workflow_api(workflow, user_uid="lje5u") + data = workflow_api.next_task.data + # User lje5u can complete the first task + self.complete_form(workflow, workflow_api.next_task, data, user_uid="lje5u") + + # The next task is a supervisor task, and should raise an error if the role + # information is not in the task data. + workflow_api = self.get_workflow_api(workflow, user_uid="lje5u") + data = workflow_api.next_task.data + data["approved"] = True + result = self.complete_form(workflow, workflow_api.next_task, data, user_uid="lje5u", + error_code="invalid_role") + + def test_validation_of_workflow_fails_if_workflow_does_not_define_user_for_lane(self): + error = None + try: + workflow = self.create_workflow('invalid_roles', as_user="lje5u") + WorkflowService.test_spec(workflow.workflow_spec_id) + except ApiError as ae: + error = ae + self.assertIsNotNone(error, "An error should be raised.") + self.assertEquals("invalid_role", error.code) + + def test_raise_error_if_user_does_not_have_the_correct_role(self): + submitter = self.create_user(uid='lje5u') + supervisor = self.create_user(uid='lb3dp') + workflow = self.create_workflow('roles', as_user=submitter.uid) + workflow_api = self.get_workflow_api(workflow, user_uid=submitter.uid) + + # User lje5u can complete the first task, and set her supervisor + data = workflow_api.next_task.data + data['supervisor'] = supervisor.uid + self.complete_form(workflow, workflow_api.next_task, data, user_uid=submitter.uid) + + # But she can not complete the supervisor role. + workflow_api = self.get_workflow_api(workflow, user_uid=submitter.uid) + data = workflow_api.next_task.data + data["approved"] = True + result = self.complete_form(workflow, workflow_api.next_task, data, user_uid=submitter.uid, + error_code="role_permission") + + # Only her supervisor can do that. + self.complete_form(workflow, workflow_api.next_task, data, user_uid=supervisor.uid) + + def test_nav_includes_lanes(self): + self.load_example_data() + + submitter = self.create_user(uid='lje5u') + workflow = self.create_workflow('roles', as_user=submitter.uid) + workflow_api = self.get_workflow_api(workflow, user_uid=submitter.uid) + + nav = workflow_api.navigation + self.assertEquals(5, len(nav)) + self.assertEquals("supervisor", nav[1]['lane']) + + def test_get_outstanding_tasks_awaiting_user_input(self): + submitter = self.create_user(uid='lje5u') + supervisor = self.create_user(uid='lb3dp') + workflow = self.create_workflow('roles', as_user=submitter.uid) + workflow_api = self.get_workflow_api(workflow, user_uid=submitter.uid) + + # User lje5u can complete the first task, and set her supervisor + data = workflow_api.next_task.data + data['supervisor'] = supervisor.uid + self.complete_form(workflow, workflow_api.next_task, data, user_uid=submitter.uid) + + # At this point there should be a task_log with an action of WAITING on it for + # the supervisor. + task_log = db.session.query(TaskEventModel).filter(TaskEventModel.user_uid == supervisor.uid) + self.assertEquals(1, len(task_log)) + + # A call to the /workflow endpoint as the supervisor user should return this workflow + rv = self.app.get('/v1.0/workflow', + headers=self.logged_in_headers(supervisor.uid), + content_type="application/json") + self.assert_success(rv) + json_data = json.loads(rv.get_data(as_text=True)) + workflow_api = WorkflowApiSchema().load(json_data) + + # The workflow navigation should be locked for all tasks that do not belong to the user. + + + + # Completing the next step of the workflow will close the task. + self.complete_form(workflow, workflow_api.next_task, data, user_uid=supervisor.uid) + + From a48322ef6ac0af59da3c0ce8725d23ccd623c36b Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 14 Jul 2020 10:29:25 -0400 Subject: [PATCH 077/101] Partial work on CR Connect Roles. Adding checks in the API to assure the correct person is completeing a task based on the task's lane. Adding lane to the Navigation item. Adding a check to assure that unique user ids can be identified using task.data Added some additional ldap entries to make testing and development easier. Removed a big chunk of duplicate code from task_tests_api Modified some of the helper functions to make it easier to test as specific users. Added some additional bpmn models to the tests for testing lanes and roles. --- Pipfile.lock | 130 +++------ crc/api/workflow.py | 50 +++- crc/models/api_models.py | 4 +- crc/services/workflow_service.py | 14 +- tests/base_test.py | 5 +- tests/data/invalid_roles/invalid_roles.bpmn | 177 +++++++++++++ tests/data/ldap_response.json | 275 +++++++++----------- tests/data/roles/roles.bpmn | 155 +++++++++++ tests/test_tasks_api.py | 72 +---- tests/workflow/test_workflow_processor.py | 17 +- 10 files changed, 560 insertions(+), 339 deletions(-) create mode 100644 tests/data/invalid_roles/invalid_roles.bpmn create mode 100644 tests/data/roles/roles.bpmn diff --git a/Pipfile.lock b/Pipfile.lock index cd2c1370..37672182 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -35,7 +35,6 @@ "sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b", "sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.6.0" }, "aniso8601": { @@ -50,7 +49,6 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "babel": { @@ -58,7 +56,6 @@ "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.0" }, "bcrypt": { @@ -82,7 +79,6 @@ "sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7", "sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.1.7" }, "beautifulsoup4": { @@ -111,7 +107,6 @@ "sha256:ef17d7dffde7fc73ecab3a3b6389d93d3213bac53fa7f28e68e33647ad50b916", "sha256:fd77e4248bb1b7af5f7922dd8e81156f540306e3a5c4b1c24167c1f5f06025da" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.4.6" }, "certifi": { @@ -166,7 +161,6 @@ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "clickclick": { @@ -188,7 +182,6 @@ "sha256:2ca44140ee259b5e3d8aaf47c79c36a7ab0d5e94d70bd4105c03ede7a20ea5a1", "sha256:cffc044844040c7ce04e9acd1838b5f2e5fa3170182f6fda4d2ea8b0099dbadd" ], - "markers": "python_version >= '3.6'", "version": "==5.0.0" }, "connexion": { @@ -247,7 +240,6 @@ "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==0.16" }, "docxtpl": { @@ -330,14 +322,12 @@ "sha256:0b656fbf87c5f24109d859bafa791d29751fabbda2302b606881ae5485b557a5", "sha256:fcfe6df52cd2ed8a63008ca36b86a51fa7a4b70cef1c39e5625f722fca32308e" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.3" }, "future": { "hashes": [ "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.18.2" }, "gunicorn": { @@ -360,7 +350,6 @@ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, "imagesize": { @@ -368,7 +357,6 @@ "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.2.0" }, "importlib-metadata": { @@ -384,7 +372,6 @@ "sha256:88b101b2668a1d81d6d72d4c2018e53bc6c7fc544c987849da1c7f77545c3bc9", "sha256:f576e85132d34f5bf7df5183c2c6f94cfb32e528f53065345cf71329ba0b8924" ], - "markers": "python_version >= '3.5'", "version": "==0.5.0" }, "itsdangerous": { @@ -392,7 +379,6 @@ "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.0" }, "jdcal": { @@ -407,7 +393,6 @@ "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.2" }, "jsonschema": { @@ -422,16 +407,11 @@ "sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a", "sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.6.11" }, "ldap3": { "hashes": [ "sha256:17f04298b70bf7ecaa5db8a7d8622b5a962ef7fc2b245b2eea705ac1c24338c0", - "sha256:298769ab0232b3a3efa1e84881096c24526fe37911c83a11285f222fe4975efd", - "sha256:4fd2db72d0412cc16ee86be01332095e86e361329c3579b314231eb2e56c7871", - "sha256:52ab557b3c4908db4a90bea16731aa714b1b54e039b54fd4c4b83994c6c48c0c", - "sha256:53aaae5bf14f3827c69600ddf4d61b88f49c055bb93060e9702c5bafd206c744", "sha256:81df4ac8b6df10fb1f05b17c18d0cb8c4c344d5a03083c382824960ed959cf5b" ], "index": "pypi", @@ -439,43 +419,42 @@ }, "lxml": { "hashes": [ - "sha256:06748c7192eab0f48e3d35a7adae609a329c6257495d5e53878003660dc0fec6", - "sha256:0790ddca3f825dd914978c94c2545dbea5f56f008b050e835403714babe62a5f", - "sha256:1aa7a6197c1cdd65d974f3e4953764eee3d9c7b67e3966616b41fab7f8f516b7", - "sha256:22c6d34fdb0e65d5f782a4d1a1edb52e0a8365858dafb1c08cb1d16546cf0786", - "sha256:2754d4406438c83144f9ffd3628bbe2dcc6d62b20dbc5c1ec4bc4385e5d44b42", - "sha256:27ee0faf8077c7c1a589573b1450743011117f1aa1a91d5ae776bbc5ca6070f2", - "sha256:2b02c106709466a93ed424454ce4c970791c486d5fcdf52b0d822a7e29789626", - "sha256:2d1ddce96cf15f1254a68dba6935e6e0f1fe39247de631c115e84dd404a6f031", - "sha256:4f282737d187ae723b2633856085c31ae5d4d432968b7f3f478a48a54835f5c4", - "sha256:51bb4edeb36d24ec97eb3e6a6007be128b720114f9a875d6b370317d62ac80b9", - "sha256:7eee37c1b9815e6505847aa5e68f192e8a1b730c5c7ead39ff317fde9ce29448", - "sha256:7fd88cb91a470b383aafad554c3fe1ccf6dfb2456ff0e84b95335d582a799804", - "sha256:9144ce36ca0824b29ebc2e02ca186e54040ebb224292072250467190fb613b96", - "sha256:925baf6ff1ef2c45169f548cc85204433e061360bfa7d01e1be7ae38bef73194", - "sha256:a636346c6c0e1092ffc202d97ec1843a75937d8c98aaf6771348ad6422e44bb0", - "sha256:a87dbee7ad9dce3aaefada2081843caf08a44a8f52e03e0a4cc5819f8398f2f4", - "sha256:a9e3b8011388e7e373565daa5e92f6c9cb844790dc18e43073212bb3e76f7007", - "sha256:afb53edf1046599991fb4a7d03e601ab5f5422a5435c47ee6ba91ec3b61416a6", - "sha256:b26719890c79a1dae7d53acac5f089d66fd8cc68a81f4e4bd355e45470dc25e1", - "sha256:b7462cdab6fffcda853338e1741ce99706cdf880d921b5a769202ea7b94e8528", - "sha256:b77975465234ff49fdad871c08aa747aae06f5e5be62866595057c43f8d2f62c", - "sha256:c47a8a5d00060122ca5908909478abce7bbf62d812e3fc35c6c802df8fb01fe7", - "sha256:c79e5debbe092e3c93ca4aee44c9a7631bdd407b2871cb541b979fd350bbbc29", - "sha256:d8d40e0121ca1606aa9e78c28a3a7d88a05c06b3ca61630242cded87d8ce55fa", - "sha256:ee2be8b8f72a2772e72ab926a3bccebf47bb727bda41ae070dc91d1fb759b726", - "sha256:f95d28193c3863132b1f55c1056036bf580b5a488d908f7d22a04ace8935a3a9", - "sha256:fadd2a63a2bfd7fb604508e553d1cf68eca250b2fbdbd81213b5f6f2fbf23529" + "sha256:05a444b207901a68a6526948c7cc8f9fe6d6f24c70781488e32fd74ff5996e3f", + "sha256:08fc93257dcfe9542c0a6883a25ba4971d78297f63d7a5a26ffa34861ca78730", + "sha256:107781b213cf7201ec3806555657ccda67b1fccc4261fb889ef7fc56976db81f", + "sha256:121b665b04083a1e85ff1f5243d4a93aa1aaba281bc12ea334d5a187278ceaf1", + "sha256:2b30aa2bcff8e958cd85d907d5109820b01ac511eae5b460803430a7404e34d7", + "sha256:4b4a111bcf4b9c948e020fd207f915c24a6de3f1adc7682a2d92660eb4e84f1a", + "sha256:5591c4164755778e29e69b86e425880f852464a21c7bb53c7ea453bbe2633bbe", + "sha256:59daa84aef650b11bccd18f99f64bfe44b9f14a08a28259959d33676554065a1", + "sha256:5a9c8d11aa2c8f8b6043d845927a51eb9102eb558e3f936df494e96393f5fd3e", + "sha256:5dd20538a60c4cc9a077d3b715bb42307239fcd25ef1ca7286775f95e9e9a46d", + "sha256:74f48ec98430e06c1fa8949b49ebdd8d27ceb9df8d3d1c92e1fdc2773f003f20", + "sha256:786aad2aa20de3dbff21aab86b2fb6a7be68064cbbc0219bde414d3a30aa47ae", + "sha256:7ad7906e098ccd30d8f7068030a0b16668ab8aa5cda6fcd5146d8d20cbaa71b5", + "sha256:80a38b188d20c0524fe8959c8ce770a8fdf0e617c6912d23fc97c68301bb9aba", + "sha256:92282c83547a9add85ad658143c76a64a8d339028926d7dc1998ca029c88ea6a", + "sha256:94150231f1e90c9595ccc80d7d2006c61f90a5995db82bccbca7944fd457f0f6", + "sha256:9dc9006dcc47e00a8a6a029eb035c8f696ad38e40a27d073a003d7d1443f5d88", + "sha256:a76979f728dd845655026ab991df25d26379a1a8fc1e9e68e25c7eda43004bed", + "sha256:aa8eba3db3d8761db161003e2d0586608092e217151d7458206e243be5a43843", + "sha256:bea760a63ce9bba566c23f726d72b3c0250e2fa2569909e2d83cda1534c79443", + "sha256:c3f511a3c58676147c277eff0224c061dd5a6a8e1373572ac817ac6324f1b1e0", + "sha256:cc411ad324a4486b142c41d9b2b6a722c534096963688d879ea6fa8a35028258", + "sha256:cdc13a1682b2a6241080745b1953719e7fe0850b40a5c71ca574f090a1391df6", + "sha256:e1cacf4796b20865789083252186ce9dc6cc59eca0c2e79cca332bdff24ac481", + "sha256:e70d4e467e243455492f5de463b72151cc400710ac03a0678206a5f27e79ddef", + "sha256:ecc930ae559ea8a43377e8b60ca6f8d61ac532fc57efb915d899de4a67928efd", + "sha256:f161af26f596131b63b236372e4ce40f3167c1b5b5d459b29d2514bd8c9dc9ee" ], "index": "pypi", - "version": "==4.5.1" + "version": "==4.5.2" }, "mako": { "hashes": [ "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27", "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.3" }, "markdown": { @@ -522,16 +501,15 @@ "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "marshmallow": { "hashes": [ - "sha256:35ee2fb188f0bd9fc1cf9ac35e45fd394bd1c153cee430745a465ea435514bd5", - "sha256:9aa20f9b71c992b4782dad07c51d92884fd0f7c5cb9d3c737bea17ec1bad765f" + "sha256:0f3a630f6a2fd124929f1bdcb5df65bd14cc8f49f52a18d0bdcfa0c42414e4a7", + "sha256:ba949379cb6ef73655f72075e82b31cf57012a5557ede642fc8614ab0354f869" ], "index": "pypi", - "version": "==3.6.1" + "version": "==3.7.0" }, "marshmallow-enum": { "hashes": [ @@ -578,7 +556,6 @@ "sha256:df1889701e2dfd8ba4dc9b1a010f0a60950077fb5242bb92c8b5c7f1a6f2668a", "sha256:fa1fe75b4a9e18b66ae7f0b122543c42debcf800aaafa0212aaff3ad273c2596" ], - "markers": "python_version >= '3.6'", "version": "==1.19.0" }, "openapi-spec-validator": { @@ -602,7 +579,6 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pandas": { @@ -665,19 +641,8 @@ }, "pyasn1": { "hashes": [ - "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", - "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", - "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", - "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", - "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", - "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", - "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", - "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", - "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", - "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba" ], "version": "==0.4.8" }, @@ -686,7 +651,6 @@ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.20" }, "pygments": { @@ -694,7 +658,6 @@ "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" ], - "markers": "python_version >= '3.5'", "version": "==2.6.1" }, "pyjwt": { @@ -710,7 +673,6 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pyrsistent": { @@ -737,9 +699,7 @@ "hashes": [ "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", - "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8", - "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77", - "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522" + "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8" ], "version": "==1.0.4" }, @@ -853,7 +813,6 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "snowballstemmer": { @@ -868,7 +827,6 @@ "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" ], - "markers": "python_version >= '3.5'", "version": "==2.0.1" }, "sphinx": { @@ -884,7 +842,6 @@ "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" ], - "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-devhelp": { @@ -892,7 +849,6 @@ "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" ], - "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-htmlhelp": { @@ -900,7 +856,6 @@ "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" ], - "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-jsmath": { @@ -908,7 +863,6 @@ "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" ], - "markers": "python_version >= '3.5'", "version": "==1.0.1" }, "sphinxcontrib-qthelp": { @@ -916,7 +870,6 @@ "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" ], - "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-serializinghtml": { @@ -924,13 +877,12 @@ "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc", "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a" ], - "markers": "python_version >= '3.5'", "version": "==1.1.4" }, "spiffworkflow": { "editable": true, "git": "https://github.com/sartography/SpiffWorkflow.git", - "ref": "e47dbce4147f2475f50ef705eab32a1426540613" + "ref": "bf9fdcd51846126e0acc8eeccad1a16c8b8330ce" }, "sqlalchemy": { "hashes": [ @@ -963,7 +915,6 @@ "sha256:f57be5673e12763dd400fea568608700a63ce1c6bd5bdbc3cc3a2c5fdb045274", "sha256:fc728ece3d5c772c196fd338a99798e7efac7a04f9cb6416299a3638ee9a94cd" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.18" }, "swagger-ui-bundle": { @@ -980,7 +931,6 @@ "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==1.25.9" }, "vine": { @@ -988,7 +938,6 @@ "sha256:133ee6d7a9016f177ddeaf191c1f58421a1dcc6ee9a42c58b34bed40e1d2cd87", "sha256:ea4947cc56d1fd6f2095c8d543ee25dad966f78692528e68b4fada11ba3f98af" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.0" }, "waitress": { @@ -996,7 +945,6 @@ "sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261", "sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==1.4.4" }, "webob": { @@ -1004,7 +952,6 @@ "sha256:a3c89a8e9ba0aeb17382836cdb73c516d0ecf6630ec40ec28288f3ed459ce87b", "sha256:aa3a917ed752ba3e0b242234b2a373f9c4e2a75d35291dcbe977649bd21fd108" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.8.6" }, "webtest": { @@ -1051,7 +998,6 @@ "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" ], - "markers": "python_version >= '3.6'", "version": "==3.1.0" } }, @@ -1061,7 +1007,6 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "coverage": { @@ -1117,7 +1062,6 @@ "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" ], - "markers": "python_version >= '3.5'", "version": "==8.4.0" }, "packaging": { @@ -1125,7 +1069,6 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pbr": { @@ -1141,7 +1084,6 @@ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.13.1" }, "py": { @@ -1149,7 +1091,6 @@ "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.9.0" }, "pyparsing": { @@ -1157,7 +1098,6 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pytest": { @@ -1173,7 +1113,6 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "wcwidth": { @@ -1188,7 +1127,6 @@ "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" ], - "markers": "python_version >= '3.6'", "version": "==3.1.0" } } diff --git a/crc/api/workflow.py b/crc/api/workflow.py index dc86ac9e..82a4b27f 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -107,10 +107,11 @@ def delete_workflow(workflow_id): def set_current_task(workflow_id, task_id): workflow_model = session.query(WorkflowModel).filter_by(id=workflow_id).first() - user_uid = __get_user_uid(workflow_model.study.user_uid) processor = WorkflowProcessor(workflow_model) task_id = uuid.UUID(task_id) spiff_task = processor.bpmn_workflow.get_task(task_id) + _verify_user_and_role(workflow_model, spiff_task) + user_uid = g.user.uid if spiff_task.state != spiff_task.COMPLETED and spiff_task.state != spiff_task.READY: raise ApiError("invalid_state", "You may not move the token to a task who's state is not " "currently set to COMPLETE or READY.") @@ -136,15 +137,17 @@ def update_task(workflow_id, task_id, body, terminate_loop=None): elif workflow_model.study is None: raise ApiError("invalid_study", "There is no study associated with the given workflow.", status_code=404) - user_uid = __get_user_uid(workflow_model.study.user_uid) processor = WorkflowProcessor(workflow_model) task_id = uuid.UUID(task_id) spiff_task = processor.bpmn_workflow.get_task(task_id) + _verify_user_and_role(workflow_model, spiff_task) + user_uid = g.user.uid if not spiff_task: raise ApiError("empty_task", "Processor failed to obtain task.", status_code=404) if spiff_task.state != spiff_task.READY: raise ApiError("invalid_state", "You may not update a task unless it is in the READY state. " "Consider calling a token reset to make this task Ready.") + if terminate_loop: spiff_task.terminate_loop() @@ -156,6 +159,9 @@ def update_task(workflow_id, task_id, body, terminate_loop=None): WorkflowService.log_task_action(user_uid, workflow_model, spiff_task, WorkflowService.TASK_ACTION_COMPLETE, version=processor.get_version_string()) workflow_api_model = WorkflowService.processor_to_workflow_api(processor) + + # If the next task + return WorkflowApiSchema().dump(workflow_api_model) @@ -210,13 +216,37 @@ def lookup(workflow_id, field_id, query=None, value=None, limit=10): return LookupDataSchema(many=True).dump(lookup_data) -def __get_user_uid(user_uid): - if 'user' in g: - if g.user.uid not in app.config['ADMIN_UIDS'] and user_uid != g.user.uid: - raise ApiError("permission_denied", "You are not authorized to edit the task data for this workflow.", - status_code=403) - else: - return g.user.uid +def _verify_user_and_role(workflow_model, spiff_task): + """Assures the currently logged in user can access the given workflow and task, or + raises an error. + Allow administrators to modify tasks, otherwise assure that the current user + is allowed to edit or update the task. Will raise the appropriate error if user + is not authorized. """ - else: + if 'user' not in g: raise ApiError("logged_out", "You are no longer logged in.", status_code=401) + + if g.user.uid in app.config['ADMIN_UIDS']: + return g.user.uid + + # If the task is in a lane, determine the user from that value. + if spiff_task.task_spec.lane is not None: + if spiff_task.task_spec.lane not in spiff_task.data: + raise ApiError.from_task("invalid_role", + f"This task is in a lane called '{spiff_task.task_spec.lane}', The " + f" current task data must have information mapping this role to " + f" a unique user id.", spiff_task) + user_id = spiff_task.data[spiff_task.task_spec.lane] + if g.user.uid != user_id: + raise ApiError.from_task("role_permission", + f"This task is in a lane called '{spiff_task.task_spec.lane}' which" + f" must be completed by '{user_id}', but you are {g.user.uid}", spiff_task) + return + elif g.user.uid == workflow_model.study.user_uid: + return + + # todo: If other users as associated with the study, and were granted access, allow them to modify tasks as well + + raise ApiError("permission_denied", "You are not authorized to edit the task data for this workflow.", + status_code=403, task_id=spiff_task.id, task_name=spiff_task.name, task_data=spiff_task.data) + diff --git a/crc/models/api_models.py b/crc/models/api_models.py index bb99eebb..e4d8425b 100644 --- a/crc/models/api_models.py +++ b/crc/models/api_models.py @@ -29,6 +29,7 @@ class NavigationItem(object): self.state = state self.is_decision = is_decision self.task = task + self.lane = lane class Task(object): @@ -107,10 +108,11 @@ class TaskSchema(ma.Schema): class NavigationItemSchema(ma.Schema): class Meta: fields = ["id", "task_id", "name", "title", "backtracks", "level", "indent", "child_count", "state", - "is_decision", "task"] + "is_decision", "task", "lane"] unknown = INCLUDE task = marshmallow.fields.Nested(TaskSchema, dump_only=True, required=False, allow_none=True) backtracks = marshmallow.fields.String(required=False, allow_none=True) + lane = 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) diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 157c4c13..18f88f4c 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -92,11 +92,16 @@ class WorkflowService(object): processor.bpmn_workflow.do_engine_steps() 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 documenation, and raise those errors. WorkflowService.populate_form_with_random_data(task, task_api, required_only) - task.complete() + processor.complete_task(task) except WorkflowException as we: WorkflowService.delete_test_data() raise ApiError.from_workflow_exception("workflow_validation_exception", str(we), we) @@ -195,25 +200,24 @@ class WorkflowService(object): possible, next_task is set to the current_task.""" nav_dict = processor.bpmn_workflow.get_nav_list() + + # Some basic cleanup of the title for the for the navigation. 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') # fixme: duplicate code from the workflow_service. Should only do this in one place. - if ' ' in nav_item['title']: + if nav_item['title'] is not None and ' ' in nav_item['title']: nav_item['title'] = nav_item['title'].partition(' ')[2] 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) diff --git a/tests/base_test.py b/tests/base_test.py index 116df5a2..2ead9e43 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -263,13 +263,13 @@ class BaseTest(unittest.TestCase): return full_study - def create_workflow(self, workflow_name, study=None, category_id=None): + def create_workflow(self, workflow_name, study=None, category_id=None, as_user="dhf8r"): db.session.flush() spec = db.session.query(WorkflowSpecModel).filter(WorkflowSpecModel.name == workflow_name).first() if spec is None: spec = self.load_test_spec(workflow_name, category_id=category_id) if study is None: - study = self.create_study() + study = self.create_study(uid=as_user) workflow_model = StudyService._create_workflow_model(study, spec) return workflow_model @@ -313,6 +313,7 @@ class BaseTest(unittest.TestCase): self.assertEqual(workflow.workflow_spec_id, workflow_api.workflow_spec_id) return workflow_api + def complete_form(self, workflow_in, task_in, dict_data, error_code=None, terminate_loop=None, user_uid="dhf8r"): prev_completed_task_count = workflow_in.completed_tasks if isinstance(task_in, dict): diff --git a/tests/data/invalid_roles/invalid_roles.bpmn b/tests/data/invalid_roles/invalid_roles.bpmn new file mode 100644 index 00000000..de10f712 --- /dev/null +++ b/tests/data/invalid_roles/invalid_roles.bpmn @@ -0,0 +1,177 @@ + + + + + + + + + StartEvent_1 + Activity_1hljoeq + Event_0lscajc + Activity_19ccxoj + + + Gateway_1fkgc4u + Activity_14eor1x + + + + Flow_0a7090c + + + # Answer me these questions 3, ere the other side you see! + + + + + + + + Flow_0a7090c + Flow_070gq5r + Flow_1hcpt7c + + + Flow_1gp4zfd + Flow_0vnghsi + Flow_1g38q6b + + + # Your responses were approved! + + +Gosh! you must really know a lot about colors and swallows and stuff! +Your supervisor provided the following feedback: + + +{{feedback}} + + +You are all done! WARNING: If you go back and reanswer the questions it will create a new approval request. + + + + + + + Flow_1g38q6b + + + # Your Request was rejected + + +Perhaps you don't know the right answer to one of the questions. +Your Supervisor provided the following feedback: + + +{{feedback}} + + +Please press save to re-try the questions, and submit your responses again. + + + + + + + Flow_0vnghsi + Flow_070gq5r + + + + + + + + + Flow_1hcpt7c + Flow_1gp4zfd + + + + + approval==True + + + approval==True + + + + + Removed a field that would set the supervisor, making this not validate. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/ldap_response.json b/tests/data/ldap_response.json index f42fee94..cab99457 100644 --- a/tests/data/ldap_response.json +++ b/tests/data/ldap_response.json @@ -1,155 +1,124 @@ { - "entries": [ - { - "attributes": { - "cn": [ - "Laura Barnes (lb3dp)" - ], - "displayName": "Laura Barnes", - "givenName": [ - "Laura" - ], - "mail": [ - "lb3dp@virginia.edu" - ], - "objectClass": [ - "top", - "person", - "organizationalPerson", - "inetOrgPerson", - "uvaPerson", - "uidObject" - ], - "telephoneNumber": [ - "+1 (434) 924-1723" - ], - "title": [ - "E0:Associate Professor of Systems and Information Engineering" - ], - "uvaDisplayDepartment": [ - "E0:EN-Eng Sys and Environment" - ], - "uvaPersonIAMAffiliation": [ - "faculty" - ], - "uvaPersonSponsoredType": [ - "Staff" - ] - }, - "dn": "uid=lb3dp,ou=People,o=University of Virginia,c=US", - "raw": { - "cn": [ - "Laura Barnes (lb3dp)" - ], - "displayName": [ - "Laura Barnes" - ], - "givenName": [ - "Laura" - ], - "mail": [ - "lb3dp@virginia.edu" - ], - "objectClass": [ - "top", - "person", - "organizationalPerson", - "inetOrgPerson", - "uvaPerson", - "uidObject" - ], - "telephoneNumber": [ - "+1 (434) 924-1723" - ], - "title": [ - "E0:Associate Professor of Systems and Information Engineering" - ], - "uvaDisplayDepartment": [ - "E0:EN-Eng Sys and Environment" - ], - "uvaPersonIAMAffiliation": [ - "faculty" - ], - "uvaPersonSponsoredType": [ - "Staff" - ] - } - }, - { - "attributes": { - "cn": [ - "Dan Funk (dhf8r)" - ], - "displayName": "Dan Funk", - "givenName": [ - "Dan" - ], - "mail": [ - "dhf8r@virginia.edu" - ], - "objectClass": [ - "top", - "person", - "organizationalPerson", - "inetOrgPerson", - "uvaPerson", - "uidObject" - ], - "telephoneNumber": [ - "+1 (434) 924-1723" - ], - "title": [ - "E42:He's a hoopy frood" - ], - "uvaDisplayDepartment": [ - "E0:EN-Eng Study of Parallel Universes" - ], - "uvaPersonIAMAffiliation": [ - "faculty" - ], - "uvaPersonSponsoredType": [ - "Staff" - ] - }, - "dn": "uid=dhf8r,ou=People,o=University of Virginia,c=US", - "raw": { - "cn": [ - "Dan Funk (dhf84)" - ], - "displayName": [ - "Dan Funk" - ], - "givenName": [ - "Dan" - ], - "mail": [ - "dhf8r@virginia.edu" - ], - "objectClass": [ - "top", - "person", - "organizationalPerson", - "inetOrgPerson", - "uvaPerson", - "uidObject" - ], - "telephoneNumber": [ - "+1 (434) 924-1723" - ], - "title": [ - "E42:He's a hoopy frood" - ], - "uvaDisplayDepartment": [ - "E0:EN-Eng Study of Parallel Universes" - ], - "uvaPersonIAMAffiliation": [ - "faculty" - ], - "uvaPersonSponsoredType": [ - "Staff" - ] - } - } - - ] + "entries": [ + { + "dn": "uid=lb3dp,ou=People,o=University of Virginia,c=US", + "raw": { + "cn": [ + "Laura Barnes (lb3dp)" + ], + "displayName": [ + "Laura Barnes" + ], + "givenName": [ + "Laura" + ], + "mail": [ + "lb3dp@virginia.edu" + ], + "objectClass": [ + "top", + "person", + "organizationalPerson", + "inetOrgPerson", + "uvaPerson", + "uidObject" + ], + "telephoneNumber": [ + "+1 (434) 924-1723" + ], + "title": [ + "E0:Associate Professor of Systems and Information Engineering" + ], + "uvaDisplayDepartment": [ + "E0:EN-Eng Sys and Environment" + ], + "uvaPersonIAMAffiliation": [ + "faculty" + ], + "uvaPersonSponsoredType": [ + "Staff" + ] + } + }, + { + "dn": "uid=dhf8r,ou=People,o=University of Virginia,c=US", + "raw": { + "cn": [ + "Dan Funk (dhf84)" + ], + "displayName": [ + "Dan Funk" + ], + "givenName": [ + "Dan" + ], + "mail": [ + "dhf8r@virginia.edu" + ], + "objectClass": [ + "top", + "person", + "organizationalPerson", + "inetOrgPerson", + "uvaPerson", + "uidObject" + ], + "telephoneNumber": [ + "+1 (434) 924-1723" + ], + "title": [ + "E42:He's a hoopy frood" + ], + "uvaDisplayDepartment": [ + "E0:EN-Eng Study of Parallel Universes" + ], + "uvaPersonIAMAffiliation": [ + "faculty" + ], + "uvaPersonSponsoredType": [ + "Staff" + ] + } + }, + { + "dn": "uid=lje5u,ou=People,o=University of Virginia,c=US", + "raw": { + "cn": [ + "Elder, Lori J (lje5u)" + ], + "displayName": [ + "Lori Elder" + ], + "givenName": [ + "Lori" + ], + "mail": [ + "lje5u@virginia.edu" + ], + "objectClass": [ + "top", + "person", + "organizationalPerson", + "inetOrgPerson", + "uvaPerson", + "uidObject" + ], + "telephoneNumber": [ + "+1 (434) 924-1723" + ], + "title": [ + "E42:The vision" + ], + "uvaDisplayDepartment": [ + "E0:EN-Phy Anything could go here." + ], + "uvaPersonIAMAffiliation": [ + "faculty" + ], + "uvaPersonSponsoredType": [ + "Staff" + ] + } + } + ] } \ No newline at end of file diff --git a/tests/data/roles/roles.bpmn b/tests/data/roles/roles.bpmn new file mode 100644 index 00000000..291f3bdd --- /dev/null +++ b/tests/data/roles/roles.bpmn @@ -0,0 +1,155 @@ + + + + + + + + + StartEvent_1 + Activity_1hljoeq + Event_0lscajc + Activity_19ccxoj + + + Gateway_1fkgc4u + Activity_14eor1x + + + + Flow_0a7090c + + + # Answer me these questions 3, ere the other side you see! + + + + + + + + + Flow_0a7090c + Flow_070gq5r + Flow_1hcpt7c + + + Flow_1gp4zfd + Flow_0vnghsi + Flow_1g38q6b + + + # Your responses were approved! + + +Gosh! you must really know a lot about colors and swallows and stuff! +Your supervisor provided the following feedback: + + +{{feedback}} + + +You are all done! WARNING: If you go back and reanswer the questions it will create a new approval request. + Flow_1g38q6b + + + # Your Request was rejected + + +Perhaps you don't know the right answer to one of the questions. +Your Supervisor provided the following feedback: + + +{{feedback}} + + +Please press save to re-try the questions, and submit your responses again. + Flow_0vnghsi + Flow_070gq5r + + + + + approval==True + + + approval==True + + + + + + + + + + + Flow_1hcpt7c + Flow_1gp4zfd + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index ebe99d93..702e8a89 100644 --- a/tests/test_tasks_api.py +++ b/tests/test_tasks_api.py @@ -9,80 +9,10 @@ from crc import session, app from crc.models.api_models import WorkflowApiSchema, MultiInstanceType, TaskSchema from crc.models.file import FileModelSchema from crc.models.workflow import WorkflowStatus -from crc.services.workflow_service import WorkflowService -from crc.models.stats import TaskEventModel + class TestTasksApi(BaseTest): - def get_workflow_api(self, workflow, soft_reset=False, hard_reset=False): - rv = self.app.get('/v1.0/workflow/%i?soft_reset=%s&hard_reset=%s' % - (workflow.id, str(soft_reset), str(hard_reset)), - headers=self.logged_in_headers(), - content_type="application/json") - self.assert_success(rv) - json_data = json.loads(rv.get_data(as_text=True)) - workflow_api = WorkflowApiSchema().load(json_data) - self.assertEqual(workflow.workflow_spec_id, workflow_api.workflow_spec_id) - return workflow_api - - def complete_form(self, workflow_in, task_in, dict_data, error_code = None): - prev_completed_task_count = workflow_in.completed_tasks - if isinstance(task_in, dict): - task_id = task_in["id"] - else: - task_id = task_in.id - rv = self.app.put('/v1.0/workflow/%i/task/%s/data' % (workflow_in.id, task_id), - headers=self.logged_in_headers(), - content_type="application/json", - data=json.dumps(dict_data)) - if error_code: - self.assert_failure(rv, error_code=error_code) - return - - self.assert_success(rv) - json_data = json.loads(rv.get_data(as_text=True)) - - # Assure stats are updated on the model - workflow = WorkflowApiSchema().load(json_data) - # The total number of tasks may change over time, as users move through gateways - # branches may be pruned. As we hit parallel Multi-Instance new tasks may be created... - self.assertIsNotNone(workflow.total_tasks) - self.assertEqual(prev_completed_task_count + 1, workflow.completed_tasks) - # Assure a record exists in the Task Events - task_events = session.query(TaskEventModel) \ - .filter_by(workflow_id=workflow.id) \ - .filter_by(task_id=task_id) \ - .order_by(TaskEventModel.date.desc()).all() - self.assertGreater(len(task_events), 0) - event = task_events[0] - self.assertIsNotNone(event.study_id) - self.assertEqual("dhf8r", event.user_uid) - self.assertEqual(workflow.id, event.workflow_id) - self.assertEqual(workflow.workflow_spec_id, event.workflow_spec_id) - self.assertEqual(workflow.spec_version, event.spec_version) - self.assertEqual(WorkflowService.TASK_ACTION_COMPLETE, event.action) - self.assertEqual(task_in.id, task_id) - self.assertEqual(task_in.name, event.task_name) - self.assertEqual(task_in.title, event.task_title) - self.assertEqual(task_in.type, event.task_type) - self.assertEqual("COMPLETED", event.task_state) - # Not sure what vodoo is happening inside of marshmallow to get me in this state. - if isinstance(task_in.multi_instance_type, MultiInstanceType): - self.assertEqual(task_in.multi_instance_type.value, event.mi_type) - else: - self.assertEqual(task_in.multi_instance_type, event.mi_type) - - self.assertEqual(task_in.multi_instance_count, event.mi_count) - self.assertEqual(task_in.multi_instance_index, event.mi_index) - self.assertEqual(task_in.process_name, event.process_name) - self.assertIsNotNone(event.date) - - # Assure that there is data in the form_data - self.assertIsNotNone(event.form_data) - - workflow = WorkflowApiSchema().load(json_data) - return workflow - def assert_options_populated(self, results, lookup_data_keys): option_keys = ['value', 'label', 'data'] self.assertIsInstance(results, list) diff --git a/tests/workflow/test_workflow_processor.py b/tests/workflow/test_workflow_processor.py index 44d90cf3..a51f029d 100644 --- a/tests/workflow/test_workflow_processor.py +++ b/tests/workflow/test_workflow_processor.py @@ -368,4 +368,19 @@ class TestWorkflowProcessor(BaseTest): task.task_spec.form.fields.append(field) with self.assertRaises(ApiError): - self._populate_form_with_random_data(task) \ No newline at end of file + self._populate_form_with_random_data(task) + + + def test_get_role_by_name(self): + self.load_example_data() + workflow_spec_model = self.load_test_spec("roles") + study = session.query(StudyModel).first() + processor = self.get_processor(study, workflow_spec_model) + processor.do_engine_steps() + tasks = processor.next_user_tasks() + task = tasks[0] + self._populate_form_with_random_data(task) + processor.complete_task(task) + supervisor_task = processor.next_user_tasks()[0] + self.assertEquals("supervisor", supervisor_task.task_spec.lane) + From 9077ff3ebfe26601bec8bdaaf5af47ca4790313d Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 14 Jul 2020 11:38:48 -0400 Subject: [PATCH 078/101] It is not possible to use task_data for an auto-complete field. It's too expensive an operation to provide that feature on the backend, and the data already fully resides on the front end anyway. Task-data can be used to populate enum fields if needed, so it can populate dropdowns, radios and checkboxes, just not auto-complete. --- crc/models/file.py | 1 - crc/services/lookup_service.py | 41 ++++---------------------------- crc/services/workflow_service.py | 27 +++++++++++++++++++-- tests/test_tasks_api.py | 13 +--------- 4 files changed, 30 insertions(+), 52 deletions(-) diff --git a/crc/models/file.py b/crc/models/file.py index 5eb50d4e..8afed6cd 100644 --- a/crc/models/file.py +++ b/crc/models/file.py @@ -144,7 +144,6 @@ class LookupFileModel(db.Model): """Gives us a quick way to tell what kind of lookup is set on a form field. Connected to the file data model, so that if a new version of the same file is created, we can update the listing.""" - #fixme: What happens if they change the file associated with a lookup field? __tablename__ = 'lookup_file' id = db.Column(db.Integer, primary_key=True) workflow_spec_id = db.Column(db.String) diff --git a/crc/services/lookup_service.py b/crc/services/lookup_service.py index 65680f34..c9eb1dd8 100644 --- a/crc/services/lookup_service.py +++ b/crc/services/lookup_service.py @@ -4,7 +4,7 @@ from collections import OrderedDict import pandas as pd from pandas import ExcelFile, np -from sqlalchemy import func, desc +from sqlalchemy import desc from sqlalchemy.sql.functions import GenericFunction from crc import db @@ -119,40 +119,6 @@ class LookupService(object): lookup_model = LookupService.build_lookup_table(data_model, value_column, label_column, workflow_model.workflow_spec_id, field_id) - # Use the value of some other task data field to populate enum field options - elif field.has_property(Task.PROP_OPTIONS_DATA_NAME): - if not (field.has_property(Task.PROP_OPTIONS_DATA_VALUE_COLUMN) or - field.has_property(Task.PROP_OPTIONS_DATA_LABEL_COLUMN)): - raise ApiError.from_task("invalid_enum", - "For enumerations based on task data, you must include 3 properties: %s, " - "%s, and %s" % (Task.PROP_OPTIONS_DATA_NAME, - Task.PROP_OPTIONS_DATA_VALUE_COLUMN, - Task.PROP_OPTIONS_DATA_LABEL_COLUMN), - task=spiff_task) - - prop = field.get_property(Task.PROP_OPTIONS_DATA_NAME) - if prop not in spiff_task.data: - raise ApiError.from_task("invalid_enum", "For enumerations based on task data, task data must have " - "a property called '%s'" % prop, - task=spiff_task) - - # Get the enum options from the task data - data_model = spiff_task.data[prop] - value_column = field.get_property(Task.PROP_OPTIONS_DATA_VALUE_COLUMN) - label_column = field.get_property(Task.PROP_OPTIONS_DATA_LABEL_COLUMN) - lookup_model = LookupFileModel(workflow_spec_id=workflow_model.workflow_spec_id, - field_id=field_id, - is_ldap=False) - db.session.add(lookup_model) - items = data_model.items() if isinstance(data_model, dict) else data_model - for item in items: - lookup_data = LookupDataModel(lookup_file_model=lookup_model, - value=item[value_column], - label=item[label_column], - data=item) - db.session.add(lookup_data) - db.session.commit() - # Use the results of an LDAP request to populate enum field options elif field.has_property(Task.PROP_LDAP_LOOKUP): lookup_model = LookupFileModel(workflow_spec_id=workflow_model.workflow_spec_id, @@ -160,8 +126,8 @@ class LookupService(object): is_ldap=True) else: raise ApiError("unknown_lookup_option", - "Lookup supports using spreadsheet, task data, or LDAP options, " - "and none of those was provided.") + "Lookup supports using spreadsheet or LDAP options, " + "and neither of those was provided.") db.session.add(lookup_model) db.session.commit() return lookup_model @@ -247,3 +213,4 @@ class LookupService(object): "data": user }) return user_list + diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 3a82aa24..e212179b 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -394,16 +394,39 @@ class WorkflowService(object): # If this is an auto-complete field, do not populate options, a lookup will happen later. if field.type == Task.FIELD_TYPE_AUTO_COMPLETE: pass - elif field.has_property(Task.PROP_OPTIONS_FILE_NAME) or field.has_property(Task.PROP_OPTIONS_DATA_NAME): + elif field.has_property(Task.PROP_OPTIONS_FILE_NAME): lookup_model = LookupService.get_lookup_model(spiff_task, field) data = db.session.query(LookupDataModel).filter(LookupDataModel.lookup_file_model == lookup_model).all() if not hasattr(field, 'options'): field.options = [] for d in data: field.options.append({"id": d.value, "name": d.label, "data": d.data}) - + elif field.has_property(Task.PROP_OPTIONS_DATA_NAME): + field.options = WorkflowService.get_options_from_task_data(spiff_task, field) return field + @staticmethod + def get_options_from_task_data(spiff_task, field): + if not (field.has_property(Task.PROP_OPTIONS_DATA_VALUE_COLUMN) or + field.has_property(Task.PROP_OPTIONS_DATA_LABEL_COLUMN)): + raise ApiError.from_task("invalid_enum", + f"For enumerations based on task data, you must include 3 properties: " + f"{Task.PROP_OPTIONS_DATA_NAME}, {Task.PROP_OPTIONS_DATA_VALUE_COLUMN}, " + f"{Task.PROP_OPTIONS_DATA_LABEL_COLUMN}", task=spiff_task) + prop = field.get_property(Task.PROP_OPTIONS_DATA_NAME) + if prop not in spiff_task.data: + raise ApiError.from_task("invalid_enum", f"For enumerations based on task data, task data must have " + f"a property called {prop}", task=spiff_task) + # Get the enum options from the task data + data_model = spiff_task.data[prop] + value_column = field.get_property(Task.PROP_OPTIONS_DATA_VALUE_COLUMN) + label_column = field.get_property(Task.PROP_OPTIONS_DATA_LABEL_COLUMN) + items = data_model.items() if isinstance(data_model, dict) else data_model + options = [] + for item in items: + options.append({"id": item[value_column], "name": item[label_column], "data": item}) + return options + @staticmethod def log_task_action(user_uid, workflow_model, spiff_task, action, version): task = WorkflowService.spiff_task_to_api_task(spiff_task) diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index 7f5cc4da..09690058 100644 --- a/tests/test_tasks_api.py +++ b/tests/test_tasks_api.py @@ -403,7 +403,7 @@ class TestTasksApi(BaseTest): self.assert_options_populated(results, ['CUSTOMER_NUMBER', 'CUSTOMER_NAME', 'CUSTOMER_CLASS_MEANING']) self.assertIsInstance(results[0]['data'], dict) - def test_lookup_endpoint_enum_in_task_data(self): + def test_enum_from_task_data(self): self.load_example_data() workflow = self.create_workflow('enum_options_from_task_data') # get the first form in the two form workflow. @@ -428,17 +428,6 @@ class TestTasksApi(BaseTest): self.assertEqual('Berthilda', options[1]['data']['first_name']) self.assertEqual('Chesterfield', options[2]['data']['first_name']) - rv = self.app.get('/v1.0/workflow/%i/lookup/%s?value=%s' % - (workflow.id, field_id, option_id), - headers=self.logged_in_headers(), - content_type="application/json") - self.assert_success(rv) - results = json.loads(rv.get_data(as_text=True)) - self.assertEqual(1, len(results)) - self.assert_options_populated(results, ['first_name', 'last_name', 'age', 'likes_pie', 'num_lumps', - 'secret_id', 'display_name']) - self.assertIsInstance(results[0]['data'], dict) - def test_lookup_endpoint_for_task_ldap_field_lookup(self): self.load_example_data() workflow = self.create_workflow('ldap_lookup') From d71ff80eac435dee3e03c8bdfa445e7029689e21 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Tue, 14 Jul 2020 13:42:52 -0400 Subject: [PATCH 079/101] Adds end and start events --- crc/services/workflow_service.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index e212179b..7e869fae 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -6,9 +6,11 @@ import random import jinja2 from SpiffWorkflow import Task as SpiffTask, WorkflowException +from SpiffWorkflow.bpmn.specs.EndEvent import EndEvent from SpiffWorkflow.bpmn.specs.ManualTask import ManualTask from SpiffWorkflow.bpmn.specs.MultiInstanceTask import MultiInstanceTask from SpiffWorkflow.bpmn.specs.ScriptTask import ScriptTask +from SpiffWorkflow.bpmn.specs.StartEvent import StartEvent from SpiffWorkflow.bpmn.specs.UserTask import UserTask from SpiffWorkflow.dmn.specs.BusinessRuleTask import BusinessRuleTask from SpiffWorkflow.specs import CancelTask, StartTask @@ -273,20 +275,14 @@ class WorkflowService(object): def spiff_task_to_api_task(spiff_task, add_docs_and_forms=False): task_type = spiff_task.task_spec.__class__.__name__ - if isinstance(spiff_task.task_spec, UserTask): - task_type = "UserTask" - elif isinstance(spiff_task.task_spec, ManualTask): - task_type = "ManualTask" - elif isinstance(spiff_task.task_spec, BusinessRuleTask): - task_type = "BusinessRuleTask" - elif isinstance(spiff_task.task_spec, CancelTask): - task_type = "CancelTask" - elif isinstance(spiff_task.task_spec, ScriptTask): - task_type = "ScriptTask" - elif isinstance(spiff_task.task_spec, StartTask): - task_type = "StartTask" - else: - task_type = "NoneTask" + task_types = [UserTask, ManualTask, BusinessRuleTask, CancelTask, ScriptTask, StartTask, EndEvent, StartEvent] + + for t in task_types: + if isinstance(spiff_task.task_spec, t): + task_type = t.__name__ + break + else: + task_type = "NoneTask" info = spiff_task.task_info() if info["is_looping"]: From c8214a4cc444504b9019634f41aa9faf6f444bc8 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Tue, 14 Jul 2020 14:49:04 -0400 Subject: [PATCH 080/101] Updates DSP --- .../bpmn/data_security_plan/HIPAA_Ids.xls | Bin 0 -> 34304 bytes .../data_security_plan/NEW_DSP_template.docx | Bin 72003 -> 53777 bytes .../data_security_plan.bpmn | 546 ++++++------------ 3 files changed, 167 insertions(+), 379 deletions(-) create mode 100644 crc/static/bpmn/data_security_plan/HIPAA_Ids.xls diff --git a/crc/static/bpmn/data_security_plan/HIPAA_Ids.xls b/crc/static/bpmn/data_security_plan/HIPAA_Ids.xls new file mode 100644 index 0000000000000000000000000000000000000000..2d703832f8222f97a9e1f71a6da40f3adec54bfd GIT binary patch literal 34304 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 DG@_-2 literal 0 HcmV?d00001 diff --git a/crc/static/bpmn/data_security_plan/NEW_DSP_template.docx b/crc/static/bpmn/data_security_plan/NEW_DSP_template.docx index 9c282eaa850a4441f6ed9b19f8775bf3cf688698..b4925d1ffcb05e5c845230a5c0082ed6a513dc45 100644 GIT binary patch delta 22364 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 delta 40716 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 - + SequenceFlow_100w7co @@ -10,45 +10,27 @@ - + + + + - - - - - - - - - - - - - - - - - - - - - + - - + - + @@ -162,28 +144,10 @@ + + + - - - - - - - - - - - - - - - - - - - - - @@ -202,31 +166,14 @@ - + + + + - - - - - - - - - - - - - - - - - - - - @@ -253,9 +200,8 @@ Answer the questions for each of the Individual Use Devices that you use to collect or store your data onto your individual use device during the course of your research. Do not select these items if they are only to be used to connect elsewhere (to the items you identified in Electronic Medical Record, UVA approved eCRF or clinical trials management system, UVA servers & websites, and Web-based server, cloud server, or any non-centrally managed server): - + - @@ -276,95 +222,75 @@ - + - - + - + - - + - + - - + + + + - - - - - - - - - - - - - - - - - - - - - + - - + - + - + - + - + + + + + + + + + + + + + - - + - + - - + - + - - - - - - - - - - + @@ -372,7 +298,7 @@ SequenceFlow_0nc6lcs SequenceFlow_0gp2pjm
- + @@ -389,7 +315,7 @@ Indicate all the possible formats in which you will transmit your data outside o - Flow_0cpwkms + SequenceFlow_0gp2pjm SequenceFlow_0mgwas4 @@ -415,7 +341,6 @@ Indicate all the possible formats in which you will transmit your data outside o - @@ -424,48 +349,27 @@ Indicate all the possible formats in which you will transmit your data outside o - + + + - - - - - - - - - - - - - - - - - - - - - - - @@ -498,7 +402,7 @@ Indicate all the possible formats in which you will transmit your data outside o - + @@ -506,7 +410,7 @@ Indicate all the possible formats in which you will transmit your data outside o - + @@ -518,6 +422,7 @@ Indicate all the possible formats in which you will transmit your data outside o SequenceFlow_0lere0k + Done message SequenceFlow_16kyite @@ -623,339 +528,222 @@ 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 - - - - - - - - - - - - - + + - - - - + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - - + + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - + + - - + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - - - - - + + + - + - + - + - + - + - + - + + + + + + + - + + + + + + + - + - + - + - + - + - + - + - + - + - + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - From d85ca1ce51ab3c59626a398f2dff08885ed4ee7c Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 14 Jul 2020 22:16:44 -0400 Subject: [PATCH 081/101] Whenever a workflow is loaded or updated, add events to the TaskEvent table with assignments for the next set of ready tasks, along with who should complete those tasks. Add the lane information to the Task model. Drop the foreign key constraint on the user_uid in the task log, as we might create tasks for users before they ever log into the system. Add a new endpoint to the API called task events. It should be possible to query this and get a list of all tasks that need a users attention. The task events returned include detailed information about the workflow and study as sub-models Rename all the actions in event log to things that are easier to pass over the api as arguments, make this backwards compatible, updating existing names in the database via the migration. Throughly test the navigation and task details as control of the workflow is passed between two lanes. --- crc/api.yml | 51 ++++++++- crc/api/admin.py | 2 +- crc/api/workflow.py | 73 ++++++------- crc/models/api_models.py | 9 +- crc/models/stats.py | 33 ------ crc/models/task_event.py | 64 +++++++++++ crc/services/study_service.py | 2 +- crc/services/workflow_service.py | 70 ++++++++++-- migrations/versions/ffef4661a37d_.py | 38 +++++++ tests/base_test.py | 7 +- tests/data/roles/roles.bpmn | 2 +- tests/study/test_study_api.py | 2 +- tests/test_user_roles.py | 139 ++++++++++++++++++++---- tests/workflow/test_workflow_service.py | 7 +- 14 files changed, 384 insertions(+), 115 deletions(-) delete mode 100644 crc/models/stats.py create mode 100644 crc/models/task_event.py create mode 100644 migrations/versions/ffef4661a37d_.py diff --git a/crc/api.yml b/crc/api.yml index 213e8d15..3d504ad4 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -502,7 +502,6 @@ paths: application/json: schema: $ref: "#/components/schemas/File" - # /v1.0/workflow/0 /reference_file: get: operationId: crc.api.file.get_reference_files @@ -565,6 +564,26 @@ paths: type: string format: binary example: '' + /task_events: + parameters: + - name: action + in: query + required: false + description: The type of action the event documents, options include "ASSIGNMENT" for tasks that are waiting on you, "COMPLETE" for things have completed. + schema: + type: string + get: + operationId: crc.api.workflow.get_task_events + summary: Returns a list of task events related to the current user. Can be filtered by type. + tags: + - Workflows and Tasks + responses: + '200': + description: Returns details about tasks that are waiting on the current user. + content: + application/json: + schema: + $ref: "#/components/schemas/TaskEvent" # /v1.0/workflow/0 /workflow/{workflow_id}: parameters: @@ -1192,6 +1211,36 @@ components: value: "model.my_boolean_field_id && model.my_enum_field_value !== 'something'" - id: "hide_expression" value: "model.my_enum_field_value === 'something'" + TaskEvent: + properties: + workflow: + $ref: "#/components/schemas/Workflow" + study: + $ref: "#/components/schemas/Study" + workflow_sec: + $ref: "#/components/schemas/WorkflowSpec" + spec_version: + type: string + action: + type: string + task_id: + type: string + task_type: + type: string + task_lane: + type: string + form_data: + type: object + mi_type: + type: string + mi_count: + type: integer + mi_index: + type: integer + process_name: + type: string + date: + type: string Form: properties: key: diff --git a/crc/api/admin.py b/crc/api/admin.py index 37532c38..4e96fcd8 100644 --- a/crc/api/admin.py +++ b/crc/api/admin.py @@ -12,7 +12,7 @@ from crc import db, app from crc.api.user import verify_token, verify_token_admin from crc.models.approval import ApprovalModel from crc.models.file import FileModel -from crc.models.stats import TaskEventModel +from crc.models.task_event import TaskEventModel from crc.models.study import StudyModel from crc.models.user import UserModel from crc.models.workflow import WorkflowModel diff --git a/crc/api/workflow.py b/crc/api/workflow.py index 82a4b27f..a290d340 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -6,7 +6,8 @@ from crc import session, app from crc.api.common import ApiError, ApiErrorSchema from crc.models.api_models import WorkflowApi, WorkflowApiSchema, NavigationItem, NavigationItemSchema from crc.models.file import FileModel, LookupDataSchema -from crc.models.stats import TaskEventModel +from crc.models.study import StudyModel, WorkflowMetadata +from crc.models.task_event import TaskEventModel, TaskEventModelSchema, TaskEvent, TaskEventSchema from crc.models.workflow import WorkflowModel, WorkflowSpecModelSchema, WorkflowSpecModel, WorkflowSpecCategoryModel, \ WorkflowSpecCategoryModelSchema from crc.services.file_service import FileService @@ -87,7 +88,7 @@ def delete_workflow_specification(spec_id): session.query(TaskEventModel).filter(TaskEventModel.workflow_spec_id == spec_id).delete() - # Delete all stats and workflow models related to this specification + # Delete all events and workflow models related to this specification for workflow in session.query(WorkflowModel).filter_by(workflow_spec_id=spec_id): StudyService.delete_workflow(workflow) session.query(WorkflowSpecModel).filter_by(id=spec_id).delete() @@ -98,9 +99,27 @@ def get_workflow(workflow_id, soft_reset=False, hard_reset=False): workflow_model: WorkflowModel = session.query(WorkflowModel).filter_by(id=workflow_id).first() processor = WorkflowProcessor(workflow_model, soft_reset=soft_reset, hard_reset=hard_reset) workflow_api_model = WorkflowService.processor_to_workflow_api(processor) + WorkflowService.update_task_assignments(processor) return WorkflowApiSchema().dump(workflow_api_model) +def get_task_events(action): + """Provides a way to see a history of what has happened, or get a list of tasks that need your attention.""" + query = session.query(TaskEventModel).filter(TaskEventModel.user_uid == g.user.uid) + if action: + query = query.filter(TaskEventModel.action == action) + events = query.all() + + # Turn the database records into something a little richer for the UI to use. + task_events = [] + for event in events: + study = session.query(StudyModel).filter(StudyModel.id == event.study_id).first() + workflow = session.query(WorkflowModel).filter(WorkflowModel.id == event.workflow_id).first() + workflow_meta = WorkflowMetadata.from_workflow(workflow) + task_events.append(TaskEvent(event, study, workflow_meta)) + return TaskEventSchema(many=True).dump(task_events) + + def delete_workflow(workflow_id): StudyService.delete_workflow(workflow_id) @@ -110,7 +129,7 @@ def set_current_task(workflow_id, task_id): processor = WorkflowProcessor(workflow_model) task_id = uuid.UUID(task_id) spiff_task = processor.bpmn_workflow.get_task(task_id) - _verify_user_and_role(workflow_model, spiff_task) + _verify_user_and_role(processor, spiff_task) user_uid = g.user.uid if spiff_task.state != spiff_task.COMPLETED and spiff_task.state != spiff_task.READY: raise ApiError("invalid_state", "You may not move the token to a task who's state is not " @@ -121,16 +140,15 @@ def set_current_task(workflow_id, task_id): spiff_task.reset_token(reset_data=True) # Don't try to copy the existing data back into this task. processor.save() - WorkflowService.log_task_action(user_uid, workflow_model, spiff_task, - WorkflowService.TASK_ACTION_TOKEN_RESET, - version=processor.get_version_string()) + WorkflowService.log_task_action(user_uid, processor, spiff_task, WorkflowService.TASK_ACTION_TOKEN_RESET) + WorkflowService.update_task_assignments(processor) + workflow_api_model = WorkflowService.processor_to_workflow_api(processor, spiff_task) return WorkflowApiSchema().dump(workflow_api_model) def update_task(workflow_id, task_id, body, terminate_loop=None): workflow_model = session.query(WorkflowModel).filter_by(id=workflow_id).first() - if workflow_model is None: raise ApiError("invalid_workflow_id", "The given workflow id is not valid.", status_code=404) @@ -140,8 +158,7 @@ def update_task(workflow_id, task_id, body, terminate_loop=None): processor = WorkflowProcessor(workflow_model) task_id = uuid.UUID(task_id) spiff_task = processor.bpmn_workflow.get_task(task_id) - _verify_user_and_role(workflow_model, spiff_task) - user_uid = g.user.uid + _verify_user_and_role(processor, spiff_task) if not spiff_task: raise ApiError("empty_task", "Processor failed to obtain task.", status_code=404) if spiff_task.state != spiff_task.READY: @@ -150,18 +167,16 @@ def update_task(workflow_id, task_id, body, terminate_loop=None): if terminate_loop: spiff_task.terminate_loop() - spiff_task.update_data(body) processor.complete_task(spiff_task) processor.do_engine_steps() processor.save() - WorkflowService.log_task_action(user_uid, workflow_model, spiff_task, WorkflowService.TASK_ACTION_COMPLETE, - version=processor.get_version_string()) + # Log the action, and any pending task assignments in the event of lanes in the workflow. + WorkflowService.log_task_action(g.user.uid, processor, spiff_task, WorkflowService.TASK_ACTION_COMPLETE) + WorkflowService.update_task_assignments(processor) + workflow_api_model = WorkflowService.processor_to_workflow_api(processor) - - # If the next task - return WorkflowApiSchema().dump(workflow_api_model) @@ -216,7 +231,7 @@ def lookup(workflow_id, field_id, query=None, value=None, limit=10): return LookupDataSchema(many=True).dump(lookup_data) -def _verify_user_and_role(workflow_model, spiff_task): +def _verify_user_and_role(processor, spiff_task): """Assures the currently logged in user can access the given workflow and task, or raises an error. Allow administrators to modify tasks, otherwise assure that the current user @@ -229,24 +244,8 @@ def _verify_user_and_role(workflow_model, spiff_task): if g.user.uid in app.config['ADMIN_UIDS']: return g.user.uid - # If the task is in a lane, determine the user from that value. - if spiff_task.task_spec.lane is not None: - if spiff_task.task_spec.lane not in spiff_task.data: - raise ApiError.from_task("invalid_role", - f"This task is in a lane called '{spiff_task.task_spec.lane}', The " - f" current task data must have information mapping this role to " - f" a unique user id.", spiff_task) - user_id = spiff_task.data[spiff_task.task_spec.lane] - if g.user.uid != user_id: - raise ApiError.from_task("role_permission", - f"This task is in a lane called '{spiff_task.task_spec.lane}' which" - f" must be completed by '{user_id}', but you are {g.user.uid}", spiff_task) - return - elif g.user.uid == workflow_model.study.user_uid: - return - - # todo: If other users as associated with the study, and were granted access, allow them to modify tasks as well - - raise ApiError("permission_denied", "You are not authorized to edit the task data for this workflow.", - status_code=403, task_id=spiff_task.id, task_name=spiff_task.name, task_data=spiff_task.data) - + allowed_users = WorkflowService.get_users_assigned_to_task(processor, spiff_task) + if g.user.uid not in allowed_users: + raise ApiError.from_task("permission_denied", + f"This task must be completed by '{allowed_users}', " + f"but you are {g.user.uid}", spiff_task) diff --git a/crc/models/api_models.py b/crc/models/api_models.py index e4d8425b..8a1d3082 100644 --- a/crc/models/api_models.py +++ b/crc/models/api_models.py @@ -43,8 +43,9 @@ class Task(object): FIELD_TYPE_AUTO_COMPLETE = "autocomplete" - def __init__(self, id, name, title, type, state, form, documentation, data, - multi_instance_type, multi_instance_count, multi_instance_index, process_name, properties): + def __init__(self, id, name, title, type, state, lane, form, documentation, data, + multi_instance_type, multi_instance_count, multi_instance_index, + process_name, properties): self.id = id self.name = name self.title = title @@ -53,6 +54,7 @@ class Task(object): self.form = form self.documentation = documentation self.data = data + self.lane = lane 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. @@ -91,7 +93,7 @@ class FormSchema(ma.Schema): class TaskSchema(ma.Schema): class Meta: - fields = ["id", "name", "title", "type", "state", "form", "documentation", "data", "multi_instance_type", + fields = ["id", "name", "title", "type", "state", "lane", "form", "documentation", "data", "multi_instance_type", "multi_instance_count", "multi_instance_index", "process_name", "properties"] multi_instance_type = EnumField(MultiInstanceType) @@ -99,6 +101,7 @@ class TaskSchema(ma.Schema): form = marshmallow.fields.Nested(FormSchema, required=False, allow_none=True) title = marshmallow.fields.String(required=False, allow_none=True) process_name = marshmallow.fields.String(required=False, allow_none=True) + lane = marshmallow.fields.String(required=False, allow_none=True) @marshmallow.post_load def make_task(self, data, **kwargs): diff --git a/crc/models/stats.py b/crc/models/stats.py deleted file mode 100644 index 0a2e69b7..00000000 --- a/crc/models/stats.py +++ /dev/null @@ -1,33 +0,0 @@ -from marshmallow_sqlalchemy import SQLAlchemyAutoSchema - -from crc import db - - -class TaskEventModel(db.Model): - __tablename__ = 'task_event' - id = db.Column(db.Integer, primary_key=True) - study_id = db.Column(db.Integer, db.ForeignKey('study.id'), nullable=False) - user_uid = db.Column(db.String, db.ForeignKey('user.uid'), nullable=False) - workflow_id = db.Column(db.Integer, db.ForeignKey('workflow.id'), nullable=False) - workflow_spec_id = db.Column(db.String, db.ForeignKey('workflow_spec.id')) - spec_version = db.Column(db.String) - action = db.Column(db.String) - task_id = db.Column(db.String) - task_name = db.Column(db.String) - task_title = db.Column(db.String) - task_type = db.Column(db.String) - task_state = db.Column(db.String) - form_data = db.Column(db.JSON) # And form data submitted when the task was completed. - mi_type = db.Column(db.String) - mi_count = db.Column(db.Integer) - mi_index = db.Column(db.Integer) - process_name = db.Column(db.String) - date = db.Column(db.DateTime) - - -class TaskEventModelSchema(SQLAlchemyAutoSchema): - class Meta: - model = TaskEventModel - load_instance = True - include_relationships = True - include_fk = True # Includes foreign keys diff --git a/crc/models/task_event.py b/crc/models/task_event.py new file mode 100644 index 00000000..a6cb1a2d --- /dev/null +++ b/crc/models/task_event.py @@ -0,0 +1,64 @@ +from marshmallow import INCLUDE, fields +from marshmallow_sqlalchemy import SQLAlchemyAutoSchema + +from crc import db, ma +from crc.models.study import StudyModel, StudySchema, WorkflowMetadataSchema, WorkflowMetadata +from crc.models.workflow import WorkflowModel + + +class TaskEventModel(db.Model): + __tablename__ = 'task_event' + id = db.Column(db.Integer, primary_key=True) + study_id = db.Column(db.Integer, db.ForeignKey('study.id'), nullable=False) + user_uid = db.Column(db.String, nullable=False) # In some cases the unique user id may not exist in the db yet. + workflow_id = db.Column(db.Integer, db.ForeignKey('workflow.id'), nullable=False) + workflow_spec_id = db.Column(db.String, db.ForeignKey('workflow_spec.id')) + spec_version = db.Column(db.String) + action = db.Column(db.String) + task_id = db.Column(db.String) + task_name = db.Column(db.String) + task_title = db.Column(db.String) + task_type = db.Column(db.String) + task_state = db.Column(db.String) + task_lane = db.Column(db.String) + form_data = db.Column(db.JSON) # And form data submitted when the task was completed. + mi_type = db.Column(db.String) + mi_count = db.Column(db.Integer) + mi_index = db.Column(db.Integer) + process_name = db.Column(db.String) + date = db.Column(db.DateTime) + + +class TaskEventModelSchema(SQLAlchemyAutoSchema): + class Meta: + model = TaskEventModel + load_instance = True + include_relationships = True + include_fk = True # Includes foreign keys + + +class TaskEvent(object): + def __init__(self, model: TaskEventModel, study: StudyModel, workflow: WorkflowMetadata): + self.id = model.id + self.study = study + self.workflow = workflow + self.user_uid = model.user_uid + self.action = model.action + self.task_id = model.task_id + self.task_title = model.task_title + self.task_name = model.task_name + self.task_type = model.task_type + self.task_state = model.task_state + self.task_lane = model.task_lane + + +class TaskEventSchema(ma.Schema): + + study = fields.Nested(StudySchema, dump_only=True) + workflow = fields.Nested(WorkflowMetadataSchema, dump_only=True) + + class Meta: + model = TaskEvent + additional = ["id", "user_uid", "action", "task_id", "task_title", + "task_name", "task_type", "task_state", "task_lane"] + unknown = INCLUDE diff --git a/crc/services/study_service.py b/crc/services/study_service.py index ce283cfe..fbc62d01 100644 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -13,7 +13,7 @@ from crc.api.common import ApiError from crc.models.file import FileModel, FileModelSchema, File from crc.models.ldap import LdapSchema from crc.models.protocol_builder import ProtocolBuilderStudy, ProtocolBuilderStatus -from crc.models.stats import TaskEventModel +from crc.models.task_event import TaskEventModel from crc.models.study import StudyModel, Study, Category, WorkflowMetadata from crc.models.workflow import WorkflowSpecCategoryModel, WorkflowModel, WorkflowSpecModel, WorkflowState, \ WorkflowStatus diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 18f88f4c..9affb720 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -1,6 +1,7 @@ import copy import json import string +import uuid from datetime import datetime import random @@ -13,13 +14,14 @@ from SpiffWorkflow.bpmn.specs.UserTask import UserTask from SpiffWorkflow.dmn.specs.BusinessRuleTask import BusinessRuleTask from SpiffWorkflow.specs import CancelTask, StartTask from SpiffWorkflow.util.deep_merge import DeepMerge +from flask import g from jinja2 import Template from crc import db, app from crc.api.common import ApiError from crc.models.api_models import Task, MultiInstanceType, NavigationItem, NavigationItemSchema, WorkflowApi from crc.models.file import LookupDataModel -from crc.models.stats import TaskEventModel +from crc.models.task_event import TaskEventModel from crc.models.study import StudyModel from crc.models.user import UserModel from crc.models.workflow import WorkflowModel, WorkflowStatus, WorkflowSpecModel @@ -30,10 +32,13 @@ from crc.services.workflow_processor import WorkflowProcessor class WorkflowService(object): - TASK_ACTION_COMPLETE = "Complete" - TASK_ACTION_TOKEN_RESET = "Backwards Move" - TASK_ACTION_HARD_RESET = "Restart (Hard)" - TASK_ACTION_SOFT_RESET = "Restart (Soft)" + TASK_ACTION_COMPLETE = "COMPLETE" + TASK_ACTION_TOKEN_RESET = "TOKEN_RESET" + TASK_ACTION_HARD_RESET = "HARD_RESET" + TASK_ACTION_SOFT_RESET = "SOFT_RESET" + TASK_ACTION_ASSIGNMENT = "ASSIGNMENT" # Whenever the lane changes between tasks we assign the task to specifc user. + + TASK_STATE_LOCKED = "LOCKED" # When the task belongs to a different user. """Provides tools for processing workflows and tasks. This should at some point, be the only way to work with Workflows, and @@ -215,6 +220,11 @@ class WorkflowService(object): 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. + + user_uids = WorkflowService.get_users_assigned_to_task(processor, spiff_task) + if g.user.uid not in user_uids: + nav_item['state'] = WorkflowService.TASK_STATE_LOCKED + else: nav_item['task'] = None @@ -243,7 +253,10 @@ class WorkflowService(object): previous_form_data = WorkflowService.get_previously_submitted_data(processor.workflow_model.id, next_task) DeepMerge.merge(next_task.data, previous_form_data) workflow_api.next_task = WorkflowService.spiff_task_to_api_task(next_task, add_docs_and_forms=True) - + # Update the state of the task to locked if the current user does not own the task. + user_uids = WorkflowService.get_users_assigned_to_task(processor, next_task) + if g.user.uid not in user_uids: + workflow_api.next_task.state = WorkflowService.TASK_STATE_LOCKED return workflow_api @staticmethod @@ -307,11 +320,17 @@ class WorkflowService(object): for key, val in spiff_task.task_spec.extensions.items(): props[key] = val + if hasattr(spiff_task.task_spec, 'lane'): + lane = spiff_task.task_spec.lane + else: + lane = None + task = Task(spiff_task.id, spiff_task.task_spec.name, spiff_task.task_spec.description, task_type, spiff_task.get_state_name(), + lane, None, "", {}, @@ -409,21 +428,50 @@ class WorkflowService(object): return field @staticmethod - def log_task_action(user_uid, workflow_model, spiff_task, action, version): + def update_task_assignments(processor): + """For every upcoming user task, log a task action + that connects the assigned user(s) to that task. All + existing assignment actions for this workflow are removed from the database, + so that only the current valid actions are available. update_task_assignments + should be called whenever progress is made on a workflow.""" + db.session.query(TaskEventModel). \ + filter(TaskEventModel.workflow_id == processor.workflow_model.id). \ + filter(TaskEventModel.action == WorkflowService.TASK_ACTION_ASSIGNMENT).delete() + + for task in processor.get_current_user_tasks(): + user_ids = WorkflowService.get_users_assigned_to_task(processor, task) + for user_id in user_ids: + WorkflowService.log_task_action(user_id, processor, task, WorkflowService.TASK_ACTION_ASSIGNMENT) + + @staticmethod + def get_users_assigned_to_task(processor, spiff_task): + if not hasattr(spiff_task.task_spec, 'lane') or spiff_task.task_spec.lane is None: + return [processor.workflow_model.study.user_uid] + # todo: return a list of all users that can edit the study by default + if spiff_task.task_spec.lane not in spiff_task.data: + return [] # No users are assignable to the task at this moment + lane_users = spiff_task.data[spiff_task.task_spec.lane] + if not isinstance(lane_users, list): + lane_users = [lane_users] + return lane_users + + @staticmethod + def log_task_action(user_uid, processor, spiff_task, action): task = WorkflowService.spiff_task_to_api_task(spiff_task) form_data = WorkflowService.extract_form_data(spiff_task.data, spiff_task) task_event = TaskEventModel( - study_id=workflow_model.study_id, + study_id=processor.workflow_model.study_id, user_uid=user_uid, - workflow_id=workflow_model.id, - workflow_spec_id=workflow_model.workflow_spec_id, - spec_version=version, + workflow_id=processor.workflow_model.id, + workflow_spec_id=processor.workflow_model.workflow_spec_id, + spec_version=processor.get_version_string(), action=action, task_id=task.id, task_name=task.name, task_title=task.title, task_type=str(task.type), task_state=task.state, + task_lane=task.lane, form_data=form_data, 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. diff --git a/migrations/versions/ffef4661a37d_.py b/migrations/versions/ffef4661a37d_.py new file mode 100644 index 00000000..2a263951 --- /dev/null +++ b/migrations/versions/ffef4661a37d_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: ffef4661a37d +Revises: 5acd138e969c +Create Date: 2020-07-14 19:52:05.270939 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ffef4661a37d' +down_revision = '5acd138e969c' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('task_event', sa.Column('task_lane', sa.String(), nullable=True)) + op.drop_constraint('task_event_user_uid_fkey', 'task_event', type_='foreignkey') + op.execute("update task_event set action = 'COMPLETE' where action='Complete'") + op.execute("update task_event set action = 'TOKEN_RESET' where action='Backwards Move'") + op.execute("update task_event set action = 'HARD_RESET' where action='Restart (Hard)'") + op.execute("update task_event set action = 'SOFT_RESET' where action='Restart (Soft)'") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_foreign_key('task_event_user_uid_fkey', 'task_event', 'user', ['user_uid'], ['uid']) + op.drop_column('task_event', 'task_lane') + op.execute("update task_event set action = 'Complete' where action='COMPLETE'") + op.execute("update task_event set action = 'Backwards Move' where action='TOKEN_RESET'") + op.execute("update task_event set action = 'Restart (Hard)' where action='HARD_RESET'") + op.execute("update task_event set action = 'Restart (Soft)' where action='SOFT_RESET'") + # ### end Alembic commands ### diff --git a/tests/base_test.py b/tests/base_test.py index 2ead9e43..6ea1966d 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -16,7 +16,7 @@ from crc.models.api_models import WorkflowApiSchema, MultiInstanceType from crc.models.approval import ApprovalModel, ApprovalStatus from crc.models.file import FileModel, FileDataModel, CONTENT_TYPES from crc.models.protocol_builder import ProtocolBuilderStatus -from crc.models.stats import TaskEventModel +from crc.models.task_event import TaskEventModel from crc.models.study import StudyModel from crc.models.user import UserModel from crc.models.workflow import WorkflowSpecModel, WorkflowSpecModelSchema, WorkflowModel @@ -230,7 +230,7 @@ class BaseTest(unittest.TestCase): db.session.commit() return user - def create_study(self, uid="dhf8r", title="Beer conception in the bipedal software engineer", primary_investigator_id="lb3dp"): + def create_study(self, uid="dhf8r", title="Beer consumption in the bipedal software engineer", primary_investigator_id="lb3dp"): study = session.query(StudyModel).filter_by(user_uid=uid).filter_by(title=title).first() if study is None: user = self.create_user(uid=uid) @@ -340,7 +340,7 @@ class BaseTest(unittest.TestCase): self.assert_success(rv) json_data = json.loads(rv.get_data(as_text=True)) - # Assure stats are updated on the model + # Assure task events are updated on the model workflow = WorkflowApiSchema().load(json_data) # The total number of tasks may change over time, as users move through gateways # branches may be pruned. As we hit parallel Multi-Instance new tasks may be created... @@ -353,6 +353,7 @@ class BaseTest(unittest.TestCase): task_events = session.query(TaskEventModel) \ .filter_by(workflow_id=workflow.id) \ .filter_by(task_id=task_id) \ + .filter_by(action=WorkflowService.TASK_ACTION_COMPLETE) \ .order_by(TaskEventModel.date.desc()).all() self.assertGreater(len(task_events), 0) event = task_events[0] diff --git a/tests/data/roles/roles.bpmn b/tests/data/roles/roles.bpmn index 291f3bdd..be7992d7 100644 --- a/tests/data/roles/roles.bpmn +++ b/tests/data/roles/roles.bpmn @@ -70,7 +70,7 @@ Please press save to re-try the questions, and submit your responses again. - approval==True + approval==False approval==True diff --git a/tests/study/test_study_api.py b/tests/study/test_study_api.py index ea5a86e6..3b781f50 100644 --- a/tests/study/test_study_api.py +++ b/tests/study/test_study_api.py @@ -8,7 +8,7 @@ from crc import session, app from crc.models.protocol_builder import ProtocolBuilderStatus, \ ProtocolBuilderStudySchema from crc.models.approval import ApprovalStatus -from crc.models.stats import TaskEventModel +from crc.models.task_event import TaskEventModel from crc.models.study import StudyModel, StudySchema from crc.models.workflow import WorkflowSpecModel, WorkflowModel from crc.services.file_service import FileService diff --git a/tests/test_user_roles.py b/tests/test_user_roles.py index 794008c8..edd086d3 100644 --- a/tests/test_user_roles.py +++ b/tests/test_user_roles.py @@ -1,10 +1,10 @@ import json -from crc.models.api_models import WorkflowApiSchema -from crc.models.stats import TaskEventModel from tests.base_test import BaseTest +from crc.models.workflow import WorkflowStatus from crc import db from crc.api.common import ApiError +from crc.models.task_event import TaskEventModel, TaskEventSchema from crc.services.workflow_service import WorkflowService @@ -23,7 +23,7 @@ class TestTasksApi(BaseTest): data = workflow_api.next_task.data data["approved"] = True result = self.complete_form(workflow, workflow_api.next_task, data, user_uid="lje5u", - error_code="invalid_role") + error_code="permission_denied") def test_validation_of_workflow_fails_if_workflow_does_not_define_user_for_lane(self): error = None @@ -49,16 +49,14 @@ class TestTasksApi(BaseTest): # But she can not complete the supervisor role. workflow_api = self.get_workflow_api(workflow, user_uid=submitter.uid) data = workflow_api.next_task.data - data["approved"] = True + data["approval"] = True result = self.complete_form(workflow, workflow_api.next_task, data, user_uid=submitter.uid, - error_code="role_permission") + error_code="permission_denied") # Only her supervisor can do that. self.complete_form(workflow, workflow_api.next_task, data, user_uid=supervisor.uid) def test_nav_includes_lanes(self): - self.load_example_data() - submitter = self.create_user(uid='lje5u') workflow = self.create_workflow('roles', as_user=submitter.uid) workflow_api = self.get_workflow_api(workflow, user_uid=submitter.uid) @@ -67,7 +65,7 @@ class TestTasksApi(BaseTest): self.assertEquals(5, len(nav)) self.assertEquals("supervisor", nav[1]['lane']) - def test_get_outstanding_tasks_awaiting_user_input(self): + def test_get_outstanding_tasks_awaiting_current_user(self): submitter = self.create_user(uid='lje5u') supervisor = self.create_user(uid='lb3dp') workflow = self.create_workflow('roles', as_user=submitter.uid) @@ -76,26 +74,129 @@ class TestTasksApi(BaseTest): # User lje5u can complete the first task, and set her supervisor data = workflow_api.next_task.data data['supervisor'] = supervisor.uid - self.complete_form(workflow, workflow_api.next_task, data, user_uid=submitter.uid) + workflow_api = self.complete_form(workflow, workflow_api.next_task, data, user_uid=submitter.uid) - # At this point there should be a task_log with an action of WAITING on it for + # At this point there should be a task_log with an action of Lane Change on it for # the supervisor. - task_log = db.session.query(TaskEventModel).filter(TaskEventModel.user_uid == supervisor.uid) - self.assertEquals(1, len(task_log)) + task_logs = db.session.query(TaskEventModel). \ + filter(TaskEventModel.user_uid == supervisor.uid). \ + filter(TaskEventModel.action == WorkflowService.TASK_ACTION_ASSIGNMENT).all() + self.assertEquals(1, len(task_logs)) - # A call to the /workflow endpoint as the supervisor user should return this workflow - rv = self.app.get('/v1.0/workflow', - headers=self.logged_in_headers(supervisor.uid), + # A call to the /task endpoint as the supervisor user should return a list of + # tasks that need their attention. + rv = self.app.get('/v1.0/task_events?action=ASSIGNMENT', + headers=self.logged_in_headers(supervisor), content_type="application/json") self.assert_success(rv) json_data = json.loads(rv.get_data(as_text=True)) - workflow_api = WorkflowApiSchema().load(json_data) - - # The workflow navigation should be locked for all tasks that do not belong to the user. - + tasks = TaskEventSchema(many=True).load(json_data) + self.assertEquals(1, len(tasks)) + self.assertEquals(workflow.id, tasks[0]['workflow']['id']) + self.assertEquals(workflow.study.id, tasks[0]['study']['id']) + # Assure we can say something sensible like: + # You have a task called "Approval" to be completed in the "Supervisor Approval" workflow + # for the study 'Why dogs are stinky' managed by user "Jane Smith (js42x)", + # please check here to complete the task. + # Display name isn't set in the tests, so just checking name, but the full workflow details are included. + # I didn't delve into the full user details to keep things decoupled from ldap, so you just get the + # uid back, but could query to get the full entry. + self.assertEquals("roles", tasks[0]['workflow']['name']) + self.assertEquals("Beer consumption in the bipedal software engineer", tasks[0]['study']['title']) + self.assertEquals("lje5u", tasks[0]['study']['user_uid']) # Completing the next step of the workflow will close the task. + data['approval'] = True self.complete_form(workflow, workflow_api.next_task, data, user_uid=supervisor.uid) + def test_navigation_and_current_task_updates_through_workflow(self): + submitter = self.create_user(uid='lje5u') + supervisor = self.create_user(uid='lb3dp') + workflow = self.create_workflow('roles', as_user=submitter.uid) + + # Navigation as Submitter with ready task. + workflow_api = self.get_workflow_api(workflow, user_uid=submitter.uid) + nav = workflow_api.navigation + self.assertEquals(5, len(nav)) + self.assertEquals('READY', nav[0]['state']) # First item is ready, no progress yet. + self.assertEquals('LOCKED', nav[1]['state']) # Second item is locked, it is the review and doesn't belong to this user. + self.assertEquals('LOCKED', nav[2]['state']) # third item is a gateway, and belongs to no one, and is locked. + self.assertEquals('NOOP', nav[3]['state']) # Approved Path, has no operation + self.assertEquals('NOOP', nav[4]['state']) # Rejected Path, has no operation. + self.assertEquals('READY', workflow_api.next_task.state) + + # Navigation as Submitter after handoff to supervisor + data = workflow_api.next_task.data + data['supervisor'] = supervisor.uid + workflow_api = self.complete_form(workflow, workflow_api.next_task, data, user_uid=submitter.uid) + nav = workflow_api.navigation + self.assertEquals('COMPLETED', nav[0]['state']) # First item is ready, no progress yet. + self.assertEquals('LOCKED', nav[1]['state']) # Second item is locked, it is the review and doesn't belong to this user. + self.assertEquals('LOCKED', nav[2]['state']) # third item is a gateway, and belongs to no one, and is locked. + self.assertEquals('LOCKED', workflow_api.next_task.state) + # In the event the next task is locked, we should say something sensible here. + # It is possible to look at the role of the task, and say The next task "TASK TITLE" will + # be handled by 'dhf8r', who is full-filling the role of supervisor. the Task Data + # is guaranteed to have a supervisor attribute in it that will contain the users uid, which + # could be looked up through an ldap service. + self.assertEquals('supervisor', workflow_api.next_task.lane) + + + # Navigation as Supervisor + workflow_api = self.get_workflow_api(workflow, user_uid=supervisor.uid) + nav = workflow_api.navigation + self.assertEquals(5, len(nav)) + self.assertEquals('LOCKED', nav[0]['state']) # First item belongs to the submitter, and is locked. + self.assertEquals('READY', nav[1]['state']) # Second item is locked, it is the review and doesn't belong to this user. + self.assertEquals('LOCKED', nav[2]['state']) # third item is a gateway, and belongs to no one, and is locked. + self.assertEquals('READY', workflow_api.next_task.state) + + data = workflow_api.next_task.data + data["approval"] = False + workflow_api = self.complete_form(workflow, workflow_api.next_task, data, user_uid=supervisor.uid) + + # Navigation as Supervisor, after completing task. + nav = workflow_api.navigation + self.assertEquals(5, len(nav)) + self.assertEquals('LOCKED', nav[0]['state']) # First item belongs to the submitter, and is locked. + self.assertEquals('COMPLETED', nav[1]['state']) # Second item is locked, it is the review and doesn't belong to this user. + self.assertEquals('COMPLETED', nav[2]['state']) # third item is a gateway, and is now complete. + self.assertEquals('LOCKED', workflow_api.next_task.state) + + # Navigation as Submitter, coming back in to a rejected workflow to view the rejection message. + workflow_api = self.get_workflow_api(workflow, user_uid=submitter.uid) + nav = workflow_api.navigation + self.assertEquals(5, len(nav)) + self.assertEquals('COMPLETED', nav[0]['state']) # First item belongs to the submitter, and is locked. + self.assertEquals('LOCKED', nav[1]['state']) # Second item is locked, it is the review and doesn't belong to this user. + self.assertEquals('LOCKED', nav[2]['state']) # third item is a gateway belonging to the supervisor, and is locked. + self.assertEquals('READY', workflow_api.next_task.state) + + # Navigation as Submitter, re-completing the original request a second time, and sending it for review. + workflow_api = self.complete_form(workflow, workflow_api.next_task, data, user_uid=submitter.uid) + nav = workflow_api.navigation + self.assertEquals(5, len(nav)) + self.assertEquals('COMPLETED', nav[0]['state']) # We still have some issues here, the navigation will be off when looping back. + self.assertEquals('LOCKED', nav[1]['state']) # Second item is locked, it is the review and doesn't belong to this user. + self.assertEquals('LOCKED', nav[2]['state']) # third item is a gateway belonging to the supervisor, and is locked. + self.assertEquals('READY', workflow_api.next_task.state) + + data["favorite_color"] = "blue" + data["quest"] = "to seek the holy grail" + workflow_api = self.complete_form(workflow, workflow_api.next_task, data, user_uid=submitter.uid) + self.assertEquals('LOCKED', workflow_api.next_task.state) + + workflow_api = self.get_workflow_api(workflow, user_uid=supervisor.uid) + self.assertEquals('READY', workflow_api.next_task.state) + + data = workflow_api.next_task.data + data["approval"] = True + workflow_api = self.complete_form(workflow, workflow_api.next_task, data, user_uid=supervisor.uid) + self.assertEquals('LOCKED', workflow_api.next_task.state) + + workflow_api = self.get_workflow_api(workflow, user_uid=submitter.uid) + self.assertEquals('COMPLETED', workflow_api.next_task.state) + self.assertEquals('NoneTask', workflow_api.next_task.type) # Are are at the end. + self.assertEquals(WorkflowStatus.complete, workflow_api.status) \ No newline at end of file diff --git a/tests/workflow/test_workflow_service.py b/tests/workflow/test_workflow_service.py index 6b1b5c58..a4c41c7c 100644 --- a/tests/workflow/test_workflow_service.py +++ b/tests/workflow/test_workflow_service.py @@ -7,7 +7,7 @@ from crc.services.workflow_service import WorkflowService from SpiffWorkflow import Task as SpiffTask, WorkflowException from example_data import ExampleDataLoader from crc import db -from crc.models.stats import TaskEventModel +from crc.models.task_event import TaskEventModel from crc.models.api_models import Task @@ -101,9 +101,8 @@ class TestWorkflowService(BaseTest): WorkflowService.populate_form_with_random_data(task, task_api, False) task.complete() # create the task events - WorkflowService.log_task_action('dhf8r', workflow, task, - WorkflowService.TASK_ACTION_COMPLETE, - version=processor.get_version_string()) + WorkflowService.log_task_action('dhf8r', processor, task, + WorkflowService.TASK_ACTION_COMPLETE) processor.save() db.session.commit() From fa7608053a2df2716bb4dd3d956b7d1fbd6cbe55 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 14 Jul 2020 22:23:54 -0400 Subject: [PATCH 082/101] fixing a failed test. --- crc/services/workflow_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 9affb720..09ea68c1 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -222,7 +222,7 @@ class WorkflowService(object): nav_item['title'] = nav_item['task'].title # Prefer the task title. user_uids = WorkflowService.get_users_assigned_to_task(processor, spiff_task) - if g.user.uid not in user_uids: + if 'user' not in g or not g.user or g.user.uid not in user_uids: nav_item['state'] = WorkflowService.TASK_STATE_LOCKED else: @@ -255,7 +255,7 @@ class WorkflowService(object): workflow_api.next_task = WorkflowService.spiff_task_to_api_task(next_task, add_docs_and_forms=True) # Update the state of the task to locked if the current user does not own the task. user_uids = WorkflowService.get_users_assigned_to_task(processor, next_task) - if g.user.uid not in user_uids: + if 'user' not in g or not g.user or g.user.uid not in user_uids: workflow_api.next_task.state = WorkflowService.TASK_STATE_LOCKED return workflow_api From c7662315aaa594bea79f355c3c22e2173989f7d2 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Wed, 15 Jul 2020 11:16:35 -0400 Subject: [PATCH 083/101] Assure that any errors that occur during the do_engine_steps is correctly captured and returned to the end user or configurator with enough information for them to act on. --- Pipfile.lock | 136 +++++------------- crc/services/workflow_processor.py | 10 +- tests/data/decision_table_invalid/bad_dmn.dmn | 50 +++++++ .../decision_table_invalid.bpmn | 56 ++++++++ tests/workflow/test_workflow_processor.py | 5 +- tests/workflow/test_workflow_service.py | 5 + 6 files changed, 160 insertions(+), 102 deletions(-) create mode 100644 tests/data/decision_table_invalid/bad_dmn.dmn create mode 100644 tests/data/decision_table_invalid/decision_table_invalid.bpmn diff --git a/Pipfile.lock b/Pipfile.lock index cd2c1370..a7f9a933 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -35,7 +35,6 @@ "sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b", "sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.6.0" }, "aniso8601": { @@ -50,7 +49,6 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "babel": { @@ -58,7 +56,6 @@ "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.0" }, "bcrypt": { @@ -82,7 +79,6 @@ "sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7", "sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.1.7" }, "beautifulsoup4": { @@ -111,7 +107,6 @@ "sha256:ef17d7dffde7fc73ecab3a3b6389d93d3213bac53fa7f28e68e33647ad50b916", "sha256:fd77e4248bb1b7af5f7922dd8e81156f540306e3a5c4b1c24167c1f5f06025da" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.4.6" }, "certifi": { @@ -166,7 +161,6 @@ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "clickclick": { @@ -188,7 +182,6 @@ "sha256:2ca44140ee259b5e3d8aaf47c79c36a7ab0d5e94d70bd4105c03ede7a20ea5a1", "sha256:cffc044844040c7ce04e9acd1838b5f2e5fa3170182f6fda4d2ea8b0099dbadd" ], - "markers": "python_version >= '3.6'", "version": "==5.0.0" }, "connexion": { @@ -247,7 +240,6 @@ "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==0.16" }, "docxtpl": { @@ -327,17 +319,15 @@ }, "flask-sqlalchemy": { "hashes": [ - "sha256:0b656fbf87c5f24109d859bafa791d29751fabbda2302b606881ae5485b557a5", - "sha256:fcfe6df52cd2ed8a63008ca36b86a51fa7a4b70cef1c39e5625f722fca32308e" + "sha256:05b31d2034dd3f2a685cbbae4cfc4ed906b2a733cff7964ada450fd5e462b84e", + "sha256:bfc7150eaf809b1c283879302f04c42791136060c6eeb12c0c6674fb1291fae5" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.4.3" + "version": "==2.4.4" }, "future": { "hashes": [ "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.18.2" }, "gunicorn": { @@ -360,7 +350,6 @@ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, "imagesize": { @@ -368,7 +357,6 @@ "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.2.0" }, "importlib-metadata": { @@ -384,7 +372,6 @@ "sha256:88b101b2668a1d81d6d72d4c2018e53bc6c7fc544c987849da1c7f77545c3bc9", "sha256:f576e85132d34f5bf7df5183c2c6f94cfb32e528f53065345cf71329ba0b8924" ], - "markers": "python_version >= '3.5'", "version": "==0.5.0" }, "itsdangerous": { @@ -392,7 +379,6 @@ "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.0" }, "jdcal": { @@ -407,7 +393,6 @@ "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.2" }, "jsonschema": { @@ -422,16 +407,11 @@ "sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a", "sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.6.11" }, "ldap3": { "hashes": [ "sha256:17f04298b70bf7ecaa5db8a7d8622b5a962ef7fc2b245b2eea705ac1c24338c0", - "sha256:298769ab0232b3a3efa1e84881096c24526fe37911c83a11285f222fe4975efd", - "sha256:4fd2db72d0412cc16ee86be01332095e86e361329c3579b314231eb2e56c7871", - "sha256:52ab557b3c4908db4a90bea16731aa714b1b54e039b54fd4c4b83994c6c48c0c", - "sha256:53aaae5bf14f3827c69600ddf4d61b88f49c055bb93060e9702c5bafd206c744", "sha256:81df4ac8b6df10fb1f05b17c18d0cb8c4c344d5a03083c382824960ed959cf5b" ], "index": "pypi", @@ -439,43 +419,42 @@ }, "lxml": { "hashes": [ - "sha256:06748c7192eab0f48e3d35a7adae609a329c6257495d5e53878003660dc0fec6", - "sha256:0790ddca3f825dd914978c94c2545dbea5f56f008b050e835403714babe62a5f", - "sha256:1aa7a6197c1cdd65d974f3e4953764eee3d9c7b67e3966616b41fab7f8f516b7", - "sha256:22c6d34fdb0e65d5f782a4d1a1edb52e0a8365858dafb1c08cb1d16546cf0786", - "sha256:2754d4406438c83144f9ffd3628bbe2dcc6d62b20dbc5c1ec4bc4385e5d44b42", - "sha256:27ee0faf8077c7c1a589573b1450743011117f1aa1a91d5ae776bbc5ca6070f2", - "sha256:2b02c106709466a93ed424454ce4c970791c486d5fcdf52b0d822a7e29789626", - "sha256:2d1ddce96cf15f1254a68dba6935e6e0f1fe39247de631c115e84dd404a6f031", - "sha256:4f282737d187ae723b2633856085c31ae5d4d432968b7f3f478a48a54835f5c4", - "sha256:51bb4edeb36d24ec97eb3e6a6007be128b720114f9a875d6b370317d62ac80b9", - "sha256:7eee37c1b9815e6505847aa5e68f192e8a1b730c5c7ead39ff317fde9ce29448", - "sha256:7fd88cb91a470b383aafad554c3fe1ccf6dfb2456ff0e84b95335d582a799804", - "sha256:9144ce36ca0824b29ebc2e02ca186e54040ebb224292072250467190fb613b96", - "sha256:925baf6ff1ef2c45169f548cc85204433e061360bfa7d01e1be7ae38bef73194", - "sha256:a636346c6c0e1092ffc202d97ec1843a75937d8c98aaf6771348ad6422e44bb0", - "sha256:a87dbee7ad9dce3aaefada2081843caf08a44a8f52e03e0a4cc5819f8398f2f4", - "sha256:a9e3b8011388e7e373565daa5e92f6c9cb844790dc18e43073212bb3e76f7007", - "sha256:afb53edf1046599991fb4a7d03e601ab5f5422a5435c47ee6ba91ec3b61416a6", - "sha256:b26719890c79a1dae7d53acac5f089d66fd8cc68a81f4e4bd355e45470dc25e1", - "sha256:b7462cdab6fffcda853338e1741ce99706cdf880d921b5a769202ea7b94e8528", - "sha256:b77975465234ff49fdad871c08aa747aae06f5e5be62866595057c43f8d2f62c", - "sha256:c47a8a5d00060122ca5908909478abce7bbf62d812e3fc35c6c802df8fb01fe7", - "sha256:c79e5debbe092e3c93ca4aee44c9a7631bdd407b2871cb541b979fd350bbbc29", - "sha256:d8d40e0121ca1606aa9e78c28a3a7d88a05c06b3ca61630242cded87d8ce55fa", - "sha256:ee2be8b8f72a2772e72ab926a3bccebf47bb727bda41ae070dc91d1fb759b726", - "sha256:f95d28193c3863132b1f55c1056036bf580b5a488d908f7d22a04ace8935a3a9", - "sha256:fadd2a63a2bfd7fb604508e553d1cf68eca250b2fbdbd81213b5f6f2fbf23529" + "sha256:05a444b207901a68a6526948c7cc8f9fe6d6f24c70781488e32fd74ff5996e3f", + "sha256:08fc93257dcfe9542c0a6883a25ba4971d78297f63d7a5a26ffa34861ca78730", + "sha256:107781b213cf7201ec3806555657ccda67b1fccc4261fb889ef7fc56976db81f", + "sha256:121b665b04083a1e85ff1f5243d4a93aa1aaba281bc12ea334d5a187278ceaf1", + "sha256:2b30aa2bcff8e958cd85d907d5109820b01ac511eae5b460803430a7404e34d7", + "sha256:4b4a111bcf4b9c948e020fd207f915c24a6de3f1adc7682a2d92660eb4e84f1a", + "sha256:5591c4164755778e29e69b86e425880f852464a21c7bb53c7ea453bbe2633bbe", + "sha256:59daa84aef650b11bccd18f99f64bfe44b9f14a08a28259959d33676554065a1", + "sha256:5a9c8d11aa2c8f8b6043d845927a51eb9102eb558e3f936df494e96393f5fd3e", + "sha256:5dd20538a60c4cc9a077d3b715bb42307239fcd25ef1ca7286775f95e9e9a46d", + "sha256:74f48ec98430e06c1fa8949b49ebdd8d27ceb9df8d3d1c92e1fdc2773f003f20", + "sha256:786aad2aa20de3dbff21aab86b2fb6a7be68064cbbc0219bde414d3a30aa47ae", + "sha256:7ad7906e098ccd30d8f7068030a0b16668ab8aa5cda6fcd5146d8d20cbaa71b5", + "sha256:80a38b188d20c0524fe8959c8ce770a8fdf0e617c6912d23fc97c68301bb9aba", + "sha256:92282c83547a9add85ad658143c76a64a8d339028926d7dc1998ca029c88ea6a", + "sha256:94150231f1e90c9595ccc80d7d2006c61f90a5995db82bccbca7944fd457f0f6", + "sha256:9dc9006dcc47e00a8a6a029eb035c8f696ad38e40a27d073a003d7d1443f5d88", + "sha256:a76979f728dd845655026ab991df25d26379a1a8fc1e9e68e25c7eda43004bed", + "sha256:aa8eba3db3d8761db161003e2d0586608092e217151d7458206e243be5a43843", + "sha256:bea760a63ce9bba566c23f726d72b3c0250e2fa2569909e2d83cda1534c79443", + "sha256:c3f511a3c58676147c277eff0224c061dd5a6a8e1373572ac817ac6324f1b1e0", + "sha256:cc411ad324a4486b142c41d9b2b6a722c534096963688d879ea6fa8a35028258", + "sha256:cdc13a1682b2a6241080745b1953719e7fe0850b40a5c71ca574f090a1391df6", + "sha256:e1cacf4796b20865789083252186ce9dc6cc59eca0c2e79cca332bdff24ac481", + "sha256:e70d4e467e243455492f5de463b72151cc400710ac03a0678206a5f27e79ddef", + "sha256:ecc930ae559ea8a43377e8b60ca6f8d61ac532fc57efb915d899de4a67928efd", + "sha256:f161af26f596131b63b236372e4ce40f3167c1b5b5d459b29d2514bd8c9dc9ee" ], "index": "pypi", - "version": "==4.5.1" + "version": "==4.5.2" }, "mako": { "hashes": [ "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27", "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.3" }, "markdown": { @@ -522,16 +501,15 @@ "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "marshmallow": { "hashes": [ - "sha256:35ee2fb188f0bd9fc1cf9ac35e45fd394bd1c153cee430745a465ea435514bd5", - "sha256:9aa20f9b71c992b4782dad07c51d92884fd0f7c5cb9d3c737bea17ec1bad765f" + "sha256:0f3a630f6a2fd124929f1bdcb5df65bd14cc8f49f52a18d0bdcfa0c42414e4a7", + "sha256:ba949379cb6ef73655f72075e82b31cf57012a5557ede642fc8614ab0354f869" ], "index": "pypi", - "version": "==3.6.1" + "version": "==3.7.0" }, "marshmallow-enum": { "hashes": [ @@ -578,7 +556,6 @@ "sha256:df1889701e2dfd8ba4dc9b1a010f0a60950077fb5242bb92c8b5c7f1a6f2668a", "sha256:fa1fe75b4a9e18b66ae7f0b122543c42debcf800aaafa0212aaff3ad273c2596" ], - "markers": "python_version >= '3.6'", "version": "==1.19.0" }, "openapi-spec-validator": { @@ -602,7 +579,6 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pandas": { @@ -665,19 +641,8 @@ }, "pyasn1": { "hashes": [ - "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", - "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", - "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", - "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", - "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", - "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", - "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", - "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", - "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", - "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba" ], "version": "==0.4.8" }, @@ -686,7 +651,6 @@ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.20" }, "pygments": { @@ -694,7 +658,6 @@ "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" ], - "markers": "python_version >= '3.5'", "version": "==2.6.1" }, "pyjwt": { @@ -710,7 +673,6 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pyrsistent": { @@ -737,9 +699,7 @@ "hashes": [ "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", - "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8", - "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77", - "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522" + "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8" ], "version": "==1.0.4" }, @@ -853,7 +813,6 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "snowballstemmer": { @@ -868,7 +827,6 @@ "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" ], - "markers": "python_version >= '3.5'", "version": "==2.0.1" }, "sphinx": { @@ -884,7 +842,6 @@ "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" ], - "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-devhelp": { @@ -892,7 +849,6 @@ "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" ], - "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-htmlhelp": { @@ -900,7 +856,6 @@ "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" ], - "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-jsmath": { @@ -908,7 +863,6 @@ "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" ], - "markers": "python_version >= '3.5'", "version": "==1.0.1" }, "sphinxcontrib-qthelp": { @@ -916,7 +870,6 @@ "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" ], - "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-serializinghtml": { @@ -924,13 +877,12 @@ "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc", "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a" ], - "markers": "python_version >= '3.5'", "version": "==1.1.4" }, "spiffworkflow": { "editable": true, "git": "https://github.com/sartography/SpiffWorkflow.git", - "ref": "e47dbce4147f2475f50ef705eab32a1426540613" + "ref": "c72ced41e323aa69fcb6f7708e1869e98add716d" }, "sqlalchemy": { "hashes": [ @@ -963,7 +915,6 @@ "sha256:f57be5673e12763dd400fea568608700a63ce1c6bd5bdbc3cc3a2c5fdb045274", "sha256:fc728ece3d5c772c196fd338a99798e7efac7a04f9cb6416299a3638ee9a94cd" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.18" }, "swagger-ui-bundle": { @@ -980,7 +931,6 @@ "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==1.25.9" }, "vine": { @@ -988,7 +938,6 @@ "sha256:133ee6d7a9016f177ddeaf191c1f58421a1dcc6ee9a42c58b34bed40e1d2cd87", "sha256:ea4947cc56d1fd6f2095c8d543ee25dad966f78692528e68b4fada11ba3f98af" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.0" }, "waitress": { @@ -996,7 +945,6 @@ "sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261", "sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==1.4.4" }, "webob": { @@ -1004,7 +952,6 @@ "sha256:a3c89a8e9ba0aeb17382836cdb73c516d0ecf6630ec40ec28288f3ed459ce87b", "sha256:aa3a917ed752ba3e0b242234b2a373f9c4e2a75d35291dcbe977649bd21fd108" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.8.6" }, "webtest": { @@ -1051,7 +998,6 @@ "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" ], - "markers": "python_version >= '3.6'", "version": "==3.1.0" } }, @@ -1061,7 +1007,6 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "coverage": { @@ -1117,7 +1062,6 @@ "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" ], - "markers": "python_version >= '3.5'", "version": "==8.4.0" }, "packaging": { @@ -1125,7 +1069,6 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pbr": { @@ -1141,7 +1084,6 @@ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.13.1" }, "py": { @@ -1149,7 +1091,6 @@ "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.9.0" }, "pyparsing": { @@ -1157,7 +1098,6 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pytest": { @@ -1173,7 +1113,6 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "wcwidth": { @@ -1188,7 +1127,6 @@ "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" ], - "markers": "python_version >= '3.6'", "version": "==3.1.0" } } diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index 50736e5f..60040a95 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -167,7 +167,10 @@ class WorkflowProcessor(object): bpmn_workflow = BpmnWorkflow(spec, script_engine=self._script_engine) bpmn_workflow.data[WorkflowProcessor.STUDY_ID_KEY] = workflow_model.study_id bpmn_workflow.data[WorkflowProcessor.VALIDATION_PROCESS_KEY] = validate_only - bpmn_workflow.do_engine_steps() + try: + bpmn_workflow.do_engine_steps() + except WorkflowException as we: + raise ApiError.from_task_spec("error_loading_workflow", str(we), we.sender) return bpmn_workflow def save(self): @@ -308,7 +311,10 @@ class WorkflowProcessor(object): new_spec = WorkflowProcessor.get_spec(self.spec_data_files, self.workflow_spec_id) new_bpmn_workflow = BpmnWorkflow(new_spec, script_engine=self._script_engine) new_bpmn_workflow.data = self.bpmn_workflow.data - new_bpmn_workflow.do_engine_steps() + try: + new_bpmn_workflow.do_engine_steps() + except WorkflowException as we: + raise ApiError.from_task_spec("hard_reset_engine_steps_error", str(we), we.sender) self.bpmn_workflow = new_bpmn_workflow def get_status(self): diff --git a/tests/data/decision_table_invalid/bad_dmn.dmn b/tests/data/decision_table_invalid/bad_dmn.dmn new file mode 100644 index 00000000..fc846175 --- /dev/null +++ b/tests/data/decision_table_invalid/bad_dmn.dmn @@ -0,0 +1,50 @@ + + + + + + + + + + 1 + + + + + + + 0 + + + 0 + + + + 'one' can't be evaluated, it must be quoted + + 1 + + + one + + + + + 2 + + + 2 + + + + + > 2 + + + 3 + + + + + diff --git a/tests/data/decision_table_invalid/decision_table_invalid.bpmn b/tests/data/decision_table_invalid/decision_table_invalid.bpmn new file mode 100644 index 00000000..bbf0473a --- /dev/null +++ b/tests/data/decision_table_invalid/decision_table_invalid.bpmn @@ -0,0 +1,56 @@ + + + + + SequenceFlow_1ma1wxb + + + + SequenceFlow_1ma1wxb + SequenceFlow_0grui6f + + + # Great Work! + +Based on the information you provided (Ginger left {{num_presents}}, we recommend the following statement be provided to Ginger: + +## {{message}} + +We hope you both have an excellent day! + SequenceFlow_0grui6f + + + + This DMN isn't provided enough information to execute + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/workflow/test_workflow_processor.py b/tests/workflow/test_workflow_processor.py index 44d90cf3..d30f9cd1 100644 --- a/tests/workflow/test_workflow_processor.py +++ b/tests/workflow/test_workflow_processor.py @@ -368,4 +368,7 @@ class TestWorkflowProcessor(BaseTest): task.task_spec.form.fields.append(field) with self.assertRaises(ApiError): - self._populate_form_with_random_data(task) \ No newline at end of file + self._populate_form_with_random_data(task) + + + diff --git a/tests/workflow/test_workflow_service.py b/tests/workflow/test_workflow_service.py index 6b1b5c58..753d29d7 100644 --- a/tests/workflow/test_workflow_service.py +++ b/tests/workflow/test_workflow_service.py @@ -9,6 +9,7 @@ from example_data import ExampleDataLoader from crc import db from crc.models.stats import TaskEventModel from crc.models.api_models import Task +from crc.api.common import ApiError class TestWorkflowService(BaseTest): @@ -132,3 +133,7 @@ class TestWorkflowService(BaseTest): self.assertEqual({}, task_logs[0].form_data) + def test_dmn_evaluation_errors_in_oncomplete_raise_api_errors_during_validation(self): + workflow_spec_model = self.load_test_spec("decision_table_invalid") + with self.assertRaises(ApiError): + WorkflowService.test_spec(workflow_spec_model.id) \ No newline at end of file From 77948c7828bb232b3069bd88ab0e9b90e8fb9abc Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Wed, 15 Jul 2020 14:28:55 -0400 Subject: [PATCH 084/101] Updates Personnel workflow --- .../irb_api_personnel/irb_api_personnel.bpmn | 49 ++++++++++++------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/crc/static/bpmn/irb_api_personnel/irb_api_personnel.bpmn b/crc/static/bpmn/irb_api_personnel/irb_api_personnel.bpmn index ba522b78..99edb961 100644 --- a/crc/static/bpmn/irb_api_personnel/irb_api_personnel.bpmn +++ b/crc/static/bpmn/irb_api_personnel/irb_api_personnel.bpmn @@ -10,27 +10,38 @@ StudyInfo investigators
+ ## The following information was gathered: +{% for type, investigator in StudyInfo.investigators.items() %} +### {{investigator.label}}: {{investigator.display_name}} + * Edit Acess? {{investigator.edit_access}} + * Send Emails? {{investigator.emails}} +{% if investigator.label == "Primary Investigator" %} + * Experience: {{investigator.experience}} +{% endif %} +{% endfor %} Flow_1mplloa - + ### Please provide supplemental information for: -#### Investigator : {{investigator.display_name}} -##### Role: {{investigator.type_full}} +#### {{investigator.display_name}} ##### Title: {{investigator.title}} ##### Department: {{investigator.department}} ##### Affiliation: {{investigator.affiliation}} - - - + + + - + + + + Flow_1dcsioh Flow_1mplloa @@ -43,28 +54,28 @@ - - + + - - + + - - + + - + - - - - + - + + + + From 3fe9662f266db1faa72eae7e9e4b86ca499d9f91 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Wed, 15 Jul 2020 16:48:32 -0400 Subject: [PATCH 085/101] Updates DSP workflow --- .../data_security_plan/NEW_DSP_template.docx | Bin 53777 -> 71844 bytes .../data_security_plan.bpmn | 5 ++++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crc/static/bpmn/data_security_plan/NEW_DSP_template.docx b/crc/static/bpmn/data_security_plan/NEW_DSP_template.docx index b4925d1ffcb05e5c845230a5c0082ed6a513dc45..b698c8b23c7618aab7ec99c47e85c73f86704251 100644 GIT binary patch delta 40563 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%0D2_h}f9(U;4%UHsoO{&_Zn2OpxD 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¹ZUDR~ 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 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 9824a50c..0bf95e18 100644 --- a/crc/static/bpmn/data_security_plan/data_security_plan.bpmn +++ b/crc/static/bpmn/data_security_plan/data_security_plan.bpmn @@ -473,7 +473,10 @@ Process: The Study Team will answer the questions in this section to create the How to The Data Security Plan is auto-generated based on your answers on this Step. You can save your information here and check the outcomes on the Data Security Plan Upload Step at any time. -Submit the step only when you are ready. After you "Submit" the step, the information will not be available for editing. +Submit the step only when you are ready. After you "Submit" the step, the information will not be available for editing. + + +# test From 040a95281e7bceb6c3f5e6ed5032bc5de31a7d3c Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Wed, 15 Jul 2020 17:09:34 -0400 Subject: [PATCH 086/101] Updates package hashes --- Pipfile.lock | 72 +++++++++++++++++++++++-- tests/workflow/test_workflow_service.py | 1 - 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index a7f9a933..6d0f9167 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -35,6 +35,7 @@ "sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b", "sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.6.0" }, "aniso8601": { @@ -49,6 +50,7 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "babel": { @@ -56,6 +58,7 @@ "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.0" }, "bcrypt": { @@ -79,6 +82,7 @@ "sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7", "sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.1.7" }, "beautifulsoup4": { @@ -107,6 +111,7 @@ "sha256:ef17d7dffde7fc73ecab3a3b6389d93d3213bac53fa7f28e68e33647ad50b916", "sha256:fd77e4248bb1b7af5f7922dd8e81156f540306e3a5c4b1c24167c1f5f06025da" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.4.6" }, "certifi": { @@ -161,6 +166,7 @@ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "clickclick": { @@ -182,6 +188,7 @@ "sha256:2ca44140ee259b5e3d8aaf47c79c36a7ab0d5e94d70bd4105c03ede7a20ea5a1", "sha256:cffc044844040c7ce04e9acd1838b5f2e5fa3170182f6fda4d2ea8b0099dbadd" ], + "markers": "python_version >= '3.6'", "version": "==5.0.0" }, "connexion": { @@ -240,6 +247,7 @@ "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==0.16" }, "docxtpl": { @@ -322,12 +330,14 @@ "sha256:05b31d2034dd3f2a685cbbae4cfc4ed906b2a733cff7964ada450fd5e462b84e", "sha256:bfc7150eaf809b1c283879302f04c42791136060c6eeb12c0c6674fb1291fae5" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.4" }, "future": { "hashes": [ "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.18.2" }, "gunicorn": { @@ -350,6 +360,7 @@ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, "imagesize": { @@ -357,6 +368,7 @@ "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.2.0" }, "importlib-metadata": { @@ -372,6 +384,7 @@ "sha256:88b101b2668a1d81d6d72d4c2018e53bc6c7fc544c987849da1c7f77545c3bc9", "sha256:f576e85132d34f5bf7df5183c2c6f94cfb32e528f53065345cf71329ba0b8924" ], + "markers": "python_version >= '3.5'", "version": "==0.5.0" }, "itsdangerous": { @@ -379,6 +392,7 @@ "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.0" }, "jdcal": { @@ -393,6 +407,7 @@ "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.2" }, "jsonschema": { @@ -407,12 +422,17 @@ "sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a", "sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.6.11" }, "ldap3": { "hashes": [ - "sha256:17f04298b70bf7ecaa5db8a7d8622b5a962ef7fc2b245b2eea705ac1c24338c0", - "sha256:81df4ac8b6df10fb1f05b17c18d0cb8c4c344d5a03083c382824960ed959cf5b" + "sha256:298769ab0232b3a3efa1e84881096c24526fe37911c83a11285f222fe4975efd", + "sha256:81df4ac8b6df10fb1f05b17c18d0cb8c4c344d5a03083c382824960ed959cf5b", + "sha256:53aaae5bf14f3827c69600ddf4d61b88f49c055bb93060e9702c5bafd206c744", + "sha256:4fd2db72d0412cc16ee86be01332095e86e361329c3579b314231eb2e56c7871", + "sha256:52ab557b3c4908db4a90bea16731aa714b1b54e039b54fd4c4b83994c6c48c0c", + "sha256:17f04298b70bf7ecaa5db8a7d8622b5a962ef7fc2b245b2eea705ac1c24338c0" ], "index": "pypi", "version": "==2.7" @@ -455,6 +475,7 @@ "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27", "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.3" }, "markdown": { @@ -501,6 +522,7 @@ "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "marshmallow": { @@ -556,6 +578,7 @@ "sha256:df1889701e2dfd8ba4dc9b1a010f0a60950077fb5242bb92c8b5c7f1a6f2668a", "sha256:fa1fe75b4a9e18b66ae7f0b122543c42debcf800aaafa0212aaff3ad273c2596" ], + "markers": "python_version >= '3.6'", "version": "==1.19.0" }, "openapi-spec-validator": { @@ -579,6 +602,7 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pandas": { @@ -641,8 +665,19 @@ }, "pyasn1": { "hashes": [ + "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", + "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", + "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", + "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", + "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", + "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", + "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", + "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba" + "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3", + "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", + "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12" ], "version": "==0.4.8" }, @@ -651,6 +686,7 @@ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.20" }, "pygments": { @@ -658,6 +694,7 @@ "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" ], + "markers": "python_version >= '3.5'", "version": "==2.6.1" }, "pyjwt": { @@ -673,6 +710,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pyrsistent": { @@ -697,9 +735,11 @@ }, "python-editor": { "hashes": [ - "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", + "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8", "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", - "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8" + "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77", + "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522", + "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d" ], "version": "==1.0.4" }, @@ -813,6 +853,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "snowballstemmer": { @@ -827,6 +868,7 @@ "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" ], + "markers": "python_version >= '3.5'", "version": "==2.0.1" }, "sphinx": { @@ -842,6 +884,7 @@ "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" ], + "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-devhelp": { @@ -849,6 +892,7 @@ "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" ], + "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-htmlhelp": { @@ -856,6 +900,7 @@ "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" ], + "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-jsmath": { @@ -863,6 +908,7 @@ "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" ], + "markers": "python_version >= '3.5'", "version": "==1.0.1" }, "sphinxcontrib-qthelp": { @@ -870,6 +916,7 @@ "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" ], + "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-serializinghtml": { @@ -877,6 +924,7 @@ "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc", "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a" ], + "markers": "python_version >= '3.5'", "version": "==1.1.4" }, "spiffworkflow": { @@ -915,6 +963,7 @@ "sha256:f57be5673e12763dd400fea568608700a63ce1c6bd5bdbc3cc3a2c5fdb045274", "sha256:fc728ece3d5c772c196fd338a99798e7efac7a04f9cb6416299a3638ee9a94cd" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.18" }, "swagger-ui-bundle": { @@ -931,6 +980,7 @@ "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==1.25.9" }, "vine": { @@ -938,6 +988,7 @@ "sha256:133ee6d7a9016f177ddeaf191c1f58421a1dcc6ee9a42c58b34bed40e1d2cd87", "sha256:ea4947cc56d1fd6f2095c8d543ee25dad966f78692528e68b4fada11ba3f98af" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.0" }, "waitress": { @@ -945,6 +996,7 @@ "sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261", "sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==1.4.4" }, "webob": { @@ -952,6 +1004,7 @@ "sha256:a3c89a8e9ba0aeb17382836cdb73c516d0ecf6630ec40ec28288f3ed459ce87b", "sha256:aa3a917ed752ba3e0b242234b2a373f9c4e2a75d35291dcbe977649bd21fd108" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.8.6" }, "webtest": { @@ -998,6 +1051,7 @@ "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" ], + "markers": "python_version >= '3.6'", "version": "==3.1.0" } }, @@ -1007,6 +1061,7 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "coverage": { @@ -1062,6 +1117,7 @@ "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" ], + "markers": "python_version >= '3.5'", "version": "==8.4.0" }, "packaging": { @@ -1069,6 +1125,7 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pbr": { @@ -1084,6 +1141,7 @@ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.13.1" }, "py": { @@ -1091,6 +1149,7 @@ "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.9.0" }, "pyparsing": { @@ -1098,6 +1157,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pytest": { @@ -1113,6 +1173,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "wcwidth": { @@ -1127,6 +1188,7 @@ "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" ], + "markers": "python_version >= '3.6'", "version": "==3.1.0" } } diff --git a/tests/workflow/test_workflow_service.py b/tests/workflow/test_workflow_service.py index 753d29d7..3fbd3a23 100644 --- a/tests/workflow/test_workflow_service.py +++ b/tests/workflow/test_workflow_service.py @@ -132,7 +132,6 @@ class TestWorkflowService(BaseTest): # The first task should be empty, with all the data removed. self.assertEqual({}, task_logs[0].form_data) - def test_dmn_evaluation_errors_in_oncomplete_raise_api_errors_during_validation(self): workflow_spec_model = self.load_test_spec("decision_table_invalid") with self.assertRaises(ApiError): From d0279a11e5f1eeb83b26c092c02575345cd3f5b6 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Wed, 15 Jul 2020 17:44:46 -0400 Subject: [PATCH 087/101] Fixes failing unit test --- crc/services/workflow_service.py | 2 +- tests/workflow/test_workflow_spec_validation_api.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 7e869fae..de6cf1c7 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -96,7 +96,7 @@ class WorkflowService(object): 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. + add_docs_and_forms=True) # Assure we try to process the documentation, and raise those errors. WorkflowService.populate_form_with_random_data(task, task_api, required_only) task.complete() except WorkflowException as we: diff --git a/tests/workflow/test_workflow_spec_validation_api.py b/tests/workflow/test_workflow_spec_validation_api.py index cb9b6b77..d79986cf 100644 --- a/tests/workflow/test_workflow_spec_validation_api.py +++ b/tests/workflow/test_workflow_spec_validation_api.py @@ -92,7 +92,7 @@ class TestWorkflowSpecValidation(BaseTest): self.load_example_data() errors = self.validate_workflow("invalid_script") self.assertEqual(2, len(errors)) - self.assertEqual("workflow_validation_exception", errors[0]['code']) + self.assertEqual("error_loading_workflow", errors[0]['code']) self.assertTrue("NoSuchScript" in errors[0]['message']) self.assertEqual("Invalid_Script_Task", errors[0]['task_id']) self.assertEqual("An Invalid Script Reference", errors[0]['task_name']) From 6a24bcbf6fe228681c77c2dd383c3dd47739ad74 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Thu, 16 Jul 2020 09:45:11 -0400 Subject: [PATCH 088/101] Clears out Jinja statements to get test to pass for now --- .../data_security_plan/NEW_DSP_template.docx | Bin 71844 -> 51176 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/crc/static/bpmn/data_security_plan/NEW_DSP_template.docx b/crc/static/bpmn/data_security_plan/NEW_DSP_template.docx index b698c8b23c7618aab7ec99c47e85c73f86704251..f6faeb28d092ff1218b745716da02d8609c1508b 100644 GIT binary patch delta 19694 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 NirizIzHeYZ{S8J4a!mjL delta 40411 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%0D2_h}f9(U;4%UHsoO{&_Zn2OpxD zd3JSmfG)C!DQrBC=Lvl*BwK0l1hqlN9x5@eFQPI_6L-n@)ww zoow3#y?PpY9`~~LPhE}%Gy5ITlQl@+yJq^U{8n{T-ZZgJ)}DOR#RoSH8y7>uP0G!b zRs5eh5O%b0<#y6(D!1LMx`-6hEN3*38qQ zJDJqFoO*WH$+YrT9vqz&UyYrzo;hIXu7W?O2ku$(Y`h|wbm%@PHw_URj|;)#^y4H8 zom4;fUrpt7oZvm8lk3uxWF4KJ0%|R}f}6L!c>pYf)52*?-vxpj8| zfXm#(K0a1?EmH@59@5OJ81#;5VX358{_U1F$N6qFiB6bsX_g?0)cmrG<(TMNzTyo!P;ET}RXZE`*2%UaN!S=Q5{mN%j7ji79;ZVEv5U-(MB}1C#DNwmDWTop8oH8vuAk zb$2yUz150q_JWvd-rmYKo=*8iv{CR*e}oCT-(L2q!>>r0h4!yyZ4b!x7Ra-9fNHS% zO{c50XtG)J>t762W-rwq+W5{!E3EdL?D_qI^6WEEk7%PR`Nx%70yYArS%f=5LpZ-- z4+qRR1dvpXsAfq9oWEk|`86L)*Ws^9HtDXbrPfSxx<1Ui zs}W8+Gdsyno0L(Vbsm{?+r8b0dvcGx#MNymn%JxFafR6j!iu>fi&a?FO5Cupkgk=Js)n zXdctHULmi*aIwO_oFX48W1e#_=bGsxuVR)S9O$5&THhY-Ukon6JZ6HmH4!i^kC}^} z#)K?#pk$S_n;OeAm)gaT0T396BW+)zs+ZqYHIvC!kD%eZQuex>VrQW{)o;EK?!|=Q zROiB@WhX^2mG&4qBMVx+sJBrQ{$kLG-vo+L+~99|o_QKO|JEqcDpPLQ3oDwO&T9$f z^>u1XE@HoIcg|uR+^;*Mp;$(TRPy_qRWMg#n_f)^gTeP~H1g;37?3}roKr&Bmv7K* zPD$EnK%@N?R+P=Y(4xEbC}$Q4cjW5FthtO^wbFm_+7_?CvP)8Fq_QgCq{#>nR|_Pq z(Mc%;?uj%WIS1G*pyWOC){lN#z~I+pWR=czL$t_Vj~p8_Dz?)fq2v* zGhIw$D;Ap2YviTd2MB()#)LobwI}JOmHvK+{<34g`s*+uTLA#i>hmfQVT zD^|7ym&?OJw}T?nuaA&>xHielh{1!bs)cRON51f)UWJ`A3NR{JK2zxesx|uF9;w1U z8jl7xv)dA2M&aFZlVyZIEQ z)ba(U?lrl94QQsgff6ClRz{m^MV88LP@rrzPNEs98{nBHPrBM{`F)UWnCpNtOFL%r zOS5g8#KBD5RcHhF6o*KU4sv;9+lQa4>OO%TM>zeM89dz5IT!+%&E8)W5aOCt42e_X zFb|)t=fq!H3{-^dIcNdV7JR~hBZm$5tFUMDH@36jCLkI(Vi~~me7akXeqHQ-y@>Oy z>~oT3G;5kMgf_hBlwY6d$8@NMymt$fiEwJ^`}-$_ho27zc8GYF$!1L7BYM2wMa_ zgxB;U0ni{ivOLzl7GEFR_2iwPd{y|9EG9@AJtanX8Zo9%veQ22)guM*+PWBy3f=njGJe2?w` zt~Bbv+iE$fz!@U+Fa@H3PI~XM+Sjb2pGqIqi>$n4+ruI)_|jeX$@1Y%l4R=MW2GK& z*sDON^G*xOw%g1+KECwDZd@-Sj*XMa>#kfrL((mf^gwf~T%9JCW3z2drdcdorp>5K zG!t{#bp`_FkW)Q25Hjc{n{LhC$o&WaD&7L$N}=`ASzDeQevr}?QB0+zG(SkaGU>ef z$^uTzbO#GJ5{_%5*Q#;2gT`%%aI?wG!ofz&ATzz!$+E$9(zHZ^#$r>s!u*|XKfWvV zZlkY4>b8t4l=@;yE41TL!Cx!7+bSy$WM>6U!84ekxqALn|Ejs3@gFEkk^)>&fyfXt z2|TkQl8JXuxY|oYqnRTJl`>+0n_eAL2Ctw3vK34>8@e~vIexg=a?g+^IXUWV=$L!# zx{tZDZcBT9r)6Y4S+Sj2Z{#dKLn*GIor`ni_zs7|+h<5Ky3k^PiOg){1pZ1KhnyKk zLTQ3BFgl>-gNmW{Sh}=d06>R(YmVCyjQ%VBFCAWw&Qp zcF|tGSgdr5rrPvQ{|bj0y^AEziu(}7(boDyzKr{&v+c6Fp%Uc<8L+Kb*z&MK7ONLe zeBY_CL{DNRK8!)!D3u=yFYWCB-vyT|m8Vv{$%x5$LYzs0B)SNvOB?Fgm4DTZe><~@ zm$nEeFF{m27hrCL^KkkGp0EY^brQ92T(tZw`G~jN5Ti}JS6g`qYi)g+M)rg7_y*&W zdzv17dXZiFx5zWUIKWq+$@`?kK{|=z)^qg-yjvLEUA#ee6PU{cx%bk~gK}&1UrTh< zXP}gg7P3Pa+`I<83_1biY`zu&8nLcjx;$fM+Yg2W6?*ot<{vq6NB zKYdWz(>xfc&2=^$ASy?k=w7gokF2s-0;46A(?snKJG{qL835d#CpuhUvmZ41ewQ7x znk?<^UdJ-Yj*lR`;u00|D}i%nS>= z_H|>97EOT2df6UVCozh`_Qvov_AKIjwl5d{(@v2v&UWPe_y5WzRs5R7`FdWadI*uY z`ly6l7dq{-TLBcgY?r(YRIPtGH4J;#NTbSeDftb`Wc+56p}%L1rdiR;Va#^_7h~@L z9b4D5i^ke9c5K_`j&0kv-LbJ_+upIA?AW$#JGpuP?|lFL{&UY6byj9^B(CBL3^ zXt0!$W#B4&W%`wI;k1e$j!{F@IE(Y+#Q@7~w7T!F^(8vv*Uat+;KfDhZN^sR;=fOB zqqREfjUn_IKx8($5o}X;?*L8?hRhL$rXr&qcobjPCR~Q-0T$Ub>I3bLj^fgRi9TLG zr1>UZEV&+~g*L+L&rlC5R%56rdFl>S#?=^g1|5eh^2|mUXhc|b;dCjB_dSiVac^0U zq*!D~+vGu(B2#PVq+-b5w9I43x%7ftU>KY1O*1LJUaM)&Zhsz5hXXEa3e3qiCw5jT z$JHK5mXcxJH_oQXc?Yj~^7=IC4ZF%Ix#QKzM#g%m`x`I~ZFX|GXFgoC$JAKjK25%x zk!!;b&$PVCjYWE6JwVCpp-c#%-b&n&-;8?m`g9(}*eI zJ<@xLayp06?%i2*X!GR19vCL(SNwYU8z5{Ju&Bte=x+Z31L&g#K_1&eN1zjJ#rU0C zf^XhB>^<>E6?xt@E+Ul@NG1pbr8^zWA=>h8`Y`4o3Ls9lU<37rt>7;Eh%Q9-DTZXy zi$4ikdf1vzQc&f)CU?t&Y4>&(A=v!PSb6yV9y;U4Bp6bP=Ubt^iY4COUk*M{ zLiN2i67dCQ_cCMe8VeHBlxO|&=wIQZ{_w%HD|*9}I_56GmteS;0v@r>6AA!4=G`sz zi3|!n@f#MB+o}QO7viydXLLt3M*NjlTUekziY`D8&vE#21@tp(&?O?lolSwSbpfRc zuMZVyTAy#zzk@op5K}E3#H1o8alE-FDSd*)iEr)Y_X+{nF0CG82|l#I;O^Sb)Mxx- zv8&w*YGf1YzNUDy(GZdv#{9?8rs%j!TrawDer&-A<=O^ftip#Jf@y3qftfBt#7}40 z2tGi!y8JG&f8Rod0fRn0yQJsO9RSf=SM1aHS7Yf~QbbQC7z-8&L3dO7jAb9%r<&QG zFQRX^Oo9cEd*A5U77Wg*o;&^qwXsh<9iQf-m>uVqD~%)6&p5Od6&r`T6z?T=Z_x82 z*WVu@(74zVC2Y*INl!CQ)t$xe^Aq4Xww{0xqhqW1S*~6;>Z;;?0`!U%noc{INZm#W;cR(P^8Zr7}-xLLm3~??> z$0P=3P~Eu6b}76X>2P5yZ~(`;^`r;CwBCspy9p#q$b9l1(6&mC?3%09xMt`uT>zb_ z3}}9_c{~~6Kw!XLgbd1cQq>^l=q*X`UIYPNX1$gN*1!tK=nfecn1^3(161HL>50}4 zbKJo&%Bb%UN;G*+B4qPA_vIXR<2sQ%`=OtYlkN>+qJMJ@zAE-O$WGftMuHPY9FxoEjr~6_E~5NR?C44L^lvGUuY%`hi9t zYNylRL>U!^i16q5*i>4;@^N}CvGEc5IH3Ic@fo1R-Rfr_BXzfVo;;zzKOF`W6A!ZEN zx)tYs+9du!+P@m+09~Y*a@$_gLic1lB_}THonsG8`EH-@&?PSa9XRoUpky$*J0E&( z4^ohXWTfrrIbA%+M@*2L9>CE+*?7;{c%LIL#Rq#U7B8_o)3106L{$zY3}wg@Ep7;@ zi0gw>{5lt+TmUpf!D}2X8kV+a1QRG@D^OrGa2xUobwVPBFwO%Q(*YWMNEED)7DyY6 z&kLS)3t69fT$2&Y?-QTLYcuWNJ_IZv2#YVTG@V;u(P)c>+lW!Y0bDiD2>G9De@&rp z3{bJL<{9Y1(B!_C&Rf}#<`%F$Gpx- zLDS;*5g~JlltiRI!vui+$90>T4dhZFV853thWZwOve6u;Lj|)ANC@;6n`lXzrDxJX zrwV|WBKYL?Qs4_d0Uj_OETrv-AbwbYLkcqedUDoKL)9F`urQ$U^N$XM|P$*69nLKQ*gVz^@K%#x;e zQvB_8jEyV0W3E&4^7813)+^nEQVXpvUTlQ8a&lbE>s(K{0F4Zdc-uKX z&w(Kq5%#wlVYbP-8Rn`UnCGK>8oPW*>a`ZJe3QS}rxZi>yV?NB6$56X1Ng5v8hfER zy4$<3o3Y~Jw>o*H9;KLF$o0^e5`jr>nB657l^b4*Xh72v@D((JKUGD{sTQPh09F!= z{Qw9(3;4}Zx3~Bu=C0rchj|36&{<*(XmMSl`A2DXN5+}`Vu|#WKcEM!+&<0Vz1wZt zo25KaS?Nipc5j@w43zoYxbOjq!b%NVzF6?jQC0Zn$)7v0Qe{`?02Mt7L@PBIus8K{ zx#^lNV89uiTs8?T7vo}uX3C&i4q_8{BdZjTcf)nQHQpN{uE~A2hW~w~z&w!#Yi6zss@5bRyY8BF~ho2-U(RE`yFN9-X`hu_@n7Kkh~y4z4L+ zyVfppC*tI^dW?G36~lK|G`D~&JqqF2-JLJw3Lt2roAmU`P2h{jZ=Zwz1b*0`E1y^I z&E+n@3HNs!Zk$Xalk}wZ?tmE!!cCeah)0b_F8$VkJI-GC3+sjz4F>OTR~%nu#xRt!f{OWIwgF_%*?xmDUTyfy^-mg+ai;zJt zMnDso;FoN@4yUt@R=hW4a+Q*~+`5aYSDW!~P)#M9-qHMO9Gq!JpP%5wQ2sEEr5P$D zep}2s=)uxP(JhNwsL=Xcv~%9fu4tdW0`|JR(N_ug+|spyYXvj4hE!y#uvhlZ9U!(x z78YW^D^M`cCfhcBwN9(r^3gLE4Q?1M)B&XFC&P!posLsOyYDGhA>m(~6QcvD_V7Y0 zfiP;3`cNEv8O_&6Fa+bcRU#VbTlVtoe=aea&X+HDplmtKKbygkk#E7I>Zr<=dGxdH zET7l%TlU|YN<4>@GuyM7y_(*%#S*|#jMxZ9Ni#YnY3VX#G=8sw(o?oePVfb;Y6HBt z&l+->=q=VrlpnI2ZSrq`2K)q$$=;Qm=e4xOX-3)(Gl+CE8#hp2O0BZJt<{rvBA1WW zcyu4&B$(aitlD>O;!m_zSGg8tV(A5ATx1qn@@vnt4zgYrBjNf9B1Cx3(r~qDhb4o9 zJ#YUO%AhH;-4M2`qnm9n&ozqO-Bp#!32TsWJxmC$PvIr4rJIh zzEZk>ew%h1u9Fzr3WLw!x+Yi0UtANWy{_8dlle_VK{Q>04Ws4h;gTC{e`J6Y&FX~qsA`#FKQmnFoH7iAc zl)`C|vM|C=mGm5N{}54+=}iXMl~YxhMGn-_Ozrvv;Gtu4rx&nPR}_zBmqF3(M%^RC zYyVkt+n}HD`4}=}Iz;p9qwuAQoJ&0R97cMFGA$ZwQ^-qMp_0FJaY*U4Az zO#_UzTTB7s)HEk1-U|;v@mq#Gf#hHbucuWR&9TE%Uk|4s@(j;&&->;55lDdgq}l>e z+o>9P$zEqoYkj6sfV)9I{HygjrM9E7SD4P0R75s{eRe+SRlxbtzC z=6m&{>S;3pye;MY`&ZWEmH$Ky$5Q*W-4slB_fKF^*eTMp@9mKQk{%K*M_R2?HDhWY z%*oe9!;k!5b|XXEOQmQ6S<@3C2ZU09l`Ey>vvMG;gdw_GSq zGI(MmDsWR3Cxa3B`g*$^YmRiCEx%S6wq|}wgh3+l<~n8a_neCjA3okdB)2eLUw$5I$a-6A6BjE zb2fveC9TM4*i6azjn(j3TuF}v#Aj8eN5mBkYT&LaI6C-%vl}Senzl;QmbT(>QnTiM zOe0L>+D2NCln9YOJY@3tMJMn@`%14ALetd+j1$x3p0Qou6QCjN(*$ExG%#3p$#Ir& zZhqURDK8oLf%C>L(1vt$pf}+KnuEG@Tz{FHTtrmP1?wSzF^RE4Px`?!+1Y=w1x@GY z8ygUc<%tmh-ViKKvr=~;C&c-U3onNTumuc%IVXkI4?bhKJ zSX(-}vijz33o#^A3XWYh@8gZI_?scpF)Ov(*32 zt`9D37Mm%W9i40)L8Z9MSjwW*7*?ne*%Cw!DPN}pN+Z~CgHE#{9rIAwr;yo;r5l*? zwfxG_k6Sr?;@lcTM&Ipvhp7;7CDT~O??&G4?h<1XG9O3fQ*=|KrpPV&jC742&zil^ zA-KqrEpGM3=ivW_OeGunyK=pqfd}#w%&LK0m7k#)wh2P>UHlit%r6QJkjOMbh8F@_P!q0m=wKQ--# zKNN5#-^!6p5iFM@B~Wo^PuSXoJl#3Zh5IFo-Gkk6vUfY29>U&8%IEmZ3_`?aaKzoo zS0WF^EyB9L5G7u(^mJB<`sffwC{+9G+kn{A`G;>@7cj=JAj{*XUONduM0YYZunVF z^{h71*Acj&gBZqZ(}K#PM>Q#xk222;B^!+j07HO1z(`VJ>)YB?Gz_8bD!L?P8NheKof*_%PTUdDhKNbA~d_Y^#afD=O3{g%mu3!mj#s2FWeZgy+z!+HXA+aO^tF za1v2REstGU$}S%i@b&$WNlncs^g|ZwmWU(47&RE-SO(4^1Qv7ld+CVS$y=BVe<#MZ zz~xgv)Fd=MLD464GmWkKLlDdRot)}}|f#P?h}6gt;?h5*o}rAakQV)ucqt zn(qxbj2E%!=?e514Y=D7z$}<+&&Blioe|5wbMYY9kCI7E(^RX6Q5J?b~t@pIRK29XQ9?i(H_3oZ5G z>|J`J_NCf{F`p1mN6SbTpV0O;7}Od0pPWcH5w@7)>H?|4gr8^Zm){R!gc-kBQU*t2 zF({RdeD;g-`8jy10jn|4!oGI-A6|4#-t9_?OLh@jiv)VDKt(J0X)1-yed^ z%EO#lfpUHC&S!Jh#ca~LTd=Yf8%gb>JmhH`?|*}PLy(CCG~|KFo5zbf(AE7&uT0BU ztl7JL*CA+wk$?$Z4kjNvkV`vsN9kdL#f-jH>sgHs)Y9~0%4`oB!T1RbZyCu=RB?bj zr|hvkE30O;apOJ*tZ@g0CP~L=QV5;W}u*I|%A?`A|Aj2|6H~Eu-G$dO=cDC$1?m{R4@}u=f2v1!3NKSkQlE({C z3O2QPX!OWRn*ry5UV-sBD^*3>Z89=at%;5*ERuf*4?#&seLBbX%8x`~1^S0h+tp|g z!fbe*zAm#fL0sy=d?hBV!xU<^?C?axwrWdt5vITUfJYdnGyRPNMPTe(mZ2I!r^O}ut@N2z+*x$=vRRx(#yfuk8di@|D>h`{ELRY z3#E$YABOdlZyI*wf3tO(+SxhV+Busz{X^F|pSWSW%7E&3MSg)xv?YWCktiBuNwqeh zvtWy`%>}O>?L)IhcyQsC`MXCm$h=jP&d}Sp>rQO){Zb`qMpd2sml+ABA~N&@VTxx~ z9Dg?e-ju{K`y>;Plq(|Y+85V8xI}-)MGMpcL9ZPIdojCu9cA6YZh^zyg%r`67r!15 z*oVMbiV-2`h_s}C=Pr!0`p)#T#U37XleyC*=G@_w7cOeLl%|L3@Wyhf*6^{23A7OV z@SvWiXp1mImK)Z%u#Rhk7{W)em(&Jj6+bJqlf?F{4onw7ndgJT*pxqjBhVhr63WCU zC3H|6Xg!5it72lNmSOC@0h^q>;Ev;$Qkc^$TW-zFFDHErjshLFxfhT68NFhXpE@7f zlVdrd;FK^j1XR!rRNrOG(M_X#D3Gu;qxxE87A*G$TKVxiR?2CK^sd1GqFZ2aZb389 zWQx=@h&U4P=S<;QD`9YEH2ie_3RaWDAk_qq=2zS}Gm|)$P@wM~%dsj&N1BW&if3Ru zt$3&VL$vc3nA$qv#YmJwXYx39tq`47q_=#wdx-}O>4QxsevNHA5H6HC%81o`0EXT^ zAt=>?QV1Zn_j#)xe5-!oKM?7$-rgKlU94)N6?h2PSOC+ zT}gR<I=6>D#_L=`?cr~##{tuTKlig!L6?h_^ z;1}L(c90CUC@7|Z64@Zs=j-8+HA3Py<_5rhytPgy_?J^TvuX@X`eeREbhde*nEWyO z>qv@%D#HkqT1d6$n3Bssl`#SWzKE0-rznw}3tE3{`f)c@9+Ng+=r|7H_nWPnRYra% zGp2ufHUQeNBtWU4a&#k+3aOmuC^5K^NrR&w$<}q1Vze^Yy;=A&Tm)CvyO?rpR8ObQy-P{0F4kJ=5 z2G69#&Q_`qS%5t!#HXs&CM)~v11q5CoLJzB4tugwAqmgoooQ9I3!Vm0*WGg(^4`Vb zrNJZzK4T989^K4c2J1mdNQ|KYq=WEo7rnR_jWS=$ZO-sV8bv6R2}+npYMR9IoTX*L zsAeqyaf`M<#IyZZ^n5M*QBimMPF34(?h1 z-H1c7R%+uEB*h`TQmc2K@h(^MGgxH2<_h^jI()yZ8ALI zTtg-jn;oMI{S|*;9Tzb1Y!uGEY{YF*pAmFUMk?kZnysM9%oN|zP6j6FNP{XlH!B3% z`5?avfqYE*!Max5K)Fw^JiDA7M_XU$U_g1(r-?uHb~^JW8OxGArY4I55`(mtT8vxb zS+)z52*EO7G4F7R1207jX%9Syh@1|{k6{*CZ>@XVflS$n2~!C0Ymj?^Kj)}LOvrLf zgRH~6m2wVHTwyP!@fTFpP=?3kiStEKtX83lb_8)a^+P3_y_mScfP})KN}0Uud+{3v zlx>$25}XUPiiQOL4G~j$7&Kcahs6<^b=c=1rCHB;@9j6#-=4ULF8$XT!qa zn!wxhKF2Q(A$AmG)@scgSe$jHY@Aa*S^dIX)^j<{VX>qDSU`aSlPr6R1D>6(HCGe0 z;PR>s&zJ0P59R*!&ownW?b)x)*I0>Y8?5j4$487bes%d~;S%+S?GUjzJ$@r$X;OwAsDiuErWF6kwe($D9#p)bCsM~RvMxfTNvYK z%0#xTA>yt5eN?;UPL&1hNBm_=@r{B_Zw`~@jFOK4llQrYB8*(&jf!r$-1AeA9RgYJ z&xNdIB?)ymt}I?Z#*#JGHDwE6+7h*Xyb8c0{pMG8wkA5L_8T}vMk0<<+fnWr7{wqf z3|kK*YKr4Yt?l{dbgqGR^vYZgvqj&UFbWPbIE2f#o0i zX2vlJuX=ch)S_f&Q*W(6t)bXbCe?KLSgI}lpj^=jsq!*aDP`YLJy*|db3a8 z6m%3)&%GLa>Er->T@8B7`EGWR98p!$??#?Axh7b5c*QajLK=!r<1M{T9NhQiSI=4d_e@WyZj}d0C-`8QRxOdUt= zm{GiBubbAps~rA-%zZWJb7B^C?EDoVm{cAUpEl*NnAvcx zr1!L31#)0lx6I0_2L1TBOZ&ygLnjNlmsQ1?DhKI(5TQQ}`FJt7Efa>pq-Bq%)Lmz( z#={p-HhT*}+I*=hNn4{u@4{Q7i|f81*{$Y95lFOKcxqyVaBv5kHnEj`{Mg7&F|wQP zLzyd)cIsuXfqlkKv9Nl`P60GF%ws+ua^cy&MA3M*VK8#>q*>Iob)57H*S&)O=u@+R z$LHKr3(>yOcEtu!rC4zKM%t{PaIbZtI9DaN&}Lh|hSjnWdZ#{?M&E_Lv(VxC^&hPA z-}Msy>qQ9UarcIqpbP}`kNo$)h>eM{g#o>Vje(g7Bb}jzZT&qYgFro{xCtsFDA@mt zW%YzB@gxHR`bYjxSf>BN&TD&<{rt}gMO@Gcx>m42K)7rP@_Fch1}!bSZ(d7Z`spu% z>(;tMUnC+(CmZAe8F@~AeUa+QDhaw;Z4HHp&!=osk+I}%xOE5*WY(?hH0C2;Q`hd7 z$EAtab0O~Yc3-c??V9D?$_=_Vm1ZBe#^(bYUbo2lk7aAMiMFjKwa6jY?&rq`y|3Ge z{(~-DF;8oNsFF3{DJA1zVb>PNEd?F@?Lep9;<4scr=9e=37>63{0shyj&0)l)7P!* zoW0%j+_ueyolh6)@A$4o`lqD>zcqbMF~^qkSl-rC^PaHd%GRdq*yG7mxZ!iUODsvN z-lpB^g}eHVq50B)Q1`}`OZ$gK)5I4yw!d461mF868J{&^vE&Gk<1ONX@4o+(cyQ?t zJJNZG@5<#~sCa$l~XzS+|86rK|a!t1>x!Cd0 zrKx#HK^BjVQGLhz=SCBLdIqZosm)Oohh_&^{d?6FyBR`CyQ_M2iHBvt)o~mG+>QPqN=JuPh$&}N|3kG#{IX&y9tK zE6ZN*!zU1A_mDt)mg;Bt`N_pzb(IbCX2R-c;UIjn!qUd23_I+{^@pp7>r)Bs;>KnZ zDD5<>(ObxPEB0$>U}m>f2HM~af6L`yiK(!LK_maiGr7k3iNaq;W!Yfj5|q~{2AH3( z76^I(g1_U_rRF%oEli4r1D0uoMCy+a#32^^fyQQpL}@jtqR4jcxE35kEa^k?goc$5 za<&g3wee!(8GD;Xm>d^B4kyO6k%d7VLOmk$C+!i7Mv#a{xhpS5rTkV8gBV-zCut~M zAi{w)p^k`5JK)ht1u^aEXcuvg$SlE;@^pd$+y{#3K#e_U^_Ww|E({oq<-#2?d#c+X z?!9|TAztC;O!`q6KmYs$aYGv8qAVihW#AD#jt(-0Gog)wft{1zFwK<>R&fxujUhr9m+o`Y8ib99 zYY;{%#)w2h;#?Fo9yQPqLxDRafS$^=lMCy|AsP9@9$_r;3vpy!qw>P~$IxnZfJZ92 zaHFWeCW#rN5Y|YQlRSSAD*ORia3zi-fVN2gr)Pn(<{p4ZJ9S9>1S^h9Jj!)O#f%O3EGR^R1z7sm zm9a9!P|;=%by(bT@nkH5td%kel1UpyRi%P{uS<9=#+l<_rW8v;i{iE{*SY4B<K_N=j+kA}dL5-I=Cx{y=r7 z{DzgkTbsI51xCwqW*~!0KYiINX#ejY%|S z;dWRd?Fihdaze1@_^-k`5KApFTlTL<3K2ta_~806OO3lAmvKV zEXpqdn=|W=acdFb^M@c*I9dgCiyz6@E`BY7Gb+KbjW!hQJ0BRg25_AN41hh(^)jSB ztz)N<*-Sq!R^{hi-8pfU+9m8VGiOQho=!4C=r_uu)bbnaL=1ac)5Mz=J%)5g-a}2f zMnvJL>XzMn{R!FzP@|*)mn4V3d#|e;XE66Rg7HIP?3hcRAsw}XTg4GmJM~xzQIhWb zX(=F`1IC2l$+C+$L&6Es2Y}`Bh&p6X-j^$BJ-hNs;5${Bz7mgQPZ@1;G|mV6 z{YT^}(dwid*(~IiEdW{J_T>g=`P?fZ5Hup`Xr5H-wFDm!UM zC=&sRA^nY{Xrx7HMa=XOPttFWM^9>$VEEbTAh1|1^`C8Uo-+$9U^WFvnF;f7-(It@Dg&19T}N7YCq1>#aj>?4b5V@K0kg_ zKrd`ggz->-I?_%gzxWj2(qFKnFZ+9JYbhr9j?tJs%RqF=esG8!)0ei5i zK?WQlBqKbN4-%OEb_wx^NLx$f#3+0Z;-X&OZ%l+7iofTyGS3qBdWF2CWT|jIpc$Wy z2{}cwpoQP>ks(B*BHa1?CzO8&p(5N_z$haNoyqtI+>Tqb?RGJWn3@(Q1odT|1&x~% zDrEP&0)A6I8)={u+wV&y!3c^AndD#pZkEPHynMd9XF@FA$=pi_9-kPmGAy=a*mEg` z?`w`grGV|~9FHG$ENzvh8nh!brYs6cP9@(9*31o2+^r^1!Wlx7EDj5ENiW0u+o{18 zus5eF2r?O+mQPXL0X_3PK%)d1a@E7MfXZQK1`x{*w&)oBsTHOM{*=yXuXQ79wk0)| zhN<1YKhFEus$IG^s7XOJd0rx05{;492e0X!R~hZoZa-Mt6!{xxsF_Gwb$T-*@%{ev zP)gWDI}erAbkYrG1#+@M1e>2ZBMxooSulw|hHlUz&`MpfGMI~Y62erArS?N(2vYaQ7K)ji2Z#oz!r2Y@BX(9ml*mzH@aD^}6AFnB*Oan0wthT=Q7`6%>8 z>F5J+0-2hM?eU$fTyil_Cg0wU${15=-a2@Ey!QGi#@ytcdb=M05>valK)^rxu2(={ z9@>rY@o^c(2Rm{39Po64R2?H>;q}&PW*D@)t0+*Yqm?@xO`53 zp5XRvRCl|&6y=MHzTYax+{FKE1$dBWGdT1NAaTFX(UeTg&dKp-4-HtHCmiitNb|*( z&82$c&6O~I@R-UPnPRnjtvB|!t#x&4dcSiCB zsX+tv&igvw;!GlkzbR$UbN2Ja;Kq1$K2kz2J4IzhFLtJtVO4pKuQ>T|H$U@d4Iup_ zv@P|i;&!j8)G>OZqn!$UL&j!oXuipMbtT`XSY+wotgOPt=bl}*QK zhJOORp0hwjrIn*)K~=}6+Rv>5$d07PyrS9ad z_|p);7n@S=x_ru?l4drBd4RA{vg7qltH?ylwl-I6S+|{l;8S0n-{xf6p2V}oRj@JI z|pGO6NrOc=L8lrvd;m*sW@yXFrfdB&QBcRA0Xru8+cZG zIR?N(C2>>H_hRm?00}j_wlePoTvo3~@2jzeiZVE$n^e z6lV5?dsonw1ain~#{2-q?k*(Ro+D#qc=wV4Uc6%GIzHb}@-=L!d=eEJH}0|fxYVZ|Hp^w?h^i|J<$h9hatXLAuK6$N2{;krX^jwrTI+0z;=Ut z`U&tsF#DptGQv6S7p!!__y8He60udugMZmair&7|%&0N?%2LG8p01OB8jW(|#f1kU z&Jc53A+l=6^Ai7A&V)pJMdYXjTE<9)`NVKyfjD1kw#NGCpM65qK@3d}GoOY$0?~J5 z+meLj_Yl+VdEEf;&hjs&vLEHriN;Kd_9%PDf*yn**g#2x10tY?cWF*%b7Qkngr!}b z@**yzZh^DZr;C2!2SMeN{J9Z>dyh)x`8xq4Df*QW;n6#oc`j!55fgF--M-+7#Wq>` z3m*k6o*?<$Y8nAmAm07#p7mHLjPv+_;_)pT!WZ9Y@(~BHA|D!Y<0J?bwiyM8eRhKq zOanb=8aCA{YXJoTQ4n85tJn@5jzaWlh+E>_brm1?*tCfp~Uo$s#OUdZbv*{j%ePk?CeKaj2Ij^in8$WD`B>WLGK z2X)N559G;f`u#;jot-3+GH&90AvdSfKQMPa_6q>bsieOJ(*m3JKGDxT0Sq4naJLVB zLc03ECFL>xazjW3`%(ZQk=S{0c7k@Q?2Kc7BE0O7Y3E>$coQCXuS#8Pxt4O|{H z&o4JV7oY-5ATHzW7wsWX`tR*ubUiwCU{Z#BSv)exp+_6a{GvYlmV zzyJ!&D1{m)^6AfRm2TQB^qMB>s8=5V)a7T*cl^G-^{*^t<)$xZ^!(LUT0f7jBD$uk zT%X?UzK7-i@4!vo-W zKI>ROg635+Zxef&RNcz*3Xd*h?#&<5X&eTJ4#3CB4@4FaO^Vj44KE2EKi~M*rH#{M zu-DHa`u62F=~lBl3asC9&Ks9%`Lnvxel7Agy~odK{~Q2l8pD~{;jXa zkGU}#K#as8qaTS^>km4zHJ+_*9=^!{+Laf;ikq_>va6HOTvkqf&Po5xq#QoNTY=x@ zqc2`n0mo=g?9trGig0hCko>VJ3iy6HL@2I=qrxW#DWg!KL{s#mhW{fA*hP5p4qp-Q z2-D(;4$Y0LZ(<@tJql1mWI&>+a{^$9t3Z<^SN`Moic+D{3mrvq#moL-NggyjLS z#F)Tm6+YBM#?NpQ%F;rH{e^gh`IXisw;=Z*_3kE>z;lOF>BD&HPqAn6+vto=S zlI$G*rv9BN@_jKw>r?D3i(ssAOickpCdMOr&{Ae%<&F?M&g!{*udo@MJuoBR8Rl``pv606Ph-x{`wFgCGe>I zw6Za9l-aQdZuogmSWbmL_@(4Gfr?^t&^&Mi-;=>gD14`Y=%hTrS?@JrR*lLZVkl*k=i3i67I8HNyd6B(#TN4IOR7 zfW{Kji#*K%AtJViagPygB(DSjla&Qc_-Q%X#bEBmZu^B{5W2=svb^&c3*_4J0lx*` zyw$z7^Vsghz3zKu6S{U)dDFr)f2+&PEOk4h+LXs4XD-{3>5UnCNDeDyEMj~8oGYeC zp5+Wg%$tD~CRPC6^}JC-*e8B5beBo)h_~UMalG@p&e+qsU*a}xfTjxY@XlcD4iBsp zkFOXgZ0*cNiX9(MLO_SMhD$L8*17*ji+g&a^J^P6k|fXQ1}#iBQ?P8B2C(T!9F9Zr zFo~eZ)nzg7c8I?zyyC9mfOvI^I5dE?$jweg_UG>r1c<4(C4v55vwuDq<~kfo3d2mq z6{4mS`%|)rIVQZGNCIrXHS6D+R?3DO$M)D;^%i&D(^R;ecCEq0DWh21A)Wi*&on?7 z{OhHhN>`mZuLxz`Tb|ykZMdpe%uU(USbSbjbblL=M^v=wm5m2IaY;jOK&tXunW~Ye z;yjYvV_+)>u{vDZS%%H8-k%8opsP2Fhevy?l}vtX6cd?g zzql(w;e3bsHDpVEOsO?GdiPUR-k2`jOUh=H5a2d2755fkv%jBu$SEc#^Tvp_6*)-f zh#$k>(8-m}vu^Ak1;qa%1JtkCeb6AcD#95)%j;G8dD(q@9K(0Ueo#2{uT9KM#u`rP zfZd#aUSMAVvM)W-N5Jg%`h^=X#`)!N{j#JiSiLJ;nX1h#&)_ zg!A)=Nxrj068pxmXzpJ`Q&y)XdDsn4lyModj=NAdlUo-i(>py)se0aqpF_V`NUnV! z=EA0av@(@@k?XHE7`Uo`i3Z$MU*bMwj>CR<4-dWr3ZPs>EZpq(`h&36pAmok83}=` z?uO^m3dG)8!OEg!`DXlc z0Mx{QIiZ4>>sQ~8pIl#=Cy9YN6`6sICuKu3sCkn0XrHX?t`cX!{Rr5AW5g{qVE4z=@xqCU??Err#P_VoIMtpTJv#KHS`B%Dh-Im@MujjST8kt?ce&OUpsKs&7E^lIlDDLyZNaSInUWOHaenrutmi zZI31~FPRNb1AWGGMz0|<(ilJHPil~L1S$o?U6piR7iz`tW33WolJc3znf>b}8K9%7 z{L{9oNh)qM-iE5>d^SJ5%su5-kJ=9c?tbgP+Pn9Gp2 zoV&!_e=QkMUc2pn|y*z{UFOX<{TY$qBISPg2x>a~=6VAkV6wqr97 z=yw~1V4VAnBL?!s)pCrIrYS(YkpaH-uaL z?0*Wak(ZGJwNNmHxll0TzXa`O7fJ{aeBclQGq#37C0xt+Kg|ByJd{6^vi$n!|FC*c z5%Tzr+WYRe`5R^U-7mOJDI&=KE+(b9ElGKTs1137Q2&yM!9Jw_L<0VQLc{1jl%*1j zZ62B5D@z=PKCg?Y7fKOcE7rN@4LOu&sF)Y8-H@s&eC{>%c(lX3jWEO;Vu-1VOX0>& zxvH0u{WYK8Jr+JfVq^Gj3#0_k_Twp`_GH`6$UGJzWSQH!+%`2-+}A(5B4PabOmih?hi z!QF5|-aKSN-c@<8u86Jt0&XGd`+`zvG0Xm%NM-$_lr48yiBGfCZkiWmrPH!3Sl#5z zK&mYLzcqhj8x?pR1=#wQm7>Rzx&E3!o`WRi?5?c{<7*2DKByzVCWQmsb@vvbwq;vAolzCZl50nP=4JW zI;G`zNE)3Q>UhCPfs>CaFJG`wvGS0WGZ{r&f~Q3h8W11U z#z0fDF%(Gs>}%}Xq(d(`3PgK6J5k#?c6c3hqmP=d&To-uW)c?)Wb0ze$&B$}k^ zE4vn5_|O_(XbdT4EaTUpRKLkt$|ummbw49VZ0N;t_@0fGoNT_EOS7#TuWYO+dnXk* zbl-pP2QCYfjs_Zf_&c1l;Yk#mDX(Q5#fXT|tvNvZXce7wFMtaVkBJfub&!5>*MA*G zmg{p`hic6mV7c)Alikh71<==rN2-~KlG64ZUMZtV)&07%$gzrWw;tvwmw$ z)gb%XVeG5Mn@^(KV)c^v432+#v)eiO%C{Of?j@Gpf#7@;xs+Yu#HVFTMZV!kKD>QU z9I2uuW20?-QhquaPJa$GYjR3t+o(|O#R_^gIK*{YDQjoOoeXeN3TSFH+eFD^n5Iji&yr&~Z3v zmAXN2*bmG)@WoFc{(qXg%CM-`wLNqQ4BevQ3^l-jbcr;mbW4|%lpvwN5{W??hLmpD zpwcBFAYBrY(t?CA(h|ZMzrFW|u+Q&v&bsEBUr)U2TJLk+>sjl**AwsV!(yw_a&wSy zMa&?be(~-A&G0ZRvsF*G8!u_i0JkqbB*|}0a3+&G_6iT)yD_2APZ~g*dG}prGE(Qh z<>MIbFQf52cGg(gNKm^~dG*?z%0^>BO;C(G3uu`rW8Q(j10x$a=lbv1>JHuzGdlNA+-9NIk)&;rVMW6>p(q z3nQ0`mv{D2A^D#>y%I=fWCL_l@W#HqsX0U~aA(NE4N9kYo9e{%x$j3ClBJJ5ROV>d zNZ~)^vPq1|o4on(OP%@Fn^j>M#hKd_x_vs^HH9^z_Q{XSlW6DwMW3Si(UoiVO#*W3 z>Jd%!7Hk$&cv@AOP)8r5o8mN@x2Q$l+6I-{W0u`8rwd?i(KpTPO_XM*oXap}8RNhjMOXOYLbkMJB!tVWQ0Ha{ ztWBfT@o7HR7;BIy&=WGPLrlMZRX~6r)^CG2OfapOKI++7d9@oS1Y&JVRY}$mqvC50ti&NkCqj~-`2XWD`R-@=6iWyDJc5(TAti!(<5|{^Eh$tT|1^# zF0}1v&RvoLBA$43d4JH_`0lqXku|Wf{QBs#-OK}zqAy+lqC>t^|RioPp-wF!gjNSBYqh&{nIM`TfUK&tkrcV*H=KtcwPjI}d)!pe9b zTH*XKEye~I%e49c?TM>Dg;0pu#9=1cYeV!rdj}UIK+BFKqhB{a|4OBoF>x<AVSzLveqkS(+T5wra@4{b8_NR?ZkJ9oFFb|WU?WDZ51%Xr zBqR)q1Lz5PLp`d|&oQ3LfZK-ck{M^n;#z96=J~+P^Xn8R5n&y1b|k6AY^1PvHkLIB zJ4Flvjfr^-6(T{r6eJ0@5Y-4pQc zip*chpOD=vRlE}LVLG0kZnE?fVAfD?HmG{HF-|?Nx65{|;#*|uzRq-2tb9EJvstMp zjnoQ-YVfTTbKJ)n>jqvN)Y{$pT;}t-^(tj6*)uNtC<;AVpP_v1Kde?rI9(z@+`g|LwkHa*zhE2 z52}UMFlmJU)b|R22$Y8)D6hVMulD3>RFaQ^p-Z#Q4wchtQtBaLgxlH7(b zNkEnio%$#poxwE#TSlBB7%%;gaxByC@2F;N5eCH;Tc+SJw@^|MKappw8~a$%Hw#AA z;##51;o7oU2126~DV@=@ckkwy#mHpiV$+jR$z&S_<5Ea4mpkvSLRTI7v+8#kIr~|Q zVJ}i*wXWB(Gz#z_q|{+iF$;N$@YO?$#{rm=ar_;m8=d&TvL>#*XCP5#yH?@Cmatw$ zfbJWKd*YMiTcOV%uZMrU-8Ra;I(VZ8!kGNpV>$XHeey1#Zf@vA%==cjR1S8P&?VYFr#qB)h&kS(Wg*KmUuMK8kc#N1b)eC-GBvoTf#9uhf!XNlYJ1qz;71q-5t03^h3* zW@Aic`P+&lwrr?j$g(O}ia=}RXX}NJRGWma58UR~KhK~?lb+c+B^nkqNdDU@8;q%h zJVEmgf1;HzWFUMDOD7Ap$4<_!a0{o$R=-3r*R!xO*fKyE0NVY(dHfILPl-{#*UE!_ zqlnWa)0I6Ab&$$+qtXfj6SlB~IPr>9*9a1H^-VfCO**EwN*uZ;98aHdpXJn?zA0L{ zlA1sNDY?O% z6+W&(K{8|Pn6k0A$9z8ne06|K8KXxXkImJo2_*-atx2r=4TZnw56se{zmBIR`@M8= z_NfZqj^c*^ct?LaygWH`Se<^0R=s&^e8POmyYXxbp_)vlI>C64^cx6QHycnU4^NF7 z?dp7@UC}~RXdutb9;>%9rO2bpNeyY)l#0w~1f6_ehF5Iin*!r~WpK)y zh58g6>^E*Z!aKi_nhPZ&Yiz0^6Ttg&Bq8>Ej`q5~F?EVbjv`WmzLsd?Wz%NKeOm)l zDyiFU%0>@+cL3O2`?i$%uAM9qp)Le>Paw;Jm6nLb2<5m|vmB7Cg@k*Cul5Kw=l0r( zB|uL5Qq*11Ek*oCjTY3ipEK{l+Z1(zc-Zlu3!+un_{(hv(w~p>m)|IN$xmCCl1VL{ zUKY=#Tt9($jT+RID*z1c+ZNgdEk^JkQz{3uC;+t&gm)W-&_HKUL!1E&OuhT#bl6FS zun!6PDAc~h&GpRytI}TVvF?($%$RgPXcUvjq^%|?=Jq`!ou5Z;VPg2&r zn~dqYm>t}u_c*%?d-GM`(b9(Y=Q$488zGzVqO<%E24mZ{z1ASElkE`KS)zvJ2r#R; z=JQdCEmP`KfRV^-uK&bIluS~EWu|qv^o|J8db6xyz!M$5Fy07!nKmm0my@oh%J9Xc zm(|OjYqOs!(n5&iO-gV#RiBaSJ#%)v`&vZo2rS5ba)WjuUt%R~`%cVKx4G#qLcp*< z#pCrCHJht`u8W3!1jP-IHT`$aj^F$_KhYZYU-N;7FkDL9DKTkIiucl z#vt-G{_WuoaCYrN(f?iZ+FAE$x6vrIkFI;t-%-j8aCZJ3rT*gx^*;bh7tc9Gv)OTq zho}KOE=k)cR5qNaj3p-*ZN6e=fb~IX;MINB7E7h5@r;SF$TFIN3HMj3k&(N@6iQo& zgHcNbe08yRy**0N)~0bqmDBJYhrLz1G;6Zl*SRj#@rMNFr`K=$&aKahV~>#X#gw=t zT_Xj4z9=mq`PeO-S&@5xQ5qKAQeyqaw&DeA{>+1t&OAAnp1G^dqiQCHQamUs8if-y zNxtw4eDe<^t3)%*rXd=C!TxCYz8!+g8mk9}7}Bc4Q74zc_U1Yul@nPiRyg?-D2krQ za4gpUv{x68E=PJTGcmqe$hBXd2-uymfkp#IutpmyLpS1w@YGdKc4tBEdADfdg6fPd z2w{Kwjk98h$9$}fh%fL?pQuyPwqivVM3hr3e@Pa1u>33hl%Fg=_&WLLD@VE($Y>PQ zR-~!-z^}IW2O7KZ3q5*H*!<(EcN&YqqK~{kT;~W6szB8EFP8C+3Kcd;)Np=#(T@ep z-}9Iz*vid(H!s;6j~-Blm|5%WAD=i3lho3Evp1CX!Tlg{a0?eau}n#3qtstG@)PB< z4m*g|S=QBrcMWpa+lfD_*W%Az{dVLlI1{2C;S?PBs$D$H_WBP#BaTEj%%AoLYU%c; zDmfBUdjop0@J};00L(q6=mgEkC5VxIsWj z-wryn-;J4~>8nv_BNg;L;FSx;jp%wad2IE_70&(ZU+vz-Ecsqh+70L|n`p%e;|0eV zT%=PQGu!W37$mNqj%k2E4Y#rXPdj^VKTUva>YxHH#=b1iJ~v-9{EP5)L9BgwZI`9G z=l&EXfBF9|+*P~BaWVg8@#eX|gc;JiodMtio$G8aYjn%xGdE< z4_3a{Kc`8V@6;gU04} Date: Thu, 16 Jul 2020 13:28:00 -0400 Subject: [PATCH 089/101] Fixing a stupid mistake where I used an unquoted value in the mock data for validation, which created a dict that could not be serilized. --- crc/scripts/study_info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crc/scripts/study_info.py b/crc/scripts/study_info.py index 94e35249..f274b899 100644 --- a/crc/scripts/study_info.py +++ b/crc/scripts/study_info.py @@ -192,7 +192,7 @@ Returns information specific to the protocol. "workflow_spec_id": "irb_api_details", }, 'protocol': { - id: 0, + 'id': 0, } } } From 9570e19b09a354fc9851c151dd8fedb8b3f5f209 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Thu, 16 Jul 2020 14:02:29 -0400 Subject: [PATCH 090/101] Fixes failing test --- tests/test_user_roles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_user_roles.py b/tests/test_user_roles.py index edd086d3..6104641c 100644 --- a/tests/test_user_roles.py +++ b/tests/test_user_roles.py @@ -198,5 +198,5 @@ class TestTasksApi(BaseTest): workflow_api = self.get_workflow_api(workflow, user_uid=submitter.uid) self.assertEquals('COMPLETED', workflow_api.next_task.state) - self.assertEquals('NoneTask', workflow_api.next_task.type) # Are are at the end. + self.assertEquals('EndEvent', workflow_api.next_task.type) # Are are at the end. self.assertEquals(WorkflowStatus.complete, workflow_api.status) \ No newline at end of file From 7cbafe966e0ac0dec60f473aea870f73760c1f22 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Thu, 16 Jul 2020 17:59:56 -0400 Subject: [PATCH 091/101] Adds form key and pool name --- tests/data/roles/roles.bpmn | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/data/roles/roles.bpmn b/tests/data/roles/roles.bpmn index be7992d7..55331173 100644 --- a/tests/data/roles/roles.bpmn +++ b/tests/data/roles/roles.bpmn @@ -1,7 +1,7 @@ - + - + @@ -19,13 +19,13 @@ Flow_0a7090c - + # Answer me these questions 3, ere the other side you see! - + @@ -77,7 +77,7 @@ Please press save to re-try the questions, and submit your responses again. - + From ab5771024ed7c3482e982657cb3987aeae994cb7 Mon Sep 17 00:00:00 2001 From: Kelly McDonald Date: Fri, 17 Jul 2020 09:24:53 -0400 Subject: [PATCH 092/101] Check in for sanity check --- Pipfile | 3 +- Pipfile.lock | 35 ++++++----- crc/services/workflow_processor.py | 60 ++++++++++--------- tests/data/docx/docx.bpmn | 4 +- tests/data/email/email.bpmn | 4 +- tests/data/invalid_script/invalid_script.bpmn | 2 +- tests/data/multi_instance/multi_instance.bpmn | 4 +- .../multi_instance_parallel.bpmn | 4 +- tests/data/random_fact/random_fact.bpmn | 4 +- tests/workflow/test_workflow_service.py | 2 +- 10 files changed, 63 insertions(+), 59 deletions(-) diff --git a/Pipfile b/Pipfile index 0e5e21dd..9af4c496 100644 --- a/Pipfile +++ b/Pipfile @@ -38,7 +38,8 @@ recommonmark = "*" requests = "*" sentry-sdk = {extras = ["flask"],version = "==0.14.4"} sphinx = "*" -spiffworkflow = {editable = true,git = "https://github.com/sartography/SpiffWorkflow.git",ref = "master"} +#spiffworkflow = {editable = true,git = "https://github.com/sartography/SpiffWorkflow.git",ref = "master"} +spiffworkflow = {editable = true,path="/home/kelly/sartography/SpiffWorkflow/"} swagger-ui-bundle = "*" webtest = "*" werkzeug = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 6d0f9167..3f4c22bd 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "97a15c4ade88db2b384d52436633889a4d9b0bdcaeea86b8a679ebda6f73fb59" + "sha256": "3a33fc1fa48276f307b40b777df92a85e090263c11d99c6ad33fd1c79f057018" }, "pipfile-spec": 6, "requires": { @@ -427,12 +427,12 @@ }, "ldap3": { "hashes": [ + "sha256:17f04298b70bf7ecaa5db8a7d8622b5a962ef7fc2b245b2eea705ac1c24338c0", "sha256:298769ab0232b3a3efa1e84881096c24526fe37911c83a11285f222fe4975efd", - "sha256:81df4ac8b6df10fb1f05b17c18d0cb8c4c344d5a03083c382824960ed959cf5b", - "sha256:53aaae5bf14f3827c69600ddf4d61b88f49c055bb93060e9702c5bafd206c744", "sha256:4fd2db72d0412cc16ee86be01332095e86e361329c3579b314231eb2e56c7871", "sha256:52ab557b3c4908db4a90bea16731aa714b1b54e039b54fd4c4b83994c6c48c0c", - "sha256:17f04298b70bf7ecaa5db8a7d8622b5a962ef7fc2b245b2eea705ac1c24338c0" + "sha256:53aaae5bf14f3827c69600ddf4d61b88f49c055bb93060e9702c5bafd206c744", + "sha256:81df4ac8b6df10fb1f05b17c18d0cb8c4c344d5a03083c382824960ed959cf5b" ], "index": "pypi", "version": "==2.7" @@ -665,19 +665,19 @@ }, "pyasn1": { "hashes": [ - "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", + "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", + "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", + "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", + "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", + "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", - "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", - "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", - "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", - "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", - "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3", + "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", - "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12" + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", + "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", + "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" ], "version": "==0.4.8" }, @@ -735,11 +735,11 @@ }, "python-editor": { "hashes": [ - "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8", + "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", + "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8", "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77", - "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522", - "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d" + "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522" ], "version": "==1.0.4" }, @@ -929,8 +929,7 @@ }, "spiffworkflow": { "editable": true, - "git": "https://github.com/sartography/SpiffWorkflow.git", - "ref": "c72ced41e323aa69fcb6f7708e1869e98add716d" + "path": "/home/kelly/sartography/SpiffWorkflow" }, "sqlalchemy": { "hashes": [ diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index 60040a95..c59ad1a3 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -40,39 +40,43 @@ class CustomBpmnScriptEngine(BpmnScriptEngine): """ # Shlex splits the whole string while respecting double quoted strings within commands = shlex.split(script) - printable_comms = commands - path_and_command = commands[0].rsplit(".", 1) - if len(path_and_command) == 1: - module_name = "crc.scripts." + self.camel_to_snake(path_and_command[0]) - class_name = path_and_command[0] + print(commands) + if commands[0] != '#!': + super().execute_data(task,script,task.data) else: - module_name = "crc.scripts." + path_and_command[0] + "." + self.camel_to_snake(path_and_command[1]) - class_name = path_and_command[1] - try: - mod = __import__(module_name, fromlist=[class_name]) - klass = getattr(mod, class_name) - study_id = task.workflow.data[WorkflowProcessor.STUDY_ID_KEY] - if WorkflowProcessor.WORKFLOW_ID_KEY in task.workflow.data: - workflow_id = task.workflow.data[WorkflowProcessor.WORKFLOW_ID_KEY] + printable_comms = commands[1:] + path_and_command = commands[1].rsplit(".", 1) + if len(path_and_command) == 1: + module_name = "crc.scripts." + self.camel_to_snake(path_and_command[0]) + class_name = path_and_command[0] else: - workflow_id = None + module_name = "crc.scripts." + path_and_command[0] + "." + self.camel_to_snake(path_and_command[1]) + class_name = path_and_command[1] + try: + mod = __import__(module_name, fromlist=[class_name]) + klass = getattr(mod, class_name) + study_id = task.workflow.data[WorkflowProcessor.STUDY_ID_KEY] + if WorkflowProcessor.WORKFLOW_ID_KEY in task.workflow.data: + workflow_id = task.workflow.data[WorkflowProcessor.WORKFLOW_ID_KEY] + else: + workflow_id = None - if not isinstance(klass(), Script): + if not isinstance(klass(), Script): + raise ApiError.from_task("invalid_script", + "This is an internal error. The script '%s:%s' you called " % + (module_name, class_name) + + "does not properly implement the CRC Script class.", + task=task) + if task.workflow.data[WorkflowProcessor.VALIDATION_PROCESS_KEY]: + """If this is running a validation, and not a normal process, then we want to + mimic running the script, but not make any external calls or database changes.""" + klass().do_task_validate_only(task, study_id, workflow_id, *commands[2:]) + else: + klass().do_task(task, study_id, workflow_id, *commands[2:]) + except ModuleNotFoundError: raise ApiError.from_task("invalid_script", - "This is an internal error. The script '%s:%s' you called " % - (module_name, class_name) + - "does not properly implement the CRC Script class.", + "Unable to locate Script: '%s:%s'" % (module_name, class_name), task=task) - if task.workflow.data[WorkflowProcessor.VALIDATION_PROCESS_KEY]: - """If this is running a validation, and not a normal process, then we want to - mimic running the script, but not make any external calls or database changes.""" - klass().do_task_validate_only(task, study_id, workflow_id, *commands[1:]) - else: - klass().do_task(task, study_id, workflow_id, *commands[1:]) - except ModuleNotFoundError: - raise ApiError.from_task("invalid_script", - "Unable to locate Script: '%s:%s'" % (module_name, class_name), - task=task) def evaluate_expression(self, task, expression): """ diff --git a/tests/data/docx/docx.bpmn b/tests/data/docx/docx.bpmn index a95feb07..8c741114 100644 --- a/tests/data/docx/docx.bpmn +++ b/tests/data/docx/docx.bpmn @@ -1,5 +1,5 @@ - + SequenceFlow_0637d8i @@ -27,7 +27,7 @@ SequenceFlow_1i7hk1a SequenceFlow_11c35oq - CompleteTemplate Letter.docx AD_CoCApp + #! CompleteTemplate Letter.docx AD_CoCApp SequenceFlow_11c35oq diff --git a/tests/data/email/email.bpmn b/tests/data/email/email.bpmn index 1b8d5252..11ecec2e 100644 --- a/tests/data/email/email.bpmn +++ b/tests/data/email/email.bpmn @@ -1,5 +1,5 @@ - + Flow_1synsig @@ -20,7 +20,7 @@ Email content to be delivered to {{ ApprvlApprvr1 }} --- Flow_08n2npe Flow_1xlrgne - Email "Camunda Email Subject" ApprvlApprvr1 PIComputingID + #! Email "Camunda Email Subject" ApprvlApprvr1 PIComputingID diff --git a/tests/data/invalid_script/invalid_script.bpmn b/tests/data/invalid_script/invalid_script.bpmn index 80417e90..83dcfe40 100644 --- a/tests/data/invalid_script/invalid_script.bpmn +++ b/tests/data/invalid_script/invalid_script.bpmn @@ -1,5 +1,5 @@ - + SequenceFlow_1pnq3kg diff --git a/tests/data/multi_instance/multi_instance.bpmn b/tests/data/multi_instance/multi_instance.bpmn index 28bda546..c1e610a5 100644 --- a/tests/data/multi_instance/multi_instance.bpmn +++ b/tests/data/multi_instance/multi_instance.bpmn @@ -1,5 +1,5 @@ - + Flow_0t6p1sb @@ -29,7 +29,7 @@ Flow_0t6p1sb SequenceFlow_1p568pp - StudyInfo investigators + #! StudyInfo investigators diff --git a/tests/data/multi_instance_parallel/multi_instance_parallel.bpmn b/tests/data/multi_instance_parallel/multi_instance_parallel.bpmn index 9e53323f..d20c8499 100644 --- a/tests/data/multi_instance_parallel/multi_instance_parallel.bpmn +++ b/tests/data/multi_instance_parallel/multi_instance_parallel.bpmn @@ -1,5 +1,5 @@ - + Flow_0t6p1sb @@ -29,7 +29,7 @@ Flow_0t6p1sb SequenceFlow_1p568pp - StudyInfo investigators + #! StudyInfo investigators diff --git a/tests/data/random_fact/random_fact.bpmn b/tests/data/random_fact/random_fact.bpmn index fc5e41bb..d5ffcbed 100644 --- a/tests/data/random_fact/random_fact.bpmn +++ b/tests/data/random_fact/random_fact.bpmn @@ -1,5 +1,5 @@ - + SequenceFlow_0c7wlth @@ -132,7 +132,7 @@ Autoconverted link https://github.com/nodeca/pica (enable linkify to see) SequenceFlow_0641sh6 SequenceFlow_0t29gjo - FactService + #! FactService # Great Job! diff --git a/tests/workflow/test_workflow_service.py b/tests/workflow/test_workflow_service.py index 3fbd3a23..af3e0073 100644 --- a/tests/workflow/test_workflow_service.py +++ b/tests/workflow/test_workflow_service.py @@ -134,5 +134,5 @@ class TestWorkflowService(BaseTest): def test_dmn_evaluation_errors_in_oncomplete_raise_api_errors_during_validation(self): workflow_spec_model = self.load_test_spec("decision_table_invalid") - with self.assertRaises(ApiError): + with self.assertRaises(NameError): WorkflowService.test_spec(workflow_spec_model.id) \ No newline at end of file From de54b63e20faaee722d169c5b406f6efe3db8212 Mon Sep 17 00:00:00 2001 From: Kelly McDonald Date: Fri, 17 Jul 2020 10:56:04 -0400 Subject: [PATCH 093/101] Process scripts with no shebang (#!) as a regular python script. If there is a shebang, we look up the class as we did before. I've also made it so that it falls back if we accidentally forget to add a shebang to a study as this would be a breaking change. With the fallback feature, it should work with unmodified bpmn documents. --- crc/services/workflow_processor.py | 82 +++++++++++-------- crc/static/bpmn/core_info/core_info.bpmn | 4 +- .../data_security_plan.bpmn | 4 +- .../documents_approvals.bpmn | 7 +- .../bpmn/ide_supplement/ide_supplement.bpmn | 4 +- .../ids_full_submission.bpmn | 4 +- .../bpmn/ind_supplement/ind_supplement.bpmn | 4 +- .../bpmn/irb_api_details/irb_api_details.bpmn | 4 +- .../irb_api_personnel/irb_api_personnel.bpmn | 24 +++--- .../bpmn/research_rampup/research_rampup.bpmn | 6 +- .../top_level_workflow.bpmn | 6 +- tests/data/invalid_script/invalid_script.bpmn | 2 +- .../data/invalid_script2/invalid_script2.bpmn | 39 +++++++++ tests/data/study_details/study_details.bpmn | 4 +- .../top_level_workflow.bpmn | 4 +- .../test_workflow_spec_validation_api.py | 11 +++ 16 files changed, 135 insertions(+), 74 deletions(-) create mode 100644 tests/data/invalid_script2/invalid_script2.bpmn diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index c59ad1a3..4ca3f20a 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -30,7 +30,7 @@ class CustomBpmnScriptEngine(BpmnScriptEngine): Rather than execute arbitrary code, this assumes the script references a fully qualified python class such as myapp.RandomFact. """ - def execute(self, task: SpiffTask, script, **kwargs): + def execute(self, task: SpiffTask, script, data): """ Assume that the script read in from the BPMN file is a fully qualified python class. Instantiate that class, pass in any data available to the current task so that it might act on it. @@ -40,43 +40,55 @@ class CustomBpmnScriptEngine(BpmnScriptEngine): """ # Shlex splits the whole string while respecting double quoted strings within commands = shlex.split(script) - print(commands) - if commands[0] != '#!': - super().execute_data(task,script,task.data) - else: - printable_comms = commands[1:] - path_and_command = commands[1].rsplit(".", 1) - if len(path_and_command) == 1: - module_name = "crc.scripts." + self.camel_to_snake(path_and_command[0]) - class_name = path_and_command[0] - else: - module_name = "crc.scripts." + path_and_command[0] + "." + self.camel_to_snake(path_and_command[1]) - class_name = path_and_command[1] - try: - mod = __import__(module_name, fromlist=[class_name]) - klass = getattr(mod, class_name) - study_id = task.workflow.data[WorkflowProcessor.STUDY_ID_KEY] - if WorkflowProcessor.WORKFLOW_ID_KEY in task.workflow.data: - workflow_id = task.workflow.data[WorkflowProcessor.WORKFLOW_ID_KEY] - else: - workflow_id = None - if not isinstance(klass(), Script): - raise ApiError.from_task("invalid_script", - "This is an internal error. The script '%s:%s' you called " % - (module_name, class_name) + - "does not properly implement the CRC Script class.", - task=task) - if task.workflow.data[WorkflowProcessor.VALIDATION_PROCESS_KEY]: - """If this is running a validation, and not a normal process, then we want to - mimic running the script, but not make any external calls or database changes.""" - klass().do_task_validate_only(task, study_id, workflow_id, *commands[2:]) - else: - klass().do_task(task, study_id, workflow_id, *commands[2:]) - except ModuleNotFoundError: + failedOnce = False + prevError = '' + if commands[0] != '#!': + try: + super().execute(task,script,data) + except SyntaxError as e: + failedOnce = True + prevError = script + else: + commands = commands[1:] + + printable_comms = commands + path_and_command = commands[0].rsplit(".", 1) + if len(path_and_command) == 1: + module_name = "crc.scripts." + self.camel_to_snake(path_and_command[0]) + class_name = path_and_command[0] + else: + module_name = "crc.scripts." + path_and_command[0] + "." + self.camel_to_snake(path_and_command[1]) + class_name = path_and_command[1] + try: + mod = __import__(module_name, fromlist=[class_name]) + klass = getattr(mod, class_name) + study_id = task.workflow.data[WorkflowProcessor.STUDY_ID_KEY] + if WorkflowProcessor.WORKFLOW_ID_KEY in task.workflow.data: + workflow_id = task.workflow.data[WorkflowProcessor.WORKFLOW_ID_KEY] + else: + workflow_id = None + + if not isinstance(klass(), Script): raise ApiError.from_task("invalid_script", - "Unable to locate Script: '%s:%s'" % (module_name, class_name), + "This is an internal error. The script '%s:%s' you called " % + (module_name, class_name) + + "does not properly implement the CRC Script class.", + task=task) + if task.workflow.data[WorkflowProcessor.VALIDATION_PROCESS_KEY]: + """If this is running a validation, and not a normal process, then we want to + mimic running the script, but not make any external calls or database changes.""" + klass().do_task_validate_only(task, study_id, workflow_id, *commands[1:]) + else: + klass().do_task(task, study_id, workflow_id, *commands[1:]) + except ModuleNotFoundError: + if failedOnce: + raise ApiError.from_task("invalid_script", + "Script had a syntax error: '%s'" % (prevError), task=task) + raise ApiError.from_task("invalid_script", + "Unable to locate Script: '%s:%s'" % (module_name, class_name), + task=task) def evaluate_expression(self, task, expression): """ diff --git a/crc/static/bpmn/core_info/core_info.bpmn b/crc/static/bpmn/core_info/core_info.bpmn index 8e790f98..8c69ffb3 100644 --- a/crc/static/bpmn/core_info/core_info.bpmn +++ b/crc/static/bpmn/core_info/core_info.bpmn @@ -1,5 +1,5 @@ - + Flow_1wqp7vf @@ -212,7 +212,7 @@ SequenceFlow_1r3yrhy Flow_09h1imz - StudyInfo details + #! StudyInfo details Flow_09h1imz 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 0bf95e18..86426d6d 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 @@ -453,7 +453,7 @@ Indicate all the possible formats in which you will transmit your data outside o SequenceFlow_0k2r83n SequenceFlow_0t6xl9i SequenceFlow_16kyite - CompleteTemplate NEW_DSP_template.docx Study_DataSecurityPlan + #! CompleteTemplate NEW_DSP_template.docx Study_DataSecurityPlan ##### Instructions diff --git a/crc/static/bpmn/documents_approvals/documents_approvals.bpmn b/crc/static/bpmn/documents_approvals/documents_approvals.bpmn index bf39615b..12e85e34 100644 --- a/crc/static/bpmn/documents_approvals/documents_approvals.bpmn +++ b/crc/static/bpmn/documents_approvals/documents_approvals.bpmn @@ -41,8 +41,7 @@ {%- else -%} | {{doc.display_name}} | Not started | [?](/help/documents/{{doc.code}}) | No file yet | {%- endif %} -{% endif %}{% endfor %} - +{% endif %}{% endfor %} @@ -54,12 +53,12 @@ Flow_0c7ryff Flow_142jtxs - StudyInfo approvals + #! StudyInfo approvals Flow_1k3su2q Flow_0c7ryff - StudyInfo documents + #! StudyInfo documents diff --git a/crc/static/bpmn/ide_supplement/ide_supplement.bpmn b/crc/static/bpmn/ide_supplement/ide_supplement.bpmn index a886b4d4..7a83643b 100644 --- a/crc/static/bpmn/ide_supplement/ide_supplement.bpmn +++ b/crc/static/bpmn/ide_supplement/ide_supplement.bpmn @@ -1,5 +1,5 @@ - + SequenceFlow_1dhb8f4 @@ -36,7 +36,7 @@ SequenceFlow_1dhb8f4 SequenceFlow_1uzcl1f - StudyInfo details + #! StudyInfo details diff --git a/crc/static/bpmn/ids_full_submission/ids_full_submission.bpmn b/crc/static/bpmn/ids_full_submission/ids_full_submission.bpmn index 72fece25..25a9ad6e 100644 --- a/crc/static/bpmn/ids_full_submission/ids_full_submission.bpmn +++ b/crc/static/bpmn/ids_full_submission/ids_full_submission.bpmn @@ -1,5 +1,5 @@ - + SequenceFlow_1dexemq @@ -217,7 +217,7 @@ Protocol Owner: **(need to insert value here)** SequenceFlow_1dexemq Flow_1x9d2mo - StudyInfo documents + #! StudyInfo documents diff --git a/crc/static/bpmn/ind_supplement/ind_supplement.bpmn b/crc/static/bpmn/ind_supplement/ind_supplement.bpmn index b25e080b..e2a07df1 100644 --- a/crc/static/bpmn/ind_supplement/ind_supplement.bpmn +++ b/crc/static/bpmn/ind_supplement/ind_supplement.bpmn @@ -1,5 +1,5 @@ - + SequenceFlow_1dhb8f4 @@ -12,7 +12,7 @@ SequenceFlow_1dhb8f4 SequenceFlow_1uzcl1f - StudyInfo details + #! StudyInfo details diff --git a/crc/static/bpmn/irb_api_details/irb_api_details.bpmn b/crc/static/bpmn/irb_api_details/irb_api_details.bpmn index b5f0da02..b4f540f5 100644 --- a/crc/static/bpmn/irb_api_details/irb_api_details.bpmn +++ b/crc/static/bpmn/irb_api_details/irb_api_details.bpmn @@ -1,5 +1,5 @@ - + @@ -8,7 +8,7 @@ SequenceFlow_1fmyo77 SequenceFlow_18nr0gf - StudyInfo details + #! StudyInfo details diff --git a/crc/static/bpmn/irb_api_personnel/irb_api_personnel.bpmn b/crc/static/bpmn/irb_api_personnel/irb_api_personnel.bpmn index 99edb961..a5258cbe 100644 --- a/crc/static/bpmn/irb_api_personnel/irb_api_personnel.bpmn +++ b/crc/static/bpmn/irb_api_personnel/irb_api_personnel.bpmn @@ -1,5 +1,5 @@ - + Flow_0kcrx5l @@ -7,7 +7,7 @@ Flow_0kcrx5l Flow_1dcsioh - StudyInfo investigators + #! StudyInfo investigators ## The following information was gathered: @@ -54,28 +54,28 @@ - - + + - - + + - - + + - + - + - + - + diff --git a/crc/static/bpmn/research_rampup/research_rampup.bpmn b/crc/static/bpmn/research_rampup/research_rampup.bpmn index 19588731..5a4bb1bc 100644 --- a/crc/static/bpmn/research_rampup/research_rampup.bpmn +++ b/crc/static/bpmn/research_rampup/research_rampup.bpmn @@ -1,5 +1,5 @@ - + SequenceFlow_05ja25w @@ -598,7 +598,7 @@ Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/ur This step is internal to the system and do not require and user interaction Flow_11uqavk Flow_0aqgwvu - CompleteTemplate ResearchRampUpPlan.docx RESEARCH_RAMPUP + #! CompleteTemplate ResearchRampUpPlan.docx RESEARCH_RAMPUP @@ -764,7 +764,7 @@ This step is internal to the system and do not require and user interaction Flow_16y8glw Flow_0uc4o6c - UpdateStudy title:PIComputingID.label pi:PIComputingID.value + #! UpdateStudy title:PIComputingID.label pi:PIComputingID.value #### Weekly Personnel Schedule(s) diff --git a/crc/static/bpmn/top_level_workflow/top_level_workflow.bpmn b/crc/static/bpmn/top_level_workflow/top_level_workflow.bpmn index 6806fa5b..23d6ff72 100644 --- a/crc/static/bpmn/top_level_workflow/top_level_workflow.bpmn +++ b/crc/static/bpmn/top_level_workflow/top_level_workflow.bpmn @@ -11,7 +11,7 @@ SequenceFlow_1ees8ka SequenceFlow_17ct47v - StudyInfo documents + #! StudyInfo documents Flow_1m8285h @@ -62,7 +62,7 @@ Flow_0pwtiqm Flow_0eq6px2 - StudyInfo details + #! StudyInfo details Flow_14ce1d7 @@ -91,7 +91,7 @@ Flow_1qyrmzn Flow_0vo6ul1 - StudyInfo investigators + #! StudyInfo investigators diff --git a/tests/data/invalid_script/invalid_script.bpmn b/tests/data/invalid_script/invalid_script.bpmn index 83dcfe40..b85e2bc4 100644 --- a/tests/data/invalid_script/invalid_script.bpmn +++ b/tests/data/invalid_script/invalid_script.bpmn @@ -11,7 +11,7 @@ SequenceFlow_1pnq3kg SequenceFlow_12pf6um - NoSuchScript withArg1 + #! NoSuchScript withArg1 diff --git a/tests/data/invalid_script2/invalid_script2.bpmn b/tests/data/invalid_script2/invalid_script2.bpmn new file mode 100644 index 00000000..b061e76c --- /dev/null +++ b/tests/data/invalid_script2/invalid_script2.bpmn @@ -0,0 +1,39 @@ + + + + + SequenceFlow_1pnq3kg + + + + SequenceFlow_12pf6um + + + SequenceFlow_1pnq3kg + SequenceFlow_12pf6um + a really bad error that should fail + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/study_details/study_details.bpmn b/tests/data/study_details/study_details.bpmn index b9aead94..2b46f935 100644 --- a/tests/data/study_details/study_details.bpmn +++ b/tests/data/study_details/study_details.bpmn @@ -1,5 +1,5 @@ - + SequenceFlow_1nfe5m9 @@ -8,7 +8,7 @@ SequenceFlow_1nfe5m9 SequenceFlow_1bqiin0 - StudyInfo info + #! StudyInfo info diff --git a/tests/data/top_level_workflow/top_level_workflow.bpmn b/tests/data/top_level_workflow/top_level_workflow.bpmn index cc6e1c57..8b1bb888 100644 --- a/tests/data/top_level_workflow/top_level_workflow.bpmn +++ b/tests/data/top_level_workflow/top_level_workflow.bpmn @@ -1,5 +1,5 @@ - + SequenceFlow_1ees8ka @@ -11,7 +11,7 @@ SequenceFlow_1ees8ka SequenceFlow_17ct47v - StudyInfo documents + #! StudyInfo documents Flow_1m8285h diff --git a/tests/workflow/test_workflow_spec_validation_api.py b/tests/workflow/test_workflow_spec_validation_api.py index d79986cf..597c4aa1 100644 --- a/tests/workflow/test_workflow_spec_validation_api.py +++ b/tests/workflow/test_workflow_spec_validation_api.py @@ -98,6 +98,17 @@ class TestWorkflowSpecValidation(BaseTest): self.assertEqual("An Invalid Script Reference", errors[0]['task_name']) self.assertEqual("invalid_script.bpmn", errors[0]['file_name']) + def test_invalid_script2(self): + self.load_example_data() + errors = self.validate_workflow("invalid_script2") + self.assertEqual(2, len(errors)) + self.assertEqual("error_loading_workflow", errors[0]['code']) + self.assertTrue("syntax error" in errors[0]['message']) + self.assertEqual("Invalid_Script_Task", errors[0]['task_id']) + self.assertEqual("An Invalid Script Reference", errors[0]['task_name']) + self.assertEqual("invalid_script2.bpmn", errors[0]['file_name']) + + def test_repeating_sections_correctly_populated(self): self.load_example_data() spec_model = self.load_test_spec('repeat_form') From e82532aad8d1792bac64c622869a74695b743d7b Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 17 Jul 2020 11:51:21 -0400 Subject: [PATCH 094/101] Updates IND workflow spec. Fixes validation process to return lookups for enum values. --- crc/services/workflow_service.py | 31 +- .../ind_supplement/decision_ind_check.dmn | 96 ------ .../bpmn/ind_supplement/ind_supplement.bpmn | 127 -------- crc/static/bpmn/ind_update/SponsorList.xls | Bin 0 -> 501760 bytes .../bpmn/ind_update/decision_ind_check.dmn | 220 ++++++++++++++ crc/static/bpmn/ind_update/ind_update.bpmn | 276 ++++++++++++++++++ example_data.py | 4 +- tests/workflow/test_workflow_service.py | 2 + .../test_workflow_spec_validation_api.py | 2 + 9 files changed, 525 insertions(+), 233 deletions(-) delete mode 100644 crc/static/bpmn/ind_supplement/decision_ind_check.dmn delete mode 100644 crc/static/bpmn/ind_supplement/ind_supplement.bpmn create mode 100644 crc/static/bpmn/ind_update/SponsorList.xls create mode 100644 crc/static/bpmn/ind_update/decision_ind_check.dmn create mode 100644 crc/static/bpmn/ind_update/ind_update.bpmn diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 74d70408..c481a0a8 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -143,20 +143,37 @@ class WorkflowService(object): @staticmethod def get_random_data_for_field(field, task): - if field.type == "enum": + has_ldap_lookup = field.has_property(Task.PROP_LDAP_LOOKUP) + has_file_lookup = field.has_property(Task.PROP_OPTIONS_FILE_NAME) + has_data_lookup = field.has_property(Task.PROP_OPTIONS_DATA_NAME) + has_lookup = has_ldap_lookup or has_file_lookup or has_data_lookup + + if field.type == "enum" and not has_lookup: + # If it's a normal enum field with no lookup, + # return a random option. if len(field.options) > 0: random_choice = random.choice(field.options) if isinstance(random_choice, dict): - return random.choice(field.options)['id'] + choice = random.choice(field.options) + return { + 'value': choice['id'], + 'label': choice['name'] + } else: # fixme: why it is sometimes an EnumFormFieldOption, and other times not? - return random_choice.id ## Assume it is an EnumFormFieldOption + # Assume it is an EnumFormFieldOption + return { + 'value': random_choice.id, + 'label': random_choice.name + } else: raise ApiError.from_task("invalid_enum", "You specified an enumeration field (%s)," " with no options" % field.id, task) - elif field.type == "autocomplete": + elif field.type == "autocomplete" or field.type == "enum": + # If it has a lookup, get the lookup model from the spreadsheet or task data, then return a random option + # from the lookup model lookup_model = LookupService.get_lookup_model(task, field) - if field.has_property(Task.PROP_LDAP_LOOKUP): # All ldap records get the same person. + if has_ldap_lookup: # All ldap records get the same person. return { "label": "dhf8r", "value": "Dan Funk", @@ -172,9 +189,7 @@ class WorkflowService(object): elif lookup_model: data = db.session.query(LookupDataModel).filter( LookupDataModel.lookup_file_model == lookup_model).limit(10).all() - options = [] - for d in data: - options.append({"id": d.value, "label": d.label}) + options = [{"value": d.value, "label": d.label, "data": d.data} for d in data] return random.choice(options) else: raise ApiError.from_task("unknown_lookup_option", "The settings for this auto complete field " diff --git a/crc/static/bpmn/ind_supplement/decision_ind_check.dmn b/crc/static/bpmn/ind_supplement/decision_ind_check.dmn deleted file mode 100644 index 9104121b..00000000 --- a/crc/static/bpmn/ind_supplement/decision_ind_check.dmn +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - StudyInfo.details.IS_IND - - - - - StudyInfo.details.IND_1 - - - - - StudyInfo.details.IND_2 - - - - - StudyInfo.details.IND_3 - - - - - - 1 - - - not('') - - - - - - - - - true - - - - - 1 - - - - - - not('') - - - - - - true - - - - - 1 - - - - - - - - - not('') - - - true - - - - - - - - - - - - - - - - - false - - - - - diff --git a/crc/static/bpmn/ind_supplement/ind_supplement.bpmn b/crc/static/bpmn/ind_supplement/ind_supplement.bpmn deleted file mode 100644 index b25e080b..00000000 --- a/crc/static/bpmn/ind_supplement/ind_supplement.bpmn +++ /dev/null @@ -1,127 +0,0 @@ - - - - - SequenceFlow_1dhb8f4 - - - - SequenceFlow_1yhv1qz - SequenceFlow_1enco3g - - - SequenceFlow_1dhb8f4 - SequenceFlow_1uzcl1f - StudyInfo details - - - - SequenceFlow_1lazou8 - SequenceFlow_1yb1vma - SequenceFlow_011l5xt - - - ind_supplement == True - - - ind_supplement == False - - - The use of an Investigational New Drug (IND) was indicated in Protocol Builder, but no IND number was entered. Please enter up to three numbers in the Supplemental section of Protocol Builder so supplemental information can be entered here. - SequenceFlow_011l5xt - SequenceFlow_1yhv1qz - - - - SequenceFlow_1uzcl1f - SequenceFlow_1lazou8 - - - - IND No.: {{StudyInfo.details.IND_1}} - - - - - - - - - - - - - - - - SequenceFlow_1yb1vma - SequenceFlow_1enco3g - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/crc/static/bpmn/ind_update/SponsorList.xls b/crc/static/bpmn/ind_update/SponsorList.xls new file mode 100644 index 0000000000000000000000000000000000000000..df1cf0d7886ebd323e51bf5aa4c2ca1473e847c5 GIT binary patch literal 501760 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 z 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 KDOo}6s_U1oWNNj>{jx4?X5AQ0*Ab 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 literal 0 HcmV?d00001 diff --git a/crc/static/bpmn/ind_update/decision_ind_check.dmn b/crc/static/bpmn/ind_update/decision_ind_check.dmn new file mode 100644 index 00000000..658f02ab --- /dev/null +++ b/crc/static/bpmn/ind_update/decision_ind_check.dmn @@ -0,0 +1,220 @@ + + + + + + + StudyInfo.details.IS_IND + + + + + StudyInfo.details.IND_1 + + + + + StudyInfo.details.IND_2 + + + + + StudyInfo.details.IND_3 + + + + + + + + 3 IND #s + + 1 + + + not('') + + + not('') + + + not('') + + + true + + + "three" + + + "Three IND #s entered" + + + + + + + 2 IND #s + + 1 + + + not('') + + + not('') + + + "" + + + true + + + "two" + + + "Two IND #s entered" + + + + + + + 3 IND#s, missing #2 + + 1 + + + not('') + + + "" + + + not('') + + + true + + + "two" + + + "Two IND #s entered" + + + + + + + 3 IND#s, missing #1 + + 1 + + + "" + + + not('') + + + not('') + + + true + + + "two" + + + "Two IND #s entered" + + + + + + + 1 IND # + + 1 + + + not('') + + + "" + + + "" + + + true + + + "one" + + + "One IND # entered" + + + StudyInfo.details.IND_1 + + + + No + + 1 + + + + + + + + + + + + true + + + "na" + + + "No IND Numbers Entered in PB" + + + "" + + + + No IND, PB Q#56 answered as No, should not be needed, but here as stopgap in case memu check failed + + 0 + + + + + + + + + + + + false + + + + + + + + + + + + + + diff --git a/crc/static/bpmn/ind_update/ind_update.bpmn b/crc/static/bpmn/ind_update/ind_update.bpmn new file mode 100644 index 00000000..15845111 --- /dev/null +++ b/crc/static/bpmn/ind_update/ind_update.bpmn @@ -0,0 +1,276 @@ + + + + + SequenceFlow_1dhb8f4 + + + + Flow_0jqdolk + Flow_OneOnly + + + SequenceFlow_1dhb8f4 + SequenceFlow_1uzcl1f + StudyInfo details + + + + SequenceFlow_1uzcl1f + SequenceFlow_1cwibmt + + + IND No.: {{ StudyInfo.details.IND_1 }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Flow_1bn0jp7 + Flow_10rb7gb + + + IND No.: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Flow_TwoOrThree + Flow_1p563xr + + + IND No.: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Flow_1p563xr + Flow_0jqdolk + + + + {{ ind_message }} + + + + + + + + + + + + + + + + + + + SequenceFlow_1cwibmt + Flow_1bn0jp7 + + + + + + + Flow_10rb7gb + Flow_TwoOrThree + Flow_OneOnly + + + IND_CntEntered != "value_one" + + + IND_CntEntered == "value_one" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example_data.py b/example_data.py index 98746c50..efdfe3b3 100644 --- a/example_data.py +++ b/example_data.py @@ -93,8 +93,8 @@ class ExampleDataLoader: description="Supplemental information for the IDE number entered in Protocol Builder", category_id=0, display_order=3) - self.create_spec(id="ind_supplement", - name="ind_supplement", + self.create_spec(id="ind_update", + name="ind_update", display_name="IND Supplement Info", description="Supplement information for the Investigational New Drug(s) specified in Protocol Builder", category_id=0, diff --git a/tests/workflow/test_workflow_service.py b/tests/workflow/test_workflow_service.py index 748dcedc..f208eecb 100644 --- a/tests/workflow/test_workflow_service.py +++ b/tests/workflow/test_workflow_service.py @@ -1,4 +1,5 @@ import json +import unittest from tests.base_test import BaseTest @@ -88,6 +89,7 @@ class TestWorkflowService(BaseTest): WorkflowService.populate_form_with_random_data(task, task_api, required_only=False) self.assertTrue(isinstance(task.data["sponsor"], dict)) + @unittest.skip("RRT no longer needs to be supported") def test_fix_legacy_data_model_for_rrt(self): ExampleDataLoader().load_rrt() # Make sure the research_rampup is loaded, as it's not a test spec. workflow = self.create_workflow('research_rampup') diff --git a/tests/workflow/test_workflow_spec_validation_api.py b/tests/workflow/test_workflow_spec_validation_api.py index d79986cf..2a5b5455 100644 --- a/tests/workflow/test_workflow_spec_validation_api.py +++ b/tests/workflow/test_workflow_spec_validation_api.py @@ -1,4 +1,5 @@ import json +import unittest from unittest.mock import patch from tests.base_test import BaseTest @@ -51,6 +52,7 @@ class TestWorkflowSpecValidation(BaseTest): app.config['PB_ENABLED'] = True self.validate_all_loaded_workflows() + @unittest.skip("RRT no longer needs to be supported") def test_successful_validation_of_rrt_workflows(self): self.load_example_data(use_rrt_data=True) self.validate_all_loaded_workflows() From fcb772c90022ff79336d4fc43ef290e19406b2ca Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Sun, 19 Jul 2020 16:40:33 -0600 Subject: [PATCH 095/101] Reporting to Sentry all captured exceptions and enabling multiple environments --- config/default.py | 3 ++- crc/__init__.py | 3 ++- crc/api/common.py | 2 ++ crc/services/workflow_service.py | 2 +- tests/workflow/test_workflow_service.py | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/config/default.py b/config/default.py index 5c8f8c51..b295bf4b 100644 --- a/config/default.py +++ b/config/default.py @@ -15,7 +15,8 @@ TEST_UID = environ.get('TEST_UID', default="dhf8r") ADMIN_UIDS = re.split(r',\s*', environ.get('ADMIN_UIDS', default="dhf8r,ajl2j,cah3us,cl3wf")) # Sentry flag -ENABLE_SENTRY = environ.get('ENABLE_SENTRY', default="false") == "true" +ENABLE_SENTRY = environ.get('ENABLE_SENTRY', default="false") == "true" # To be removed soon +SENTRY_ENVIRONMENT = environ.get('SENTRY_ENVIRONMENT', None) # Add trailing slash to base path APPLICATION_ROOT = re.sub(r'//', '/', '/%s/' % environ.get('APPLICATION_ROOT', default="/").strip('/')) diff --git a/crc/__init__.py b/crc/__init__.py index d56085d0..c3fff567 100644 --- a/crc/__init__.py +++ b/crc/__init__.py @@ -52,8 +52,9 @@ origins_re = [r"^https?:\/\/%s(.*)" % o.replace('.', '\.') for o in app.config[' cors = CORS(connexion_app.app, origins=origins_re) # Sentry error handling -if app.config['ENABLE_SENTRY']: +if app.config['SENTRY_ENVIRONMENT']: sentry_sdk.init( + environment=app.config['SENTRY_ENVIRONMENT'], dsn="https://25342ca4e2d443c6a5c49707d68e9f40@o401361.ingest.sentry.io/5260915", integrations=[FlaskIntegration()] ) diff --git a/crc/api/common.py b/crc/api/common.py index f8673a5b..cb527c73 100644 --- a/crc/api/common.py +++ b/crc/api/common.py @@ -25,6 +25,7 @@ class ApiError(Exception): instance.task_name = task.task_spec.description or "" instance.file_name = task.workflow.spec.file or "" instance.task_data = task.data + app.logger.error(message, exc_info=True) return instance @classmethod @@ -35,6 +36,7 @@ class ApiError(Exception): instance.task_name = task_spec.description or "" if task_spec._wf_spec: instance.file_name = task_spec._wf_spec.file + app.logger.error(message, exc_info=True) return instance @classmethod diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index c481a0a8..a2d31f36 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -376,7 +376,7 @@ class WorkflowService(object): try: task.title = spiff_task.workflow.script_engine.evaluate_expression(spiff_task, task.properties['display_name']) except Exception as e: - app.logger.info("Failed to set title on task due to type error." + str(e)) + app.logger.error("Failed to set title on task due to type error." + str(e), exc_info=True) elif task.title and ' ' in task.title: task.title = task.title.partition(' ')[2] return task diff --git a/tests/workflow/test_workflow_service.py b/tests/workflow/test_workflow_service.py index f208eecb..d42601eb 100644 --- a/tests/workflow/test_workflow_service.py +++ b/tests/workflow/test_workflow_service.py @@ -136,4 +136,4 @@ class TestWorkflowService(BaseTest): def test_dmn_evaluation_errors_in_oncomplete_raise_api_errors_during_validation(self): workflow_spec_model = self.load_test_spec("decision_table_invalid") with self.assertRaises(ApiError): - WorkflowService.test_spec(workflow_spec_model.id) \ No newline at end of file + WorkflowService.test_spec(workflow_spec_model.id) From f415f22ccb1c83e819ddfbb77144192113fcedbc Mon Sep 17 00:00:00 2001 From: Kelly McDonald Date: Mon, 20 Jul 2020 10:12:15 -0400 Subject: [PATCH 096/101] Add warning message when we fail due to syntax error and then we try to look up the class as a backup --- crc/services/workflow_processor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index 4ca3f20a..e02957d9 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -23,6 +23,7 @@ from crc.models.file import FileDataModel, FileModel, FileType from crc.models.workflow import WorkflowStatus, WorkflowModel, WorkflowSpecDependencyFile from crc.scripts.script import Script from crc.services.file_service import FileService +from crc import app class CustomBpmnScriptEngine(BpmnScriptEngine): @@ -49,6 +50,8 @@ class CustomBpmnScriptEngine(BpmnScriptEngine): except SyntaxError as e: failedOnce = True prevError = script + app.logger.warning('We experienced a syntax error, but we are going to try the old method on ' + '"%s"'%script) else: commands = commands[1:] From 06430550c81348555da2104738d3f2c12392e258 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Mon, 20 Jul 2020 11:39:50 -0400 Subject: [PATCH 097/101] Dropping the RRT-Data-Fix, it should have come out already, but had a failing test, so pulling it out now rather than delve into what is going wrong with obsolete code. --- Pipfile | 4 +- Pipfile.lock | 88 +++++-------------------- crc/__init__.py | 6 -- crc/services/workflow_processor.py | 4 +- crc/services/workflow_service.py | 35 ---------- docker_run.sh | 6 -- tests/workflow/test_workflow_service.py | 46 +------------ 7 files changed, 19 insertions(+), 170 deletions(-) diff --git a/Pipfile b/Pipfile index 9af4c496..6b28197a 100644 --- a/Pipfile +++ b/Pipfile @@ -38,8 +38,8 @@ recommonmark = "*" requests = "*" sentry-sdk = {extras = ["flask"],version = "==0.14.4"} sphinx = "*" -#spiffworkflow = {editable = true,git = "https://github.com/sartography/SpiffWorkflow.git",ref = "master"} -spiffworkflow = {editable = true,path="/home/kelly/sartography/SpiffWorkflow/"} +spiffworkflow = {editable = true,git = "https://github.com/sartography/SpiffWorkflow.git",ref = "master"} +#spiffworkflow = {editable = true,path="/home/kelly/sartography/SpiffWorkflow/"} swagger-ui-bundle = "*" webtest = "*" werkzeug = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 3f4c22bd..ff3840b4 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "3a33fc1fa48276f307b40b777df92a85e090263c11d99c6ad33fd1c79f057018" + "sha256": "97a15c4ade88db2b384d52436633889a4d9b0bdcaeea86b8a679ebda6f73fb59" }, "pipfile-spec": 6, "requires": { @@ -35,7 +35,6 @@ "sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b", "sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.6.0" }, "aniso8601": { @@ -50,7 +49,6 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "babel": { @@ -58,7 +56,6 @@ "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.0" }, "bcrypt": { @@ -82,7 +79,6 @@ "sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7", "sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.1.7" }, "beautifulsoup4": { @@ -111,7 +107,6 @@ "sha256:ef17d7dffde7fc73ecab3a3b6389d93d3213bac53fa7f28e68e33647ad50b916", "sha256:fd77e4248bb1b7af5f7922dd8e81156f540306e3a5c4b1c24167c1f5f06025da" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.4.6" }, "certifi": { @@ -166,7 +161,6 @@ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "clickclick": { @@ -188,7 +182,6 @@ "sha256:2ca44140ee259b5e3d8aaf47c79c36a7ab0d5e94d70bd4105c03ede7a20ea5a1", "sha256:cffc044844040c7ce04e9acd1838b5f2e5fa3170182f6fda4d2ea8b0099dbadd" ], - "markers": "python_version >= '3.6'", "version": "==5.0.0" }, "connexion": { @@ -247,7 +240,6 @@ "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==0.16" }, "docxtpl": { @@ -330,14 +322,12 @@ "sha256:05b31d2034dd3f2a685cbbae4cfc4ed906b2a733cff7964ada450fd5e462b84e", "sha256:bfc7150eaf809b1c283879302f04c42791136060c6eeb12c0c6674fb1291fae5" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.4" }, "future": { "hashes": [ "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.18.2" }, "gunicorn": { @@ -360,7 +350,6 @@ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, "imagesize": { @@ -368,7 +357,6 @@ "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.2.0" }, "importlib-metadata": { @@ -384,7 +372,6 @@ "sha256:88b101b2668a1d81d6d72d4c2018e53bc6c7fc544c987849da1c7f77545c3bc9", "sha256:f576e85132d34f5bf7df5183c2c6f94cfb32e528f53065345cf71329ba0b8924" ], - "markers": "python_version >= '3.5'", "version": "==0.5.0" }, "itsdangerous": { @@ -392,7 +379,6 @@ "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.0" }, "jdcal": { @@ -407,7 +393,6 @@ "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.2" }, "jsonschema": { @@ -422,16 +407,11 @@ "sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a", "sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.6.11" }, "ldap3": { "hashes": [ "sha256:17f04298b70bf7ecaa5db8a7d8622b5a962ef7fc2b245b2eea705ac1c24338c0", - "sha256:298769ab0232b3a3efa1e84881096c24526fe37911c83a11285f222fe4975efd", - "sha256:4fd2db72d0412cc16ee86be01332095e86e361329c3579b314231eb2e56c7871", - "sha256:52ab557b3c4908db4a90bea16731aa714b1b54e039b54fd4c4b83994c6c48c0c", - "sha256:53aaae5bf14f3827c69600ddf4d61b88f49c055bb93060e9702c5bafd206c744", "sha256:81df4ac8b6df10fb1f05b17c18d0cb8c4c344d5a03083c382824960ed959cf5b" ], "index": "pypi", @@ -443,6 +423,7 @@ "sha256:08fc93257dcfe9542c0a6883a25ba4971d78297f63d7a5a26ffa34861ca78730", "sha256:107781b213cf7201ec3806555657ccda67b1fccc4261fb889ef7fc56976db81f", "sha256:121b665b04083a1e85ff1f5243d4a93aa1aaba281bc12ea334d5a187278ceaf1", + "sha256:1fa21263c3aba2b76fd7c45713d4428dbcc7644d73dcf0650e9d344e433741b3", "sha256:2b30aa2bcff8e958cd85d907d5109820b01ac511eae5b460803430a7404e34d7", "sha256:4b4a111bcf4b9c948e020fd207f915c24a6de3f1adc7682a2d92660eb4e84f1a", "sha256:5591c4164755778e29e69b86e425880f852464a21c7bb53c7ea453bbe2633bbe", @@ -453,6 +434,7 @@ "sha256:786aad2aa20de3dbff21aab86b2fb6a7be68064cbbc0219bde414d3a30aa47ae", "sha256:7ad7906e098ccd30d8f7068030a0b16668ab8aa5cda6fcd5146d8d20cbaa71b5", "sha256:80a38b188d20c0524fe8959c8ce770a8fdf0e617c6912d23fc97c68301bb9aba", + "sha256:8f0ec6b9b3832e0bd1d57af41f9238ea7709bbd7271f639024f2fc9d3bb01293", "sha256:92282c83547a9add85ad658143c76a64a8d339028926d7dc1998ca029c88ea6a", "sha256:94150231f1e90c9595ccc80d7d2006c61f90a5995db82bccbca7944fd457f0f6", "sha256:9dc9006dcc47e00a8a6a029eb035c8f696ad38e40a27d073a003d7d1443f5d88", @@ -460,8 +442,10 @@ "sha256:aa8eba3db3d8761db161003e2d0586608092e217151d7458206e243be5a43843", "sha256:bea760a63ce9bba566c23f726d72b3c0250e2fa2569909e2d83cda1534c79443", "sha256:c3f511a3c58676147c277eff0224c061dd5a6a8e1373572ac817ac6324f1b1e0", + "sha256:c9d317efde4bafbc1561509bfa8a23c5cab66c44d49ab5b63ff690f5159b2304", "sha256:cc411ad324a4486b142c41d9b2b6a722c534096963688d879ea6fa8a35028258", "sha256:cdc13a1682b2a6241080745b1953719e7fe0850b40a5c71ca574f090a1391df6", + "sha256:cfd7c5dd3c35c19cec59c63df9571c67c6d6e5c92e0fe63517920e97f61106d1", "sha256:e1cacf4796b20865789083252186ce9dc6cc59eca0c2e79cca332bdff24ac481", "sha256:e70d4e467e243455492f5de463b72151cc400710ac03a0678206a5f27e79ddef", "sha256:ecc930ae559ea8a43377e8b60ca6f8d61ac532fc57efb915d899de4a67928efd", @@ -475,7 +459,6 @@ "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27", "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.3" }, "markdown": { @@ -522,16 +505,15 @@ "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "marshmallow": { "hashes": [ - "sha256:0f3a630f6a2fd124929f1bdcb5df65bd14cc8f49f52a18d0bdcfa0c42414e4a7", - "sha256:ba949379cb6ef73655f72075e82b31cf57012a5557ede642fc8614ab0354f869" + "sha256:67bf4cae9d3275b3fc74bd7ff88a7c98ee8c57c94b251a67b031dc293ecc4b76", + "sha256:a2a5eefb4b75a3b43f05be1cca0b6686adf56af7465c3ca629e5ad8d1e1fe13d" ], "index": "pypi", - "version": "==3.7.0" + "version": "==3.7.1" }, "marshmallow-enum": { "hashes": [ @@ -578,7 +560,6 @@ "sha256:df1889701e2dfd8ba4dc9b1a010f0a60950077fb5242bb92c8b5c7f1a6f2668a", "sha256:fa1fe75b4a9e18b66ae7f0b122543c42debcf800aaafa0212aaff3ad273c2596" ], - "markers": "python_version >= '3.6'", "version": "==1.19.0" }, "openapi-spec-validator": { @@ -602,7 +583,6 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pandas": { @@ -665,19 +645,8 @@ }, "pyasn1": { "hashes": [ - "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", - "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", - "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", - "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", - "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", - "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", - "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", - "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", - "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", - "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba" ], "version": "==0.4.8" }, @@ -686,7 +655,6 @@ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.20" }, "pygments": { @@ -694,7 +662,6 @@ "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" ], - "markers": "python_version >= '3.5'", "version": "==2.6.1" }, "pyjwt": { @@ -710,7 +677,6 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pyrsistent": { @@ -737,9 +703,7 @@ "hashes": [ "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", - "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8", - "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77", - "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522" + "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8" ], "version": "==1.0.4" }, @@ -853,7 +817,6 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "snowballstemmer": { @@ -868,7 +831,6 @@ "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" ], - "markers": "python_version >= '3.5'", "version": "==2.0.1" }, "sphinx": { @@ -884,7 +846,6 @@ "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" ], - "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-devhelp": { @@ -892,7 +853,6 @@ "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" ], - "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-htmlhelp": { @@ -900,7 +860,6 @@ "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" ], - "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-jsmath": { @@ -908,7 +867,6 @@ "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" ], - "markers": "python_version >= '3.5'", "version": "==1.0.1" }, "sphinxcontrib-qthelp": { @@ -916,7 +874,6 @@ "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" ], - "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-serializinghtml": { @@ -924,12 +881,12 @@ "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc", "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a" ], - "markers": "python_version >= '3.5'", "version": "==1.1.4" }, "spiffworkflow": { "editable": true, - "path": "/home/kelly/sartography/SpiffWorkflow" + "git": "https://github.com/sartography/SpiffWorkflow.git", + "ref": "161b3e2ac62a824c6b771e9b817a2bd477af0d17" }, "sqlalchemy": { "hashes": [ @@ -962,24 +919,21 @@ "sha256:f57be5673e12763dd400fea568608700a63ce1c6bd5bdbc3cc3a2c5fdb045274", "sha256:fc728ece3d5c772c196fd338a99798e7efac7a04f9cb6416299a3638ee9a94cd" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.18" }, "swagger-ui-bundle": { "hashes": [ - "sha256:49d2e12d60a6499e9d37ea37953b5d700f4e114edc7520fe918bae5eb693a20e", - "sha256:c5373b683487b1b914dccd23bcd9a3016afa2c2d1cda10f8713c0a9af0f91dd3", - "sha256:f776811855092c086dbb08216c8810a84accef8c76c796a135caa13645c5cc68" + "sha256:f5255f786cde67a2638111f4a7d04355836743198a83c4ecbe815d9fc384b0c8", + "sha256:f5691167f2e9f73ecbe8229a89454ae5ea958f90bb0d4583ed7adaae598c4122" ], "index": "pypi", - "version": "==0.0.6" + "version": "==0.0.8" }, "urllib3": { "hashes": [ "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==1.25.9" }, "vine": { @@ -987,7 +941,6 @@ "sha256:133ee6d7a9016f177ddeaf191c1f58421a1dcc6ee9a42c58b34bed40e1d2cd87", "sha256:ea4947cc56d1fd6f2095c8d543ee25dad966f78692528e68b4fada11ba3f98af" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.0" }, "waitress": { @@ -995,7 +948,6 @@ "sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261", "sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==1.4.4" }, "webob": { @@ -1003,7 +955,6 @@ "sha256:a3c89a8e9ba0aeb17382836cdb73c516d0ecf6630ec40ec28288f3ed459ce87b", "sha256:aa3a917ed752ba3e0b242234b2a373f9c4e2a75d35291dcbe977649bd21fd108" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.8.6" }, "webtest": { @@ -1050,7 +1001,6 @@ "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" ], - "markers": "python_version >= '3.6'", "version": "==3.1.0" } }, @@ -1060,7 +1010,6 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "coverage": { @@ -1116,7 +1065,6 @@ "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" ], - "markers": "python_version >= '3.5'", "version": "==8.4.0" }, "packaging": { @@ -1124,7 +1072,6 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pbr": { @@ -1140,7 +1087,6 @@ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.13.1" }, "py": { @@ -1148,7 +1094,6 @@ "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.9.0" }, "pyparsing": { @@ -1156,7 +1101,6 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pytest": { @@ -1172,7 +1116,6 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "wcwidth": { @@ -1187,7 +1130,6 @@ "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" ], - "markers": "python_version >= '3.6'", "version": "==3.1.0" } } diff --git a/crc/__init__.py b/crc/__init__.py index d56085d0..d293975f 100644 --- a/crc/__init__.py +++ b/crc/__init__.py @@ -91,9 +91,3 @@ def clear_db(): from example_data import ExampleDataLoader ExampleDataLoader.clean_db() -@app.cli.command() -def rrt_data_fix(): - """Finds all the empty task event logs, and populates - them with good wholesome data.""" - from crc.services.workflow_service import WorkflowService - WorkflowService.fix_legacy_data_model_for_rrt() diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index 4ca3f20a..dbc18dae 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -17,7 +17,7 @@ from SpiffWorkflow.dmn.parser.BpmnDmnParser import BpmnDmnParser from SpiffWorkflow.exceptions import WorkflowTaskExecException from SpiffWorkflow.specs import WorkflowSpec -from crc import session +from crc import session, app from crc.api.common import ApiError from crc.models.file import FileDataModel, FileModel, FileType from crc.models.workflow import WorkflowStatus, WorkflowModel, WorkflowSpecDependencyFile @@ -40,7 +40,6 @@ class CustomBpmnScriptEngine(BpmnScriptEngine): """ # Shlex splits the whole string while respecting double quoted strings within commands = shlex.split(script) - failedOnce = False prevError = '' if commands[0] != '#!': @@ -52,7 +51,6 @@ class CustomBpmnScriptEngine(BpmnScriptEngine): else: commands = commands[1:] - printable_comms = commands path_and_command = commands[0].rsplit(".", 1) if len(path_and_command) == 1: module_name = "crc.scripts." + self.camel_to_snake(path_and_command[0]) diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index de6cf1c7..8af09dc0 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -449,41 +449,6 @@ class WorkflowService(object): db.session.add(task_event) db.session.commit() - @staticmethod - def fix_legacy_data_model_for_rrt(): - """ Remove this after use! This is just to fix RRT so the data is handled correctly. - - Utility that is likely called via the flask command line, it will loop through all the - workflows in the system and attempt to add the right data into the task action log so that - users do not have to re fill out all of the forms if they start over or go back in the workflow. - Viciously inefficient, but should only have to run one time for RRT""" - workflows = db.session.query(WorkflowModel).all() - for workflow_model in workflows: - task_logs = db.session.query(TaskEventModel) \ - .filter(TaskEventModel.workflow_id == workflow_model.id) \ - .filter(TaskEventModel.action == WorkflowService.TASK_ACTION_COMPLETE) \ - .order_by(TaskEventModel.date.desc()).all() - - processor = WorkflowProcessor(workflow_model) - # Grab all the data from last task completed, which will be everything in this - # rrt situation because of how we were keeping all the data at the time. - latest_data = processor.next_task().data - - # Move forward in the task spec tree, dropping any data that would have been - # added in subsequent tasks, just looking at form data, will not track the automated - # task data additions, hopefully this doesn't hang us. - for log in task_logs: -# if log.task_data is not None: # Only do this if the task event does not have data populated in it. -# continue - data = copy.deepcopy(latest_data) # Or you end up with insane crazy issues. - # In the simple case of RRT, there is exactly one task for the given task_spec - task = processor.bpmn_workflow.get_tasks_from_spec_name(log.task_name)[0] - data = WorkflowService.extract_form_data(data, task) - log.form_data = data - db.session.add(log) - - db.session.commit() - @staticmethod def extract_form_data(latest_data, task): """Removes data from latest_data that would be added by the child task or any of its children.""" diff --git a/docker_run.sh b/docker_run.sh index 8ad66274..ec80bb99 100755 --- a/docker_run.sh +++ b/docker_run.sh @@ -23,12 +23,6 @@ if [ "$RESET_DB_RRT" = "true" ]; then pipenv run flask load-example-rrt-data fi -if [ "$FIX_RRT_DATA" = "true" ]; then - echo 'Fixing RRT data...' - pipenv run flask rrt-data-fix -fi - - # THIS MUST BE THE LAST COMMAND! if [ "$APPLICATION_ROOT" = "/" ]; then pipenv run gunicorn --bind 0.0.0.0:$PORT0 wsgi:app diff --git a/tests/workflow/test_workflow_service.py b/tests/workflow/test_workflow_service.py index af3e0073..aa11a371 100644 --- a/tests/workflow/test_workflow_service.py +++ b/tests/workflow/test_workflow_service.py @@ -88,51 +88,7 @@ class TestWorkflowService(BaseTest): WorkflowService.populate_form_with_random_data(task, task_api, required_only=False) self.assertTrue(isinstance(task.data["sponsor"], dict)) - def test_fix_legacy_data_model_for_rrt(self): - ExampleDataLoader().load_rrt() # Make sure the research_rampup is loaded, as it's not a test spec. - workflow = self.create_workflow('research_rampup') - processor = WorkflowProcessor(workflow, validate_only=True) - - # Use the test spec code to complete the workflow of research rampup. - while not processor.bpmn_workflow.is_completed(): - processor.bpmn_workflow.do_engine_steps() - tasks = processor.bpmn_workflow.get_tasks(SpiffTask.READY) - for task in tasks: - task_api = WorkflowService.spiff_task_to_api_task(task, add_docs_and_forms=True) - WorkflowService.populate_form_with_random_data(task, task_api, False) - task.complete() - # create the task events - WorkflowService.log_task_action('dhf8r', workflow, task, - WorkflowService.TASK_ACTION_COMPLETE, - version=processor.get_version_string()) - processor.save() - db.session.commit() - - WorkflowService.fix_legacy_data_model_for_rrt() - - # All tasks should now have data associated with them. - task_logs = db.session.query(TaskEventModel) \ - .filter(TaskEventModel.workflow_id == workflow.id) \ - .filter(TaskEventModel.action == WorkflowService.TASK_ACTION_COMPLETE) \ - .order_by(TaskEventModel.date).all() # Get them back in order. - - self.assertEqual(17, len(task_logs)) - for log in task_logs: - task = processor.bpmn_workflow.get_tasks_from_spec_name(log.task_name)[0] - self.assertIsNotNone(log.form_data) - # Each task should have the data in the form for that task in the task event. - if hasattr(task.task_spec, 'form'): - for field in task.task_spec.form.fields: - if field.has_property(Task.PROP_OPTIONS_REPEAT): - self.assertIn(field.get_property(Task.PROP_OPTIONS_REPEAT), log.form_data) - else: - self.assertIn(field.id, log.form_data) - - # Some spot checks: - # The first task should be empty, with all the data removed. - self.assertEqual({}, task_logs[0].form_data) - def test_dmn_evaluation_errors_in_oncomplete_raise_api_errors_during_validation(self): workflow_spec_model = self.load_test_spec("decision_table_invalid") - with self.assertRaises(NameError): + with self.assertRaises(ApiError): WorkflowService.test_spec(workflow_spec_model.id) \ No newline at end of file From dd0f984347792c162bb617e2e4fb0395e3156132 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Mon, 20 Jul 2020 12:26:34 -0400 Subject: [PATCH 098/101] Drop backwards compatibility of scripts. While this will cause some initial pain, it's less confusing and error prone, and we are still in the development phase of the project. Were this going straight to production we would likely want to keep this backwards compatibility. Don't parse on spaces if this is python code, so we avoid any errors in processing - spaces should be valid. --- crc/services/workflow_processor.py | 34 ++++++++----------- .../bpmn/research_rampup/research_rampup.bpmn | 2 +- .../test_workflow_spec_validation_api.py | 3 -- 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index 63113aa7..165d3313 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -33,27 +33,27 @@ class CustomBpmnScriptEngine(BpmnScriptEngine): def execute(self, task: SpiffTask, script, data): """ - Assume that the script read in from the BPMN file is a fully qualified python class. Instantiate - that class, pass in any data available to the current task so that it might act on it. - Assume that the class implements the "do_task" method. - - This allows us to reference custom code from the BPMN diagram. + Functions in two modes. + 1. If the command is proceeded by #! then this is assumed to be a python script, and will + attempt to load that python module and execute the do_task method on that script. Scripts + must be located in the scripts package and they must extend the script.py class. + 2. If not proceeded by the #! this will attempt to execute the script directly and assumes it is + valid Python. """ # Shlex splits the whole string while respecting double quoted strings within - commands = shlex.split(script) - failedOnce = False - prevError = '' - if commands[0] != '#!': + if not script.startswith('#!'): try: - super().execute(task,script,data) + super().execute(task, script, data) except SyntaxError as e: - failedOnce = True - prevError = script - app.logger.warning('We experienced a syntax error, but we are going to try the old method on ' - '"%s"'%script) + raise ApiError.from_task('syntax_error', + f'If you are running a pre-defined script, please' + f' proceed the script with "#!", otherwise this is assumed to be' + f' pure python: {script}, {e.msg}', task=task) else: - commands = commands[1:] + self.run_predefined_script(task, script[2:], data) # strip off the first two characters. + def run_predefined_script(self, task: SpiffTask, script, data): + commands = shlex.split(script) path_and_command = commands[0].rsplit(".", 1) if len(path_and_command) == 1: module_name = "crc.scripts." + self.camel_to_snake(path_and_command[0]) @@ -83,10 +83,6 @@ class CustomBpmnScriptEngine(BpmnScriptEngine): else: klass().do_task(task, study_id, workflow_id, *commands[1:]) except ModuleNotFoundError: - if failedOnce: - raise ApiError.from_task("invalid_script", - "Script had a syntax error: '%s'" % (prevError), - task=task) raise ApiError.from_task("invalid_script", "Unable to locate Script: '%s:%s'" % (module_name, class_name), task=task) diff --git a/crc/static/bpmn/research_rampup/research_rampup.bpmn b/crc/static/bpmn/research_rampup/research_rampup.bpmn index 5a4bb1bc..4a04eb6d 100644 --- a/crc/static/bpmn/research_rampup/research_rampup.bpmn +++ b/crc/static/bpmn/research_rampup/research_rampup.bpmn @@ -755,7 +755,7 @@ Notify the Area Monitor for This step is internal to the system and do not require and user interaction Flow_0j4rs82 Flow_07ge8uf - RequestApproval ApprvlApprvr1 ApprvlApprvr2 + #!RequestApproval ApprvlApprvr1 ApprvlApprvr2 #### Script Task diff --git a/tests/workflow/test_workflow_spec_validation_api.py b/tests/workflow/test_workflow_spec_validation_api.py index 597c4aa1..00406f5b 100644 --- a/tests/workflow/test_workflow_spec_validation_api.py +++ b/tests/workflow/test_workflow_spec_validation_api.py @@ -66,7 +66,6 @@ class TestWorkflowSpecValidation(BaseTest): errors.extend(ApiErrorSchema(many=True).load(json_data)) self.assertEqual(0, len(errors), json.dumps(errors)) - def test_invalid_expression(self): self.load_example_data() errors = self.validate_workflow("invalid_expression") @@ -103,12 +102,10 @@ class TestWorkflowSpecValidation(BaseTest): errors = self.validate_workflow("invalid_script2") self.assertEqual(2, len(errors)) self.assertEqual("error_loading_workflow", errors[0]['code']) - self.assertTrue("syntax error" in errors[0]['message']) self.assertEqual("Invalid_Script_Task", errors[0]['task_id']) self.assertEqual("An Invalid Script Reference", errors[0]['task_name']) self.assertEqual("invalid_script2.bpmn", errors[0]['file_name']) - def test_repeating_sections_correctly_populated(self): self.load_example_data() spec_model = self.load_test_spec('repeat_form') From d01b30debcf414dc29a06b8f4c54da3a0054f8cd Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 21 Jul 2020 13:57:33 -0400 Subject: [PATCH 099/101] Building the start of an endpoint that could be used by logged in / authenticated users that will evaluate basic python functions sent in via the API. The hope here is that we can process these in real time for the front end, but still do it using the same evaluation engine we use everywhere else, so the syntax for things like hide-expressions can be properly verified during workflow validation and will be assured to work during front end rendering. Removing any all javascript code in the BPMN models. --- crc/api.yml | 26 ++++++++++++++++++++++++++ crc/api/tools.py | 13 +++++++++++++ tests/test_tools_api.py | 9 +++++++++ 3 files changed, 48 insertions(+) diff --git a/crc/api.yml b/crc/api.yml index 3d504ad4..148d030e 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -838,6 +838,32 @@ paths: type: array items: $ref: "#/components/schemas/Script" + /eval: + parameters: + - name: expression + in: query + required: true + description: The python expression to execute. + schema: + type: string + - name: data + in: query + required: true + description: The json data to use as local variables, typically this the the task.data + schema: + type: string + get: + operationId: crc.api.tools.evaluate_python_expression + summary: Execute the given python expression, with the given json data structure used as local variables, returns the result of the evaluation. + tags: + - Configurator Tools + responses: + '200': + description: Returns the result of executing the given python script. + content: + text/plain: + schema: + type: string /approval-counts: parameters: - name: as_user diff --git a/crc/api/tools.py b/crc/api/tools.py index 760d0d71..539a6181 100644 --- a/crc/api/tools.py +++ b/crc/api/tools.py @@ -61,8 +61,21 @@ def list_scripts(): }) return script_meta + def send_email(address): """Just sends a quick test email to assure the system is working.""" if not address: address = "dan@sartography.com" return send_test_email(address, [address]) + + +def evaluate_python_expression(expression, data): + """Evaluate the given python expression, returning it's result. This is useful if the + front end application needs to do real-time processing on task data. If for instance + there is a hide expression that is based on a previous value in the same form.""" + try: + data = json.loads(data) + locals().update(data) + return eval(expression) + except Exception as e: + raise ApiError("expression_error", str(e)) diff --git a/tests/test_tools_api.py b/tests/test_tools_api.py index c6f543c1..51ccd1ec 100644 --- a/tests/test_tools_api.py +++ b/tests/test_tools_api.py @@ -37,3 +37,12 @@ class TestStudyApi(BaseTest): self.assertTrue(len(scripts) > 1) self.assertIsNotNone(scripts[0]['name']) self.assertIsNotNone(scripts[0]['description']) + + def test_eval_hide_expression(self): + """Assures we can use python to process a hide expression fron the front end""" + rv = self.app.get('/v1.0/eval') + self.assert_success(rv) + scripts = json.loads(rv.get_data(as_text=True)) + self.assertTrue(len(scripts) > 1) + self.assertIsNotNone(scripts[0]['name']) + self.assertIsNotNone(scripts[0]['description']) From a243c14d754dd578d0790a96690d5b175421d1f3 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Wed, 22 Jul 2020 11:30:16 -0400 Subject: [PATCH 100/101] Updating to lastest spiffworkflow which uses box to support dot notation. Adding a new endpoint for evaluating the results of a python expression into the tools section of the api. --- Pipfile.lock | 63 +++++++++++++++++------------- crc/api.yml | 15 +++---- crc/api/tools.py | 9 +++-- crc/services/workflow_processor.py | 4 ++ tests/test_tools_api.py | 10 ++--- 5 files changed, 57 insertions(+), 44 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index ff3840b4..bd8581a5 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -533,34 +533,34 @@ }, "numpy": { "hashes": [ - "sha256:13af0184177469192d80db9bd02619f6fa8b922f9f327e077d6f2a6acb1ce1c0", - "sha256:26a45798ca2a4e168d00de75d4a524abf5907949231512f372b217ede3429e98", - "sha256:26f509450db547e4dfa3ec739419b31edad646d21fb8d0ed0734188b35ff6b27", - "sha256:30a59fb41bb6b8c465ab50d60a1b298d1cd7b85274e71f38af5a75d6c475d2d2", - "sha256:33c623ef9ca5e19e05991f127c1be5aeb1ab5cdf30cb1c5cf3960752e58b599b", - "sha256:356f96c9fbec59974a592452ab6a036cd6f180822a60b529a975c9467fcd5f23", - "sha256:3c40c827d36c6d1c3cf413694d7dc843d50997ebffbc7c87d888a203ed6403a7", - "sha256:4d054f013a1983551254e2379385e359884e5af105e3efe00418977d02f634a7", - "sha256:63d971bb211ad3ca37b2adecdd5365f40f3b741a455beecba70fd0dde8b2a4cb", - "sha256:658624a11f6e1c252b2cd170d94bf28c8f9410acab9f2fd4369e11e1cd4e1aaf", - "sha256:76766cc80d6128750075378d3bb7812cf146415bd29b588616f72c943c00d598", - "sha256:7b57f26e5e6ee2f14f960db46bd58ffdca25ca06dd997729b1b179fddd35f5a3", - "sha256:7b852817800eb02e109ae4a9cef2beda8dd50d98b76b6cfb7b5c0099d27b52d4", - "sha256:8cde829f14bd38f6da7b2954be0f2837043e8b8d7a9110ec5e318ae6bf706610", - "sha256:a2e3a39f43f0ce95204beb8fe0831199542ccab1e0c6e486a0b4947256215632", - "sha256:a86c962e211f37edd61d6e11bb4df7eddc4a519a38a856e20a6498c319efa6b0", - "sha256:a8705c5073fe3fcc297fb8e0b31aa794e05af6a329e81b7ca4ffecab7f2b95ef", - "sha256:b6aaeadf1e4866ca0fdf7bb4eed25e521ae21a7947c59f78154b24fc7abbe1dd", - "sha256:be62aeff8f2f054eff7725f502f6228298891fd648dc2630e03e44bf63e8cee0", - "sha256:c2edbb783c841e36ca0fa159f0ae97a88ce8137fb3a6cd82eae77349ba4b607b", - "sha256:cbe326f6d364375a8e5a8ccb7e9cd73f4b2f6dc3b2ed205633a0db8243e2a96a", - "sha256:d34fbb98ad0d6b563b95de852a284074514331e6b9da0a9fc894fb1cdae7a79e", - "sha256:d97a86937cf9970453c3b62abb55a6475f173347b4cde7f8dcdb48c8e1b9952d", - "sha256:dd53d7c4a69e766e4900f29db5872f5824a06827d594427cf1a4aa542818b796", - "sha256:df1889701e2dfd8ba4dc9b1a010f0a60950077fb5242bb92c8b5c7f1a6f2668a", - "sha256:fa1fe75b4a9e18b66ae7f0b122543c42debcf800aaafa0212aaff3ad273c2596" + "sha256:082f8d4dd69b6b688f64f509b91d482362124986d98dc7dc5f5e9f9b9c3bb983", + "sha256:1bc0145999e8cb8aed9d4e65dd8b139adf1919e521177f198529687dbf613065", + "sha256:309cbcfaa103fc9a33ec16d2d62569d541b79f828c382556ff072442226d1968", + "sha256:3673c8b2b29077f1b7b3a848794f8e11f401ba0b71c49fbd26fb40b71788b132", + "sha256:480fdd4dbda4dd6b638d3863da3be82873bba6d32d1fc12ea1b8486ac7b8d129", + "sha256:56ef7f56470c24bb67fb43dae442e946a6ce172f97c69f8d067ff8550cf782ff", + "sha256:5a936fd51049541d86ccdeef2833cc89a18e4d3808fe58a8abeb802665c5af93", + "sha256:5b6885c12784a27e957294b60f97e8b5b4174c7504665333c5e94fbf41ae5d6a", + "sha256:667c07063940e934287993366ad5f56766bc009017b4a0fe91dbd07960d0aba7", + "sha256:7ed448ff4eaffeb01094959b19cbaf998ecdee9ef9932381420d514e446601cd", + "sha256:8343bf67c72e09cfabfab55ad4a43ce3f6bf6e6ced7acf70f45ded9ebb425055", + "sha256:92feb989b47f83ebef246adabc7ff3b9a59ac30601c3f6819f8913458610bdcc", + "sha256:935c27ae2760c21cd7354402546f6be21d3d0c806fffe967f745d5f2de5005a7", + "sha256:aaf42a04b472d12515debc621c31cf16c215e332242e7a9f56403d814c744624", + "sha256:b12e639378c741add21fbffd16ba5ad25c0a1a17cf2b6fe4288feeb65144f35b", + "sha256:b1cca51512299841bf69add3b75361779962f9cee7d9ee3bb446d5982e925b69", + "sha256:b8456987b637232602ceb4d663cb34106f7eb780e247d51a260b84760fd8f491", + "sha256:b9792b0ac0130b277536ab8944e7b754c69560dac0415dd4b2dbd16b902c8954", + "sha256:c9591886fc9cbe5532d5df85cb8e0cc3b44ba8ce4367bd4cf1b93dc19713da72", + "sha256:cf1347450c0b7644ea142712619533553f02ef23f92f781312f6a3553d031fc7", + "sha256:de8b4a9b56255797cbddb93281ed92acbc510fb7b15df3f01bd28f46ebc4edae", + "sha256:e1b1dc0372f530f26a03578ac75d5e51b3868b9b76cd2facba4c9ee0eb252ab1", + "sha256:e45f8e981a0ab47103181773cc0a54e650b2aef8c7b6cd07405d0fa8d869444a", + "sha256:e4f6d3c53911a9d103d8ec9518190e52a8b945bab021745af4939cfc7c0d4a9e", + "sha256:ed8a311493cf5480a2ebc597d1e177231984c818a86875126cfd004241a73c3e", + "sha256:ef71a1d4fd4858596ae80ad1ec76404ad29701f8ca7cdcebc50300178db14dfc" ], - "version": "==1.19.0" + "version": "==1.19.1" }, "openapi-spec-validator": { "hashes": [ @@ -685,6 +685,13 @@ ], "version": "==0.16.0" }, + "python-box": { + "hashes": [ + "sha256:2df0d0e0769b6d6e7daed8d5e0b10a38e0b5486ee75914c30f2a927f7a374111", + "sha256:ddea019b4ee53fe3f822407b0b26ec54ff6233042c68b54244d3503ae4d6218f" + ], + "version": "==5.0.1" + }, "python-dateutil": { "hashes": [ "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", @@ -886,7 +893,7 @@ "spiffworkflow": { "editable": true, "git": "https://github.com/sartography/SpiffWorkflow.git", - "ref": "161b3e2ac62a824c6b771e9b817a2bd477af0d17" + "ref": "74529738b4e16be5aadd846669a201560f81a6d4" }, "sqlalchemy": { "hashes": [ diff --git a/crc/api.yml b/crc/api.yml index 148d030e..4c6ebd1b 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -846,17 +846,18 @@ paths: description: The python expression to execute. schema: type: string - - name: data - in: query - required: true - description: The json data to use as local variables, typically this the the task.data - schema: - type: string - get: + put: operationId: crc.api.tools.evaluate_python_expression summary: Execute the given python expression, with the given json data structure used as local variables, returns the result of the evaluation. tags: - Configurator Tools + requestBody: + description: The json data to use as local variables when evaluating the expresson. + required: true + content: + application/json: + schema: + type: object responses: '200': description: Returns the result of executing the given python script. diff --git a/crc/api/tools.py b/crc/api/tools.py index 539a6181..c8d84d82 100644 --- a/crc/api/tools.py +++ b/crc/api/tools.py @@ -2,6 +2,7 @@ import io import json import connexion +from SpiffWorkflow.bpmn.PythonScriptEngine import PythonScriptEngine from flask import send_file from jinja2 import Template, UndefinedError @@ -10,6 +11,7 @@ from crc.scripts.complete_template import CompleteTemplate from crc.scripts.script import Script import crc.scripts from crc.services.mails import send_test_email +from crc.services.workflow_processor import WorkflowProcessor def render_markdown(data, template): @@ -69,13 +71,12 @@ def send_email(address): return send_test_email(address, [address]) -def evaluate_python_expression(expression, data): +def evaluate_python_expression(expression, body): """Evaluate the given python expression, returning it's result. This is useful if the front end application needs to do real-time processing on task data. If for instance there is a hide expression that is based on a previous value in the same form.""" try: - data = json.loads(data) - locals().update(data) - return eval(expression) + script_engine = PythonScriptEngine() + return script_engine.evaluate(expression, **body) except Exception as e: raise ApiError("expression_error", str(e)) diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index 165d3313..1af7dc94 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -428,3 +428,7 @@ class WorkflowProcessor(object): return spiff_task, field raise ApiError("invalid_field", "Unable to find a task in the workflow with a lookup field called: %s" % field_id) + + @staticmethod + def get_script_engine(): + return WorkflowProcessor._script_engine diff --git a/tests/test_tools_api.py b/tests/test_tools_api.py index 51ccd1ec..3ddf9fea 100644 --- a/tests/test_tools_api.py +++ b/tests/test_tools_api.py @@ -40,9 +40,9 @@ class TestStudyApi(BaseTest): def test_eval_hide_expression(self): """Assures we can use python to process a hide expression fron the front end""" - rv = self.app.get('/v1.0/eval') + rv = self.app.put('/v1.0/eval?expression=x.y==2', + data='{"x":{"y":2}}', follow_redirects=True, + content_type='application/json', + headers=self.logged_in_headers()) self.assert_success(rv) - scripts = json.loads(rv.get_data(as_text=True)) - self.assertTrue(len(scripts) > 1) - self.assertIsNotNone(scripts[0]['name']) - self.assertIsNotNone(scripts[0]['description']) + self.assertEqual("true", rv.get_data(as_text=True).strip()) From acb43cc27168843fa48a9c2007c1c77874c6f614 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Wed, 22 Jul 2020 11:40:49 -0400 Subject: [PATCH 101/101] Removing an unused function and addign a fix me. --- crc/api/tools.py | 2 ++ crc/services/workflow_processor.py | 4 ---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/crc/api/tools.py b/crc/api/tools.py index c8d84d82..de30d10d 100644 --- a/crc/api/tools.py +++ b/crc/api/tools.py @@ -76,6 +76,8 @@ def evaluate_python_expression(expression, body): front end application needs to do real-time processing on task data. If for instance there is a hide expression that is based on a previous value in the same form.""" try: + # fixme: The script engine should be pulled from Workflow Processor, + # but the one it returns overwrites the evaluate expression making it uncallable. script_engine = PythonScriptEngine() return script_engine.evaluate(expression, **body) except Exception as e: diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index 1af7dc94..165d3313 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -428,7 +428,3 @@ class WorkflowProcessor(object): return spiff_task, field raise ApiError("invalid_field", "Unable to find a task in the workflow with a lookup field called: %s" % field_id) - - @staticmethod - def get_script_engine(): - return WorkflowProcessor._script_engine