From 4e4cc7884c567ff9af0cb05db665b4a7d98eb2c7 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Fri, 29 May 2020 15:17:51 -0400 Subject: [PATCH 01/76] Better ldap searching. --- crc/services/ldap_service.py | 47 +++++++++++++++++++++++++----------- tests/test_lookup_service.py | 3 +++ 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/crc/services/ldap_service.py b/crc/services/ldap_service.py index 4602ed59..5fb3544f 100644 --- a/crc/services/ldap_service.py +++ b/crc/services/ldap_service.py @@ -1,5 +1,7 @@ import os +from ldap3.core.exceptions import LDAPExceptionError + from crc import app from ldap3 import Connection, Server, MOCK_SYNC @@ -38,7 +40,9 @@ class LdapService(object): attributes = ['uid', 'cn', 'sn', 'displayName', 'givenName', 'mail', 'objectClass', 'UvaDisplayDepartment', 'telephoneNumber', 'title', 'uvaPersonIAMAffiliation', 'uvaPersonSponsoredType'] uid_search_string = "(&(objectclass=person)(uid=%s))" - user_or_last_name_search_string = "(&(objectclass=person)(|(uid=%s*)(sn=%s*)))" + user_or_last_name_search = "(&(objectclass=person)(|(uid=%s*)(sn=%s*)))" + cn_single_search = '(&(objectclass=person)(cn=%s*))' + cn_double_search = '(&(objectclass=person)(&(cn=%s*)(cn=*%s*)))' def __init__(self): if app.config['TESTING']: @@ -67,18 +71,33 @@ class LdapService(object): return LdapUserInfo.from_entry(entry) def search_users(self, query, limit): - if len(query) < 3: return [] - search_string = LdapService.user_or_last_name_search_string % (query, query) - self.conn.search(LdapService.search_base, search_string, attributes=LdapService.attributes) - - # Entries are returned as a generator, accessing entries - # can make subsequent calls to the ldap service, so limit - # those here. - count = 0 + if len(query.strip()) < 3: + return [] + elif query.endswith(' '): + search_string = LdapService.cn_single_search % (query.strip()) + elif query.strip().count(',') == 1: + f, l = query.split(",") + search_string = LdapService.cn_double_search % (l.strip(), f.strip()) + elif query.strip().count(' ') == 1: + f,l = query.split(" ") + search_string = LdapService.cn_double_search % (f, l) + else: + # Search by user_id or last name + search_string = LdapService.user_or_last_name_search % (query, query) results = [] - for entry in self.conn.entries: - if count > limit: - break - results.append(LdapUserInfo.from_entry(entry)) - count += 1 + print(search_string) + try: + self.conn.search(LdapService.search_base, search_string, attributes=LdapService.attributes) + # Entries are returned as a generator, accessing entries + # can make subsequent calls to the ldap service, so limit + # those here. + count = 0 + for entry in self.conn.entries: + if count > limit: + break + results.append(LdapUserInfo.from_entry(entry)) + count += 1 + except LDAPExceptionError as le: + app.logger.info("Failed to execute ldap search. %s", str(le)) + return results diff --git a/tests/test_lookup_service.py b/tests/test_lookup_service.py index c0d72ae9..d2977a57 100644 --- a/tests/test_lookup_service.py +++ b/tests/test_lookup_service.py @@ -114,6 +114,9 @@ class TestLookupService(BaseTest): results = LookupService.lookup(workflow, "AllTheNames", "1 (!-Something", limit=10) self.assertEquals("1 Something", results[0].label, "special characters don't flake out") + results = LookupService.lookup(workflow, "AllTheNames", "Dan Funk (dhf", limit=10) + self.assertEquals(results) + # 1018 10000 Something Industry # 1019 1000 Something Industry From 5046d3a6693b3a73aa4b32da2869af47ee01492e Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Fri, 29 May 2020 15:20:22 -0400 Subject: [PATCH 02/76] Committed a stupid. --- tests/test_lookup_service.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_lookup_service.py b/tests/test_lookup_service.py index d2977a57..4a2b1920 100644 --- a/tests/test_lookup_service.py +++ b/tests/test_lookup_service.py @@ -114,8 +114,6 @@ class TestLookupService(BaseTest): results = LookupService.lookup(workflow, "AllTheNames", "1 (!-Something", limit=10) self.assertEquals("1 Something", results[0].label, "special characters don't flake out") - results = LookupService.lookup(workflow, "AllTheNames", "Dan Funk (dhf", limit=10) - self.assertEquals(results) # 1018 10000 Something Industry From 860c475b29120497226bc1dc2f271e8dcb35c337 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Sat, 30 May 2020 15:37:04 -0400 Subject: [PATCH 03/76] Fill out repeating sections during validation process. Also, when returning error messages, attempt to include the task data for the task that caused the error. Also, when attempting to delete any file, respond with an API error explaining the issue, and log the details. --- crc/api/common.py | 7 +- crc/models/api_models.py | 1 + crc/services/file_service.py | 27 +++-- crc/services/study_service.py | 3 + crc/services/workflow_service.py | 117 +++++++++++++-------- tests/data/repeat_form/repeat_form.bpmn | 47 +++++++++ tests/test_workflow_spec_validation_api.py | 16 ++- 7 files changed, 159 insertions(+), 59 deletions(-) create mode 100644 tests/data/repeat_form/repeat_form.bpmn diff --git a/crc/api/common.py b/crc/api/common.py index 2cd09522..b89dd8d5 100644 --- a/crc/api/common.py +++ b/crc/api/common.py @@ -3,7 +3,7 @@ from crc import ma, app class ApiError(Exception): def __init__(self, code, message, status_code=400, - file_name="", task_id="", task_name="", tag=""): + file_name="", task_id="", task_name="", tag="", task_data = {}): self.status_code = status_code self.code = code # a short consistent string describing the error. self.message = message # A detailed message that provides more information. @@ -11,6 +11,7 @@ class ApiError(Exception): self.task_name = task_name or "" # OPTIONAL: The name of the task in the BPMN Diagram. self.file_name = file_name or "" # OPTIONAL: The file that caused the error. self.tag = tag or "" # OPTIONAL: The XML Tag that caused the issue. + self.task_data = task_data or "" # OPTIONAL: A snapshot of data connected to the task when error ocurred. Exception.__init__(self, self.message) @classmethod @@ -20,6 +21,7 @@ class ApiError(Exception): instance.task_id = task.task_spec.name or "" instance.task_name = task.task_spec.description or "" instance.file_name = task.workflow.spec.file or "" + instance.task_data = task.data return instance @classmethod @@ -35,7 +37,8 @@ class ApiError(Exception): class ApiErrorSchema(ma.Schema): class Meta: - fields = ("code", "message", "workflow_name", "file_name", "task_name", "task_id") + fields = ("code", "message", "workflow_name", "file_name", "task_name", "task_id", + "task_data") @app.errorhandler(ApiError) diff --git a/crc/models/api_models.py b/crc/models/api_models.py index 4b279965..d53e43bc 100644 --- a/crc/models/api_models.py +++ b/crc/models/api_models.py @@ -31,6 +31,7 @@ class NavigationItem(object): class Task(object): + PROP_OPTIONS_REPEAT = "repeat" PROP_OPTIONS_FILE = "spreadsheet.name" PROP_OPTIONS_VALUE_COLUMN = "spreadsheet.value.column" PROP_OPTIONS_LABEL_COL = "spreadsheet.label.column" diff --git a/crc/services/file_service.py b/crc/services/file_service.py index beb22831..273460c1 100644 --- a/crc/services/file_service.py +++ b/crc/services/file_service.py @@ -5,11 +5,13 @@ from datetime import datetime from uuid import UUID from xml.etree import ElementTree +import flask from SpiffWorkflow.bpmn.parser.ValidationException import ValidationException from pandas import ExcelFile from sqlalchemy import desc +from sqlalchemy.exc import IntegrityError -from crc import session +from crc import session, app from crc.api.common import ApiError from crc.models.file import FileType, FileDataModel, FileModel, LookupFileModel, LookupDataModel from crc.models.workflow import WorkflowSpecModel, WorkflowModel, WorkflowSpecDependencyFile @@ -295,12 +297,17 @@ class FileService(object): @staticmethod def delete_file(file_id): - data_models = session.query(FileDataModel).filter_by(file_model_id=file_id).all() - for dm in data_models: - lookup_files = session.query(LookupFileModel).filter_by(file_data_model_id=dm.id).all() - for lf in lookup_files: - session.query(LookupDataModel).filter_by(lookup_file_model_id=lf.id).delete() - session.query(LookupFileModel).filter_by(id=lf.id).delete() - session.query(FileDataModel).filter_by(file_model_id=file_id).delete() - session.query(FileModel).filter_by(id=file_id).delete() - session.commit() + try: + data_models = session.query(FileDataModel).filter_by(file_model_id=file_id).all() + for dm in data_models: + lookup_files = session.query(LookupFileModel).filter_by(file_data_model_id=dm.id).all() + for lf in lookup_files: + session.query(LookupDataModel).filter_by(lookup_file_model_id=lf.id).delete() + session.query(LookupFileModel).filter_by(id=lf.id).delete() + session.query(FileDataModel).filter_by(file_model_id=file_id).delete() + session.query(FileModel).filter_by(id=file_id).delete() + session.commit() + except IntegrityError as ie: + app.logger.error("Failed to delete file: %i, due to %s" % (file_id, str(ie))) + raise ApiError('file_integrity_error', "You are attempting to delete a file that is " + "required by other records in the system.") \ No newline at end of file diff --git a/crc/services/study_service.py b/crc/services/study_service.py index 98a8d15a..424a911f 100644 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -4,6 +4,7 @@ from typing import List import requests from SpiffWorkflow import WorkflowException +from SpiffWorkflow.exceptions import WorkflowTaskExecException from ldap3.core.exceptions import LDAPSocketOpenError from crc import db, session, app @@ -309,6 +310,8 @@ class StudyService(object): for workflow_spec in new_specs: try: StudyService._create_workflow_model(study_model, workflow_spec) + except WorkflowTaskExecException as wtee: + errors.append(ApiError.from_task("workflow_execution_exception", str(wtee), wtee.task)) except WorkflowException as we: errors.append(ApiError.from_task_spec("workflow_execution_exception", str(we), we.sender)) return errors diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index c6cb8638..cf40b84d 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -7,8 +7,9 @@ from SpiffWorkflow import Task as SpiffTask, WorkflowException from SpiffWorkflow.bpmn.specs.ManualTask import ManualTask from SpiffWorkflow.bpmn.specs.ScriptTask import ScriptTask from SpiffWorkflow.bpmn.specs.UserTask import UserTask -from SpiffWorkflow.bpmn.workflow import BpmnWorkflow +from SpiffWorkflow.camunda.specs.UserTask import EnumFormField from SpiffWorkflow.dmn.specs.BusinessRuleTask import BusinessRuleTask +from SpiffWorkflow.exceptions import WorkflowTaskExecException from SpiffWorkflow.specs import CancelTask, StartTask from flask import g from jinja2 import Template @@ -17,7 +18,6 @@ from crc import db, app from crc.api.common import ApiError from crc.models.api_models import Task, MultiInstanceType from crc.models.file import LookupDataModel -from crc.models.protocol_builder import ProtocolBuilderStatus from crc.models.stats import TaskEventModel from crc.models.study import StudyModel from crc.models.user import UserModel @@ -39,7 +39,9 @@ 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.""" + 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. """ @staticmethod def make_test_workflow(spec_id): @@ -61,17 +63,25 @@ class WorkflowService(object): for study in db.session.query(StudyModel).filter(StudyModel.user_uid=="test"): StudyService.delete_study(study.id) db.session.commit() - db.session.query(UserModel).filter_by(uid="test").delete() + + user = db.session.query(UserModel).filter_by(uid="test").first() + if user: + db.session.delete(user) @staticmethod def test_spec(spec_id): - """Runs a spec through it's paces to see if it results in any errors. Not fool-proof, but a good - sanity check.""" + """Runs a spec through it's paces to see if it results in any errors. + Not fool-proof, but a good sanity check. Returns the final data + output form the last task if successful. """ workflow_model = WorkflowService.make_test_workflow(spec_id) try: processor = WorkflowProcessor(workflow_model, validate_only=True) + except WorkflowTaskExecException as wtee: + WorkflowService.delete_test_data() + raise ApiError.from_task("workflow_execution_exception", str(wtee), + wtee.task) except WorkflowException as we: WorkflowService.delete_test_data() raise ApiError.from_task_spec("workflow_execution_exception", str(we), @@ -87,11 +97,17 @@ class WorkflowService(object): 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) task.complete() + except WorkflowTaskExecException as wtee: + WorkflowService.delete_test_data() + raise ApiError.from_task("workflow_execution_exception", str(wtee), + wtee.task) except WorkflowException as we: WorkflowService.delete_test_data() raise ApiError.from_task_spec("workflow_execution_exception", str(we), we.sender) + WorkflowService.delete_test_data() + return processor.bpmn_workflow.last_task.data @staticmethod def populate_form_with_random_data(task, task_api): @@ -101,22 +117,35 @@ class WorkflowService(object): form_data = {} for field in task_api.form.fields: - if field.type == "enum": - if len(field.options) > 0: - random_choice = random.choice(field.options) - if isinstance(random_choice, dict): - form_data[field.id] = random.choice(field.options)['id'] - else: - # fixme: why it is sometimes an EnumFormFieldOption, and other times not? - form_data[field.id] = random_choice.id ## Assume it is an EnumFormFieldOption + if field.has_property(Task.PROP_OPTIONS_REPEAT): + group = field.get_property(Task.PROP_OPTIONS_REPEAT) + if group not in form_data: + form_data[group] = [{},{},{}] + for i in range(3): + form_data[group][i][field.id] = WorkflowService.get_random_data_for_field(field, task) + else: + form_data[field.id] = WorkflowService.get_random_data_for_field(field, task) + if task.data is None: + task.data = {} + task.data.update(form_data) + + @staticmethod + def get_random_data_for_field(field, task): + if field.type == "enum": + if len(field.options) > 0: + random_choice = random.choice(field.options) + if isinstance(random_choice, dict): + return random.choice(field.options)['id'] else: - raise ApiError.from_task("invalid_enum", "You specified an enumeration field (%s)," - " with no options" % field.id, - task) - elif field.type == "autocomplete": - lookup_model = LookupService.get_lookup_model(task, field) - if field.has_property(Task.PROP_LDAP_LOOKUP): - form_data[field.id] = { + # fixme: why it is sometimes an EnumFormFieldOption, and other times not? + return random_choice.id ## Assume it is an EnumFormFieldOption + else: + raise ApiError.from_task("invalid_enum", "You specified an enumeration field (%s)," + " with no options" % field.id, task) + elif field.type == "autocomplete": + lookup_model = LookupService.get_lookup_model(task, field) + if field.has_property(Task.PROP_LDAP_LOOKUP): # All ldap records get the same person. + return { "label": "dhf8r", "value": "Dan Funk", "data": { @@ -126,32 +155,30 @@ class WorkflowService(object): "email_address": "dhf8r@virginia.edu", "department": "Depertment of Psychocosmographictology", "affiliation": "Rousabout", - "sponsor_type": "Staff" + "sponsor_type": "Staff"} } - } - 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, "name": d.label}) - form_data[field.id] = random.choice(options) - else: - raise ApiError.from_task("invalid_autocomplete", "The settings for this auto complete field " - "are incorrect: %s " % field.id, task) - elif field.type == "long": - form_data[field.id] = random.randint(1, 1000) - elif field.type == 'boolean': - form_data[field.id] = random.choice([True, False]) - elif field.type == 'file': - form_data[field.id] = random.randint(1, 100) - elif field.type == 'files': - form_data[field.id] = random.randrange(1, 100) + 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, "name": d.label}) + return random.choice(options) else: - form_data[field.id] = WorkflowService._random_string() - if task.data is None: - task.data = {} - task.data.update(form_data) + raise ApiError.from_task("invalid_autocomplete", "The settings for this auto complete field " + "are incorrect: %s " % field.id, task) + elif field.type == "long": + return random.randint(1, 1000) + elif field.type == 'boolean': + return random.choice([True, False]) + elif field.type == 'file': + # fixme: produce some something sensible for files. + return random.randint(1, 100) + # fixme: produce some something sensible for files. + elif field.type == 'files': + return random.randrange(1, 100) + else: + return WorkflowService._random_string() def __get_options(self): pass diff --git a/tests/data/repeat_form/repeat_form.bpmn b/tests/data/repeat_form/repeat_form.bpmn new file mode 100644 index 00000000..f0e3f922 --- /dev/null +++ b/tests/data/repeat_form/repeat_form.bpmn @@ -0,0 +1,47 @@ + + + + + SequenceFlow_0lvudp8 + + + + SequenceFlow_02vev7n + + + + + + + + + + + + + SequenceFlow_0lvudp8 + SequenceFlow_02vev7n + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_workflow_spec_validation_api.py b/tests/test_workflow_spec_validation_api.py index 9e581874..d46746dc 100644 --- a/tests/test_workflow_spec_validation_api.py +++ b/tests/test_workflow_spec_validation_api.py @@ -3,17 +3,16 @@ from unittest.mock import patch from tests.base_test import BaseTest -from crc.services.protocol_builder import ProtocolBuilderService from crc import session, app from crc.api.common import ApiErrorSchema from crc.models.protocol_builder import ProtocolBuilderStudySchema from crc.models.workflow import WorkflowSpecModel +from crc.services.workflow_service import WorkflowService class TestWorkflowSpecValidation(BaseTest): def validate_workflow(self, workflow_name): - self.load_example_data() spec_model = self.load_test_spec(workflow_name) rv = self.app.get('/v1.0/workflow-specification/%s/validate' % spec_model.id, headers=self.logged_in_headers()) self.assert_success(rv) @@ -22,6 +21,7 @@ class TestWorkflowSpecValidation(BaseTest): def test_successful_validation_of_test_workflows(self): app.config['PB_ENABLED'] = False # Assure this is disabled. + self.load_example_data() self.assertEqual(0, len(self.validate_workflow("parallel_tasks"))) self.assertEqual(0, len(self.validate_workflow("decision_table"))) self.assertEqual(0, len(self.validate_workflow("docx"))) @@ -60,6 +60,7 @@ class TestWorkflowSpecValidation(BaseTest): self.assertEqual(0, len(errors), json.dumps(errors)) def test_invalid_expression(self): + self.load_example_data() errors = self.validate_workflow("invalid_expression") self.assertEqual(1, len(errors)) self.assertEqual("workflow_execution_exception", errors[0]['code']) @@ -68,8 +69,11 @@ class TestWorkflowSpecValidation(BaseTest): self.assertEqual("invalid_expression.bpmn", errors[0]['file_name']) self.assertEqual('ExclusiveGateway_003amsm: Error evaluating expression \'this_value_does_not_exist==true\', ' 'name \'this_value_does_not_exist\' is not defined', errors[0]["message"]) + self.assertIsNotNone(errors[0]['task_data']) + self.assertIn("has_bananas", errors[0]['task_data']) def test_validation_error(self): + self.load_example_data() errors = self.validate_workflow("invalid_spec") self.assertEqual(1, len(errors)) self.assertEqual("workflow_validation_error", errors[0]['code']) @@ -77,6 +81,7 @@ class TestWorkflowSpecValidation(BaseTest): self.assertEqual("invalid_spec.bpmn", errors[0]['file_name']) def test_invalid_script(self): + self.load_example_data() errors = self.validate_workflow("invalid_script") self.assertEqual(1, len(errors)) self.assertEqual("workflow_execution_exception", errors[0]['code']) @@ -84,3 +89,10 @@ class TestWorkflowSpecValidation(BaseTest): self.assertEqual("Invalid_Script_Task", errors[0]['task_id']) self.assertEqual("An Invalid Script Reference", errors[0]['task_name']) self.assertEqual("invalid_script.bpmn", errors[0]['file_name']) + + def test_repeating_sections_correctly_populated(self): + self.load_example_data() + spec_model = self.load_test_spec('repeat_form') + final_data = WorkflowService.test_spec(spec_model.id) + self.assertIsNotNone(final_data) + self.assertIn('cats', final_data) \ No newline at end of file From 1f0e8741ba228e0d71581097d888c41f79058b67 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Sat, 30 May 2020 17:21:57 -0400 Subject: [PATCH 04/76] Run the validation twice, once completing all of the data, and a second time, completing only the required fields. Also, add a helper method to reduce boiler plate code around Workflow Exceptions. --- Pipfile.lock | 20 ++++---- crc/api/common.py | 13 +++++ crc/api/workflow.py | 2 + crc/models/api_models.py | 1 + crc/services/workflow_service.py | 31 +++++------- tests/data/decision_table/decision_table.bpmn | 37 +++++++------- .../exclusive_gateway/exclusive_gateway.bpmn | 6 ++- tests/data/random_fact/random_fact.bpmn | 44 +++++++++-------- .../data/required_fields/required_fields.bpmn | 48 +++++++++++++++++++ tests/test_workflow_processor.py | 2 +- tests/test_workflow_service.py | 2 +- tests/test_workflow_spec_validation_api.py | 15 +++++- 12 files changed, 151 insertions(+), 70 deletions(-) create mode 100644 tests/data/required_fields/required_fields.bpmn diff --git a/Pipfile.lock b/Pipfile.lock index d9c2bfab..ce620efc 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -428,10 +428,10 @@ }, "mako": { "hashes": [ - "sha256:3139c5d64aa5d175dbafb95027057128b5fbd05a40c53999f3905ceb53366d9d", - "sha256:8e8b53c71c7e59f3de716b6832c4e401d903af574f6962edbbbf6ecc2a5fe6c9" + "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27", + "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9" ], - "version": "==1.1.2" + "version": "==1.1.3" }, "markupsafe": { "hashes": [ @@ -489,11 +489,11 @@ }, "marshmallow-sqlalchemy": { "hashes": [ - "sha256:3247e41e424146340b03a369f2b7c6f0364477ccedc4e2481e84d5f3a8d3c67f", - "sha256:dbbe51d28bb28e7ee2782e51310477f7a2c5a111a301f6dd8e264e11ab820427" + "sha256:03a555b610bb307689b821b64e2416593ec21a85925c8c436c2cd08ebc6bb85e", + "sha256:0ef59c8da8da2e18e808e3880158049e9d72f3031c84cc804b6c533a0eb668a9" ], "index": "pypi", - "version": "==0.23.0" + "version": "==0.23.1" }, "numpy": { "hashes": [ @@ -778,7 +778,7 @@ "spiffworkflow": { "editable": true, "git": "https://github.com/sartography/SpiffWorkflow.git", - "ref": "c8d87826d496af825a184bdc3f0a751e603cfe44" + "ref": "b8a064a0bb76c705a1be04ee9bb8ac7beee56eb0" }, "sqlalchemy": { "hashes": [ @@ -876,11 +876,11 @@ }, "xlsxwriter": { "hashes": [ - "sha256:488e1988ab16ff3a9cd58c7656d0a58f8abe46ee58b98eecea78c022db28656b", - "sha256:97ab487b81534415c5313154203f3e8a637d792b1e6a8201e8f7f71da0203c2a" + "sha256:828b3285fc95105f5b1946a6a015b31cf388bd5378fdc6604e4d1b7839df2e77", + "sha256:82a3b0e73e3913483da23791d1a25e4d2dbb3837d1be4129473526b9a270a5cc" ], "index": "pypi", - "version": "==1.2.8" + "version": "==1.2.9" }, "zipp": { "hashes": [ diff --git a/crc/api/common.py b/crc/api/common.py index b89dd8d5..f8673a5b 100644 --- a/crc/api/common.py +++ b/crc/api/common.py @@ -1,3 +1,6 @@ +from SpiffWorkflow import WorkflowException +from SpiffWorkflow.exceptions import WorkflowTaskExecException + from crc import ma, app @@ -34,6 +37,16 @@ class ApiError(Exception): instance.file_name = task_spec._wf_spec.file return instance + @classmethod + def from_workflow_exception(cls, code, message, exp: WorkflowException): + """We catch a lot of workflow exception errors, + so consolidating the code, and doing the best things + we can with the data we have.""" + if isinstance(exp, WorkflowTaskExecException): + return ApiError.from_task(code, message, exp.task) + else: + return ApiError.from_task_spec(code, message, exp.sender) + class ApiErrorSchema(ma.Schema): class Meta: diff --git a/crc/api/workflow.py b/crc/api/workflow.py index efcccc26..9d6e4680 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -42,7 +42,9 @@ def validate_workflow_specification(spec_id): errors = [] try: + # Run the validation twice, the second time, just populate the required fields. WorkflowService.test_spec(spec_id) + WorkflowService.test_spec(spec_id, required_only=True) except ApiError as ae: errors.append(ae) return ApiErrorSchema(many=True).dump(errors) diff --git a/crc/models/api_models.py b/crc/models/api_models.py index d53e43bc..eee6d5f5 100644 --- a/crc/models/api_models.py +++ b/crc/models/api_models.py @@ -36,6 +36,7 @@ class Task(object): PROP_OPTIONS_VALUE_COLUMN = "spreadsheet.value.column" PROP_OPTIONS_LABEL_COL = "spreadsheet.label.column" PROP_LDAP_LOOKUP = "ldap.lookup" + VALIDATION_REQUIRED = "required" FIELD_TYPE_AUTO_COMPLETE = "autocomplete" diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index cf40b84d..dc900400 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -7,9 +7,7 @@ from SpiffWorkflow import Task as SpiffTask, WorkflowException from SpiffWorkflow.bpmn.specs.ManualTask import ManualTask from SpiffWorkflow.bpmn.specs.ScriptTask import ScriptTask from SpiffWorkflow.bpmn.specs.UserTask import UserTask -from SpiffWorkflow.camunda.specs.UserTask import EnumFormField from SpiffWorkflow.dmn.specs.BusinessRuleTask import BusinessRuleTask -from SpiffWorkflow.exceptions import WorkflowTaskExecException from SpiffWorkflow.specs import CancelTask, StartTask from flask import g from jinja2 import Template @@ -69,23 +67,22 @@ class WorkflowService(object): db.session.delete(user) @staticmethod - def test_spec(spec_id): + def test_spec(spec_id, required_only=False): """Runs a spec through it's paces to see if it results in any errors. Not fool-proof, but a good sanity check. Returns the final data - output form the last task if successful. """ + output form the last task if successful. + + required_only can be set to true, in which case this will run the + spec, only completing the required fields, rather than everything. + """ workflow_model = WorkflowService.make_test_workflow(spec_id) try: processor = WorkflowProcessor(workflow_model, validate_only=True) - except WorkflowTaskExecException as wtee: - WorkflowService.delete_test_data() - raise ApiError.from_task("workflow_execution_exception", str(wtee), - wtee.task) except WorkflowException as we: WorkflowService.delete_test_data() - raise ApiError.from_task_spec("workflow_execution_exception", str(we), - we.sender) + raise ApiError.from_workflow_exception("workflow_execution_exception", str(we), we) while not processor.bpmn_workflow.is_completed(): try: @@ -95,28 +92,26 @@ class WorkflowService(object): 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) + WorkflowService.populate_form_with_random_data(task, task_api, required_only) task.complete() - except WorkflowTaskExecException as wtee: - WorkflowService.delete_test_data() - raise ApiError.from_task("workflow_execution_exception", str(wtee), - wtee.task) except WorkflowException as we: WorkflowService.delete_test_data() - raise ApiError.from_task_spec("workflow_execution_exception", str(we), - we.sender) + raise ApiError.from_workflow_exception("workflow_execution_exception", str(we), we) WorkflowService.delete_test_data() return processor.bpmn_workflow.last_task.data @staticmethod - def populate_form_with_random_data(task, task_api): + def populate_form_with_random_data(task, task_api, required_only): """populates a task with random data - useful for testing a spec.""" if not hasattr(task.task_spec, 'form'): return form_data = {} for field in task_api.form.fields: + if required_only and (not field.has_validation(Task.VALIDATION_REQUIRED) or + field.get_validation(Task.VALIDATION_REQUIRED).lower().strip() != "true"): + continue # Don't include any fields that aren't specifically marked as required. if field.has_property(Task.PROP_OPTIONS_REPEAT): group = field.get_property(Task.PROP_OPTIONS_REPEAT) if group not in form_data: diff --git a/tests/data/decision_table/decision_table.bpmn b/tests/data/decision_table/decision_table.bpmn index 796233e5..82bcb385 100644 --- a/tests/data/decision_table/decision_table.bpmn +++ b/tests/data/decision_table/decision_table.bpmn @@ -1,5 +1,5 @@ - + SequenceFlow_1ma1wxb @@ -8,7 +8,11 @@ - + + + + + SequenceFlow_1ma1wxb @@ -26,38 +30,37 @@ Based on the information you provided (Ginger left {{num_presents}}, we recommen ## {{message}} -We hope you both have an excellent day! - +We hope you both have an excellent day! SequenceFlow_0grui6f - - - - - - + + + - - - + + + + + + + + + + - - - - diff --git a/tests/data/exclusive_gateway/exclusive_gateway.bpmn b/tests/data/exclusive_gateway/exclusive_gateway.bpmn index 1c7e55fe..8467c954 100644 --- a/tests/data/exclusive_gateway/exclusive_gateway.bpmn +++ b/tests/data/exclusive_gateway/exclusive_gateway.bpmn @@ -8,7 +8,11 @@ - + + + + + SequenceFlow_1pnq3kg diff --git a/tests/data/random_fact/random_fact.bpmn b/tests/data/random_fact/random_fact.bpmn index 81f355c3..628f1bd4 100644 --- a/tests/data/random_fact/random_fact.bpmn +++ b/tests/data/random_fact/random_fact.bpmn @@ -1,5 +1,5 @@ - + SequenceFlow_0c7wlth @@ -108,6 +108,9 @@ Autoconverted link https://github.com/nodeca/pica (enable linkify to see) + + + @@ -121,8 +124,7 @@ Autoconverted link https://github.com/nodeca/pica (enable linkify to see) SequenceFlow_0641sh6 - - + @@ -155,6 +157,18 @@ Your random fact is: + + + + + + + + + + + + @@ -164,35 +178,23 @@ Your random fact is: + + + + + + - - - - - - - - - - - - - - - - - - diff --git a/tests/data/required_fields/required_fields.bpmn b/tests/data/required_fields/required_fields.bpmn new file mode 100644 index 00000000..7612f69b --- /dev/null +++ b/tests/data/required_fields/required_fields.bpmn @@ -0,0 +1,48 @@ + + + + + SequenceFlow_0lvudp8 + + + + SequenceFlow_02vev7n + + + + + + + + + + + + + + SequenceFlow_0lvudp8 + SequenceFlow_02vev7n + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_workflow_processor.py b/tests/test_workflow_processor.py index 36d23755..b3f6c374 100644 --- a/tests/test_workflow_processor.py +++ b/tests/test_workflow_processor.py @@ -25,7 +25,7 @@ class TestWorkflowProcessor(BaseTest): 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) + 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) diff --git a/tests/test_workflow_service.py b/tests/test_workflow_service.py index 281d1756..f509f642 100644 --- a/tests/test_workflow_service.py +++ b/tests/test_workflow_service.py @@ -77,5 +77,5 @@ class TestWorkflowService(BaseTest): processor.do_engine_steps() 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) + 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 diff --git a/tests/test_workflow_spec_validation_api.py b/tests/test_workflow_spec_validation_api.py index d46746dc..1594d681 100644 --- a/tests/test_workflow_spec_validation_api.py +++ b/tests/test_workflow_spec_validation_api.py @@ -95,4 +95,17 @@ class TestWorkflowSpecValidation(BaseTest): spec_model = self.load_test_spec('repeat_form') final_data = WorkflowService.test_spec(spec_model.id) self.assertIsNotNone(final_data) - self.assertIn('cats', final_data) \ No newline at end of file + self.assertIn('cats', final_data) + + def test_required_fields(self): + self.load_example_data() + spec_model = self.load_test_spec('required_fields') + final_data = WorkflowService.test_spec(spec_model.id) + self.assertIsNotNone(final_data) + self.assertIn('string_required', final_data) + self.assertIn('string_not_required', final_data) + + final_data = WorkflowService.test_spec(spec_model.id, required_only=True) + self.assertIsNotNone(final_data) + self.assertIn('string_required', final_data) + self.assertNotIn('string_not_required', final_data) From 98fb305868f21f033d7e419f29ecc8cc6eb2ed93 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Sat, 30 May 2020 18:43:20 -0400 Subject: [PATCH 05/76] Run the validation process twice, each time it is requested, first populating everything, and then a second time using on the required form fields. --- crc/api/workflow.py | 7 +- crc/services/study_service.py | 1 + .../research_rampup/ResearchRampUpPlan.docx | Bin 58311 -> 58518 bytes .../bpmn/research_rampup/research_rampup.bpmn | 236 +++++++++++------- tests/base_test.py | 10 +- tests/test_workflow_spec_validation_api.py | 16 +- 6 files changed, 170 insertions(+), 100 deletions(-) diff --git a/crc/api/workflow.py b/crc/api/workflow.py index 9d6e4680..8b3758d8 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -42,10 +42,15 @@ def validate_workflow_specification(spec_id): errors = [] try: - # Run the validation twice, the second time, just populate the required fields. WorkflowService.test_spec(spec_id) + except ApiError as ae: + ae.message = "When populating all fields ... " + ae.message + errors.append(ae) + try: + # Run the validation twice, the second time, just populate the required fields. WorkflowService.test_spec(spec_id, required_only=True) except ApiError as ae: + ae.message = "When populating only required fields ... " + ae.message errors.append(ae) return ApiErrorSchema(many=True).dump(errors) diff --git a/crc/services/study_service.py b/crc/services/study_service.py index 424a911f..6dea83a9 100644 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -175,6 +175,7 @@ class StudyService(object): return documents + @staticmethod def get_investigators(study_id): diff --git a/crc/static/bpmn/research_rampup/ResearchRampUpPlan.docx b/crc/static/bpmn/research_rampup/ResearchRampUpPlan.docx index 0c555fdb102d4f49e72dfc17265a2d7db360182b..2ff0ed801e4f0fa2ffcfc06ef5901eb86ef1689f 100644 GIT binary patch delta 22146 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 delta 22049 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 - + SequenceFlow_05ja25w @@ -34,7 +34,7 @@ Schools are developing a process for the approval of ramp up requests and enforc 1. The Research Ramp-up Plan allows for one request to be entered for a single Principle Investigator. In the form that follows enter the Primary Investigator this request is for and other identifying information. The PI's School and Supervisor will be used as needed for approval routing. 2. Provide all available information in the forms that follow to provide an overview of where the research will resume, who will be involved, what supporting resources will be needed and what steps have been taken to assure compliance with [Research Ramp-up Guidance](https://research.virginia.edu/research-ramp-guidance). 3. After all forms have been completed, you will be presented with the option to create your Research Recovery Plan in Word format. Download the document and review it. If you see any corrections that need to be made, return to the corresponding form and make the correction. -4. Once the generated Research Recovery Plan is finalize, proceed to the Plan Submission step to submit your plan for approval. +4. Once the generated Research Recovery Plan is finalized, proceed to the Plan Submission step to submit your plan for approval. SequenceFlow_05ja25w SequenceFlow_0h50bp3 @@ -47,6 +47,7 @@ Enter the following information for the PI submitting this request + @@ -60,6 +61,9 @@ Enter the following information for the PI submitting this request + + + @@ -123,7 +127,7 @@ Enter the following information for the PI submitting this request - + @@ -135,7 +139,7 @@ Enter the following information for the PI submitting this request #### People for whom you are requesting access -Provide information on all researchers you are requesting approval for reentry into the previously entered lab/research and/or office space(s) for conducting research on-Grounds. (If there are personnel already working in the space, include them). +Provide information on all researchers you are requesting approval for reentry into the previously entered lab, workspace and/or office space(s) for conducting research on-Grounds. (If there are personnel already working in the space, include them). **Note: no undergraduates will be allowed to work on-Grounds during Phase I.** @@ -155,12 +159,21 @@ No shared space entered {% endfor %} + + + + + + + - + + + @@ -168,7 +181,7 @@ No shared space entered - + @@ -182,7 +195,7 @@ No shared space entered - + @@ -193,17 +206,12 @@ No shared space entered - + - - - - - Flow_1eiud85 @@ -232,8 +240,8 @@ No shared space entered Flow_12ie6w0 - #### End of Workflow -Place instruction here, + #### End of Research Ramp-up Plan Workflow +Thank you for participating, Flow_05w8yd6 @@ -250,7 +258,7 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a - + @@ -277,8 +285,9 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a - + + @@ -304,6 +313,7 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a + @@ -345,9 +355,9 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a Submit one entry for each space the PI is the exclusive investigator. If all space is shared with one or more other investigators, Click Save to skip this section and proceed to the Shared Space section. - + - + @@ -366,7 +376,7 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - + @@ -374,19 +384,21 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp + - + - + + @@ -410,11 +422,12 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - + + @@ -452,7 +465,7 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - + #### Distancing requirements: Maintain social distancing by designing space between people to be at least 9 feet during prolonged work which will be accomplished by restricting the number of people in the lab to a density of ~250 sq. ft. /person in lab areas. When moving around, a minimum of 6 feet social distancing is required. Ideally only one person per lab bench and not more than one person can work at the same time in the same bay. @@ -475,7 +488,7 @@ Maintain social distancing by designing space between people to be at least 9 fe - Flow_1nbjr72 + Flow_097fpi3 Flow_0p2r1bo Flow_0mkh1wn Flow_1yqkpgu @@ -488,8 +501,7 @@ Maintain social distancing by designing space between people to be at least 9 fe - Indicate total square footage for every lab/space that you are requesting adding personnel to in this application. If you would like help obtaining a floor plan for your lab, your department or deans office can help. You can also create a hand drawing/block diagram of your space and the location of objects on a graph paper. - Upload your physical layout and workspace organization in the form of a jpg image or a pdf file. This can be hand-drawn or actual floor plans. - Show and/or describe designated work location for each member (during their shift) in the lab when multiple members are present at a time to meet the distancing guidelines. -- Provide a foot traffic plan (on the schematic) to indicate how people can move around while maintaining distancing requirements. This can be a freeform sketch on your floor plan showing where foot traffic can occur in your lab, and conditions, if any, to ensure distancing at all times. (e.g., direction to walk around a lab bench, rules for using shared equipment located in the lab, certain areas of lab prohibited from access, etc.). -- Provide your initial weekly laboratory schedule (see excel template) for all members that you are requesting access for, indicating all shifts as necessary. If schedule changes, please submit your revised schedule through the web portal. +- Provide a foot traffic plan (on the schematic) to indicate how people can move around while maintaining distancing requirements. This can be a freeform sketch on your floor plan showing where foot traffic can occur in your lab, and conditions, if any, to ensure distancing at all times. (e.g., direction to walk around a lab bench, rules for using shared equipment located in the lab, certain areas of lab prohibited from access, etc.). @@ -508,7 +520,7 @@ Maintain social distancing by designing space between people to be at least 9 fe Flow_0zrsh65 - + #### Health Safety Requirements: Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/url?q=http://ehs.virginia.edu/files/Lab-Safety-Plan-During-COVID-19.docx&source=gmail&ust=1590687968958000&usg=AFQjCNE83uGDFtxGkKaxjuXGhTocu-FDmw) to create and upload a copy of your laboratory policy statement to all members which includes at a minimum the following details: - Laboratory face covering rules, use of other PPE use as required @@ -519,7 +531,7 @@ Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/ur - Where and how to obtain PPE including face covering - + Flow_1yqkpgu @@ -584,7 +596,7 @@ Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/ur - + Flow_0zrsh65 Flow_0tz5c2v @@ -592,12 +604,16 @@ Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/ur Flow_0qbi47d Flow_06873ag - + + #### Script Task + + +This step is internal to the system and do not require and user interaction Flow_06873ag Flow_0aqgwvu CompleteTemplate ResearchRampUpPlan.docx RESEARCH_RAMPUP - + @@ -635,6 +651,10 @@ If a rejection notification is received, go back to the first step that needs to + #### Business Rule Task + + +This step is internal to the system and do not require and user interaction Flow_1e2qi9s Flow_08njvvi @@ -671,98 +691,129 @@ No shared space entered Flow_0cpmvcw + #### Script Task + + +This step is internal to the system and do not require and user interaction Flow_0j4rs82 Flow_07ge8uf RequestApproval ApprvlApprvr1 ApprvlApprvr2 + #### Script Task + + +This step is internal to the system and do not require and user interaction Flow_16y8glw Flow_1v7r1tg UpdateStudy title:PIComputingID.label pi:PIComputingID.value + + + #### Weekly Schedule +Provide your initial weekly laboratory schedule for all members that you are requesting access for, indicating all shifts as necessary. If any schedule changes after approval, please submit your revised schedule here for re-approval. + + + + + + + + + + + + + Flow_1nbjr72 + Flow_097fpi3 + + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - + @@ -813,8 +864,8 @@ No shared space entered - - + + @@ -840,10 +891,10 @@ No shared space entered - + - + @@ -861,47 +912,50 @@ No shared space entered - + - + - + - + - + - + - + - - + + - + - + - + - + - + + + + diff --git a/tests/base_test.py b/tests/base_test.py index f8ffd1ca..f0418343 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -115,15 +115,17 @@ class BaseTest(unittest.TestCase): self.assertIsNotNone(user_model.display_name) return dict(Authorization='Bearer ' + user_model.encode_auth_token().decode()) - def load_example_data(self, use_crc_data=False): + def load_example_data(self, use_crc_data=False, use_rrt_data=False): """use_crc_data will cause this to load the mammoth collection of documents - we built up developing crc, otherwise it depends on a small setup for - running tests.""" + we built up developing crc, use_rrt_data will do the same for hte rrt project, + otherwise it depends on a small setup for running tests.""" from example_data import ExampleDataLoader ExampleDataLoader.clean_db() - if(use_crc_data): + if use_crc_data: ExampleDataLoader().load_all() + elif use_rrt_data: + ExampleDataLoader().load_rrt() else: ExampleDataLoader().load_test_data() diff --git a/tests/test_workflow_spec_validation_api.py b/tests/test_workflow_spec_validation_api.py index 1594d681..e2f652d9 100644 --- a/tests/test_workflow_spec_validation_api.py +++ b/tests/test_workflow_spec_validation_api.py @@ -49,6 +49,13 @@ class TestWorkflowSpecValidation(BaseTest): self.load_example_data(use_crc_data=True) app.config['PB_ENABLED'] = True + self.validate_all_loaded_workflows() + + def test_successful_validation_of_rrt_workflows(self): + self.load_example_data(use_rrt_data=True) + self.validate_all_loaded_workflows() + + def validate_all_loaded_workflows(self): workflows = session.query(WorkflowSpecModel).all() errors = [] for w in workflows: @@ -59,15 +66,16 @@ 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") - self.assertEqual(1, len(errors)) + self.assertEqual(2, len(errors)) self.assertEqual("workflow_execution_exception", errors[0]['code']) self.assertEqual("ExclusiveGateway_003amsm", errors[0]['task_id']) self.assertEqual("Has Bananas Gateway", errors[0]['task_name']) self.assertEqual("invalid_expression.bpmn", errors[0]['file_name']) - self.assertEqual('ExclusiveGateway_003amsm: Error evaluating expression \'this_value_does_not_exist==true\', ' + self.assertEqual('When populating all fields ... ExclusiveGateway_003amsm: Error evaluating expression \'this_value_does_not_exist==true\', ' 'name \'this_value_does_not_exist\' is not defined', errors[0]["message"]) self.assertIsNotNone(errors[0]['task_data']) self.assertIn("has_bananas", errors[0]['task_data']) @@ -75,7 +83,7 @@ class TestWorkflowSpecValidation(BaseTest): def test_validation_error(self): self.load_example_data() errors = self.validate_workflow("invalid_spec") - self.assertEqual(1, len(errors)) + self.assertEqual(2, len(errors)) self.assertEqual("workflow_validation_error", errors[0]['code']) self.assertEqual("StartEvent_1", errors[0]['task_id']) self.assertEqual("invalid_spec.bpmn", errors[0]['file_name']) @@ -83,7 +91,7 @@ class TestWorkflowSpecValidation(BaseTest): def test_invalid_script(self): self.load_example_data() errors = self.validate_workflow("invalid_script") - self.assertEqual(1, len(errors)) + self.assertEqual(2, len(errors)) self.assertEqual("workflow_execution_exception", errors[0]['code']) self.assertTrue("NoSuchScript" in errors[0]['message']) self.assertEqual("Invalid_Script_Task", errors[0]['task_id']) From 2bc735a3f01ffd961370f99e5ede46b7bef3ed2a Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Sun, 31 May 2020 13:48:00 -0400 Subject: [PATCH 06/76] Kelly wrote a beautiful method for resetting the workflow that doesn't loose data when reset inside a parallel task, all I needed to do was use it. Catching TypeErrors and reporting them back to the UI so we don't 500 in a bad way (but we still 500) --- crc/services/workflow_processor.py | 28 +++-- crc/services/workflow_service.py | 5 +- .../bpmn/research_rampup/research_rampup.bpmn | 104 ++++++++++-------- 3 files changed, 80 insertions(+), 57 deletions(-) diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index d032b94a..93590d94 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -299,21 +299,27 @@ 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 don't need to reenter all the previous data. + """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. """ + + # 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) - spec = WorkflowProcessor.get_spec(self.spec_data_files, self.workflow_spec_id) - # spec = WorkflowProcessor.get_spec(self.workflow_spec_id, version) - bpmn_workflow = BpmnWorkflow(spec, script_engine=self._script_engine) - bpmn_workflow.data = self.bpmn_workflow.data - for task in bpmn_workflow.get_tasks(SpiffTask.READY): - task.data = self.bpmn_workflow.last_task.data - bpmn_workflow.do_engine_steps() - self.bpmn_workflow = bpmn_workflow + 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=False) + 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 def get_status(self): return self.status_of(self.bpmn_workflow) diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index dc900400..03a23aac 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -294,10 +294,11 @@ class WorkflowService(object): template = Template(raw_doc) return template.render(**spiff_task.data) except jinja2.exceptions.TemplateError as ue: - - # return "Error processing template. %s" % ue.message raise ApiError(code="template_error", message="Error processing template for task %s: %s" % (spiff_task.task_spec.name, str(ue)), status_code=500) + except TypeError as te: + raise ApiError(code="template_error", message="Error processing template for task %s: %s" % + (spiff_task.task_spec.name, str(te)), status_code=500) # TODO: Catch additional errors and report back. @staticmethod diff --git a/crc/static/bpmn/research_rampup/research_rampup.bpmn b/crc/static/bpmn/research_rampup/research_rampup.bpmn index 93c4dd6d..03d82d00 100644 --- a/crc/static/bpmn/research_rampup/research_rampup.bpmn +++ b/crc/static/bpmn/research_rampup/research_rampup.bpmn @@ -72,6 +72,9 @@ Enter the following information for the PI submitting this request + + + @@ -81,6 +84,9 @@ Enter the following information for the PI submitting this request + + + @@ -89,6 +95,9 @@ Enter the following information for the PI submitting this request + + + @@ -97,6 +106,9 @@ Enter the following information for the PI submitting this request + + + @@ -105,6 +117,9 @@ Enter the following information for the PI submitting this request + + + @@ -113,18 +128,25 @@ Enter the following information for the PI submitting this request + + + + + + + @@ -138,42 +160,34 @@ Enter the following information for the PI submitting this request - #### People for whom you are requesting access -Provide information on all researchers you are requesting approval for reentry into the previously entered lab, workspace and/or office space(s) for conducting research on-Grounds. (If there are personnel already working in the space, include them). + #### Personnel for whom you are requesting access +Provide information on all personnel you are requesting approval for reentry into the previously entered lab, workspace and/or office space(s) for conducting research on-Grounds. (If there are personnel already working in the space, include them). **Note: no undergraduates will be allowed to work on-Grounds during Phase I.** #### Exclusive Space previously entered -{% for es in exclusive %} -{{ es.ExclusiveSpaceRoomID + " " + es.ExclusiveSpaceBuilding.label }} -{% else %} -No exclusive space entered -{% endfor %} - +{%+ for es in exclusive %}{{ es.ExclusiveSpaceRoomID + " " + es.ExclusiveSpaceBuilding.label }}{% if loop.last %}{% else %}, {% endif %}{% else %}No exclusive space entered{% endfor %} #### Shared Space previously entered -{% for ss in shared %} -{{ ss.SharedSpaceRoomID + " " + ss.SharedSpaceBuilding.label }} -{% else %} -No shared space entered -{% endfor %} +{%+ for ss in shared %}{{ ss.SharedSpaceRoomID + " " + ss.SharedSpaceBuilding.label }}{% if loop.last %}{% else %}, {% endif %}{% else %}No shared space entered.{% endfor %} - - + + - + + @@ -181,7 +195,7 @@ No shared space entered - + @@ -192,10 +206,8 @@ No shared space entered - - - - + + @@ -206,7 +218,7 @@ No shared space entered - + @@ -218,7 +230,7 @@ No shared space entered Flow_1nbjr72 - #### If applicable, provide a list of any [Core Resources](https://research.virginia.edu/research-core-resources) you will utilize space or instruments in and name/email of contact person in the core you have coordinated your plan with. (Core facility managers are responsible for developing a plan for their space) + If applicable, provide a list of any [Core Resources](https://research.virginia.edu/research-core-resources) utilization of space and/or instruments along with the name(s) and email(s) of contact person(s) in the core with whom you have coordinated your plan. (Core facility managers are responsible for developing a plan for their space) @@ -232,6 +244,7 @@ No shared space entered + @@ -263,6 +276,7 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a + @@ -352,7 +366,8 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a #### Space managed exclusively by {{ PIComputingID.label }} -Submit one entry for each space the PI is the exclusive investigator. If all space is shared with one or more other investigators, Click Save to skip this section and proceed to the Shared Space section. + +Submit one entry for each space the PI is the exclusive investigator. If all space is shared with one or more other investigators, click Save to skip this section and proceed to the Shared Space section. @@ -362,6 +377,7 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp + @@ -465,7 +481,7 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - + #### Distancing requirements: Maintain social distancing by designing space between people to be at least 9 feet during prolonged work which will be accomplished by restricting the number of people in the lab to a density of ~250 sq. ft. /person in lab areas. When moving around, a minimum of 6 feet social distancing is required. Ideally only one person per lab bench and not more than one person can work at the same time in the same bay. @@ -496,7 +512,7 @@ Maintain social distancing by designing space between people to be at least 9 fe - Describe physical work arrangements for each lab. Show schematic of the lab and space organization to meet the distancing guidelines (see key safety expectations for ramp-up). + Describe physical work arrangements for each lab, workspace and/or office space previously entered. Show schematic of the space organization to meet the distancing guidelines (see key safety expectations for ramp-up). - Show gross dimensions, location of desks, and equipment in blocks (not details) that show available space for work and foot traffic. - Indicate total square footage for every lab/space that you are requesting adding personnel to in this application. If you would like help obtaining a floor plan for your lab, your department or deans office can help. You can also create a hand drawing/block diagram of your space and the location of objects on a graph paper. - Upload your physical layout and workspace organization in the form of a jpg image or a pdf file. This can be hand-drawn or actual floor plans. @@ -508,6 +524,7 @@ Maintain social distancing by designing space between people to be at least 9 fe + @@ -586,7 +603,7 @@ Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/ur #### By submitting this request, you understand that every member listed in this form for on Grounds laboratory access will: -- Complete online COVID awareness & precaution training module (link forthcoming-May 25) +- Complete [online COVID awareness & precaution training module](https://researchcompliance.web.virginia.edu/training_html5/module_content/154/index.cfm) - Complete daily health acknowledgement form signed (electronically) –email generated daily to those listed on your plan for access to on Grounds lab/research space - Fill out daily work attendance log for all lab members following your school process to check-in and out of work each day. Flow_08njvvi @@ -673,20 +690,14 @@ If notification is received that the Research Ramp-up Plan approval process is n Notify the Area Monitor for -#### Exclusive Space Area Monitors -{% for es in exclusive %} -{{ es.ExclusiveSpaceAMComputingID.data.display_name }} -{% else %} -No exclusive space entered -{% endfor %} +#### Exclusive Space previously entered +{%+ for es in exclusive %}{{ es.ExclusiveSpaceRoomID + " " + es.ExclusiveSpaceBuilding.label + " - " }}{% if es.ExclusiveSpaceAMComputingID is none %}No Area Monitor entered{% else %}{{ es.ExclusiveSpaceAMComputingID.label }}{% endif %}{% if loop.last %}{% else %}, {% endif %}{% else %}No exclusive space entered{% endfor %} -#### Shared Space Area Monitors -{% for ss in shared %} -{{ ss.SharedSpaceAMComputingID.data.display_name }} -{% else %} -No shared space entered -{% endfor %} + + +#### Shared Space previously entered +{%+ for ss in shared %}{{ ss.SharedSpaceRoomID + " " + ss.SharedSpaceBuilding.label }}{% if ss.SharedSpaceAMComputingID is none %}No Area Monitor entered{% else %}{{ ss.SharedSpaceAMComputingID.label }}{% endif %}{% if loop.last %}{% else %}, {% endif %}{% else %}No shared space entered.{% endfor %} Flow_1ufh44h Flow_0cpmvcw @@ -709,10 +720,15 @@ This step is internal to the system and do not require and user interactionFlow_1v7r1tg UpdateStudy title:PIComputingID.label pi:PIComputingID.value - - - #### Weekly Schedule -Provide your initial weekly laboratory schedule for all members that you are requesting access for, indicating all shifts as necessary. If any schedule changes after approval, please submit your revised schedule here for re-approval. + + + #### Weekly Personnel Schedule(s) +Provide initial weekly schedule(s) for the PI and all personnel for whom access has been requested, indicating each space they will be working in and all shifts, if applicable. + +##### Personnel and spaces they will work in previously entered +{%+ for p in personnel %}{{ p.PersonnelComputingID.label + " - " + p.PersonnelSpace }}{% if loop.last %}{% else %}; {% endif %}{% endfor %} + +**Note:** If any schedule changes after approval, please re-submit revised schedule(s) here for re-approval. @@ -953,7 +969,7 @@ Provide your initial weekly laboratory schedule for all members that you are req - + From 26809d14706e465e88770830ef61cc097b564f3b Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Sun, 31 May 2020 13:35:42 -0600 Subject: [PATCH 07/76] Waiting status renaming --- crc/models/approval.py | 2 +- crc/services/approval_service.py | 2 +- tests/test_approvals_api.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crc/models/approval.py b/crc/models/approval.py index f7aa2e06..c469025b 100644 --- a/crc/models/approval.py +++ b/crc/models/approval.py @@ -14,7 +14,7 @@ from crc.services.ldap_service import LdapService class ApprovalStatus(enum.Enum): - WAITING = "WAITING" # no one has done jack. + PENDING = "PENDING" # no one has done jack. APPROVED = "APPROVED" # approved by the reviewer DECLINED = "DECLINED" # rejected by the reviewer CANCELED = "CANCELED" # The document was replaced with a new version and this review is no longer needed. diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index 8a13e6c2..bd272585 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -78,7 +78,7 @@ class ApprovalService(object): version = 1 model = ApprovalModel(study_id=study_id, workflow_id=workflow_id, - approver_uid=approver_uid, status=ApprovalStatus.WAITING.value, + approver_uid=approver_uid, status=ApprovalStatus.PENDING.value, message="", date_created=datetime.now(), version=version) approval_files = ApprovalService._create_approval_files(workflow_data_files, model) diff --git a/tests/test_approvals_api.py b/tests/test_approvals_api.py index 393831e7..b9b6d226 100644 --- a/tests/test_approvals_api.py +++ b/tests/test_approvals_api.py @@ -45,7 +45,7 @@ class TestApprovals(BaseTest): study=self.study, workflow=self.workflow, approver_uid='arc93', - status=ApprovalStatus.WAITING.value, + status=ApprovalStatus.PENDING.value, version=1 ) session.add(self.approval) @@ -54,7 +54,7 @@ class TestApprovals(BaseTest): study=self.study, workflow=self.workflow, approver_uid='dhf8r', - status=ApprovalStatus.WAITING.value, + status=ApprovalStatus.PENDING.value, version=1 ) session.add(self.approval_2) @@ -98,7 +98,7 @@ class TestApprovals(BaseTest): data = dict(APPROVAL_PAYLOAD) data['id'] = approval_id - self.assertEqual(self.approval.status, ApprovalStatus.WAITING.value) + self.assertEqual(self.approval.status, ApprovalStatus.PENDING.value) rv = self.app.put(f'/v1.0/approval/{approval_id}', content_type="application/json", From be9b613bbb02fc3ef27e0e707c3e4c200de67cbf Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sun, 31 May 2020 16:49:39 -0400 Subject: [PATCH 08/76] Refactors user authentication endpoints so we can use the Swagger UI in production mode --- config/default.py | 5 +- config/testing.py | 3 +- config/travis-testing.py | 2 - crc/__init__.py | 15 +-- crc/api.yml | 65 ++++------- crc/api/user.py | 215 +++++++++++++++++++++++------------ crc/api/workflow.py | 4 +- tests/base_test.py | 4 +- tests/test_authentication.py | 113 ++++++++++++++++-- 9 files changed, 291 insertions(+), 135 deletions(-) diff --git a/config/default.py b/config/default.py index e368b32d..080c2753 100644 --- a/config/default.py +++ b/config/default.py @@ -9,9 +9,10 @@ JSON_SORT_KEYS = False # CRITICAL. Do not sort the data when returning values NAME = "CR Connect Workflow" FLASK_PORT = environ.get('PORT0') or environ.get('FLASK_PORT', default="5000") CORS_ALLOW_ORIGINS = re.split(r',\s*', environ.get('CORS_ALLOW_ORIGINS', default="localhost:4200, localhost:5002")) -DEVELOPMENT = environ.get('DEVELOPMENT', default="true") == "true" TESTING = environ.get('TESTING', default="false") == "true" -PRODUCTION = (environ.get('PRODUCTION', default="false") == "true") or (not DEVELOPMENT and not TESTING) +PRODUCTION = (environ.get('PRODUCTION', default="false") == "true") +TEST_UID = environ.get('TEST_UID', default="dhf8r") +ADMIN_UIDS = re.split(r',\s*', environ.get('ADMIN_UIDS', default="dhf8r,ajl2j,cah13us,cl3wf")) # Add trailing slash to base path APPLICATION_ROOT = re.sub(r'//', '/', '/%s/' % environ.get('APPLICATION_ROOT', default="/").strip('/')) diff --git a/config/testing.py b/config/testing.py index a7c6a893..546ea829 100644 --- a/config/testing.py +++ b/config/testing.py @@ -4,7 +4,6 @@ from os import environ basedir = os.path.abspath(os.path.dirname(__file__)) NAME = "CR Connect Workflow" -DEVELOPMENT = True TESTING = True TOKEN_AUTH_SECRET_KEY = "Shhhh!!! This is secret! And better darn well not show up in prod." PB_ENABLED = False @@ -23,8 +22,8 @@ SQLALCHEMY_DATABASE_URI = environ.get( 'SQLALCHEMY_DATABASE_URI', default="postgresql://%s:%s@%s:%s/%s" % (DB_USER, DB_PASSWORD, DB_HOST, DB_PORT, DB_NAME) ) +ADMIN_UIDS = ['dhf8r'] print('### USING TESTING CONFIG: ###') print('SQLALCHEMY_DATABASE_URI = ', SQLALCHEMY_DATABASE_URI) -print('DEVELOPMENT = ', DEVELOPMENT) print('TESTING = ', TESTING) diff --git a/config/travis-testing.py b/config/travis-testing.py index 17a4b914..8949061a 100644 --- a/config/travis-testing.py +++ b/config/travis-testing.py @@ -2,7 +2,6 @@ import os basedir = os.path.abspath(os.path.dirname(__file__)) NAME = "CR Connect Workflow" -DEVELOPMENT = True TESTING = True SQLALCHEMY_DATABASE_URI = "postgresql://postgres:@localhost:5432/crc_test" TOKEN_AUTH_TTL_HOURS = 2 @@ -12,6 +11,5 @@ PB_ENABLED = False print('+++ USING TRAVIS TESTING CONFIG: +++') print('SQLALCHEMY_DATABASE_URI = ', SQLALCHEMY_DATABASE_URI) -print('DEVELOPMENT = ', DEVELOPMENT) print('TESTING = ', TESTING) print('FRONTEND_AUTH_CALLBACK = ', FRONTEND_AUTH_CALLBACK) diff --git a/crc/__init__.py b/crc/__init__.py index fe510daf..91e5c8f5 100644 --- a/crc/__init__.py +++ b/crc/__init__.py @@ -41,15 +41,16 @@ origins_re = [r"^https?:\/\/%s(.*)" % o.replace('.', '\.') for o in app.config[' cors = CORS(connexion_app.app, origins=origins_re) print('=== USING THESE CONFIG SETTINGS: ===') -print('DB_HOST = ', ) -print('CORS_ALLOW_ORIGINS = ', app.config['CORS_ALLOW_ORIGINS']) -print('DEVELOPMENT = ', app.config['DEVELOPMENT']) -print('TESTING = ', app.config['TESTING']) -print('PRODUCTION = ', app.config['PRODUCTION']) -print('PB_BASE_URL = ', app.config['PB_BASE_URL']) -print('LDAP_URL = ', app.config['LDAP_URL']) print('APPLICATION_ROOT = ', app.config['APPLICATION_ROOT']) +print('CORS_ALLOW_ORIGINS = ', app.config['CORS_ALLOW_ORIGINS']) +print('DB_HOST = ', app.config['DB_HOST']) +print('LDAP_URL = ', app.config['LDAP_URL']) +print('PB_BASE_URL = ', app.config['PB_BASE_URL']) print('PB_ENABLED = ', app.config['PB_ENABLED']) +print('PRODUCTION = ', app.config['PRODUCTION']) +print('TESTING = ', app.config['TESTING']) +print('TEST_UID = ', app.config['TEST_UID']) +print('ADMIN_UIDS = ', app.config['ADMIN_UIDS']) @app.cli.command() def load_example_data(): diff --git a/crc/api.yml b/crc/api.yml index edc3861b..f2ac27ce 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -9,54 +9,18 @@ servers: security: - jwt: ['secret'] paths: - /sso_backdoor: + /login: get: - operationId: crc.api.user.backdoor - summary: A backdoor that allows someone to log in as a specific user, if they - are in a staging environment. + operationId: crc.api.user.login + summary: In production, logs the user in via SSO. If not in production, logs in as a specific user for testing. security: [] # Disable security for this endpoint only. parameters: - name: uid - in: query - required: true - schema: - type: string - - name: email_address in: query required: false schema: type: string - - name: display_name - in: query - required: false - schema: - type: string - - name: affiliation - in: query - required: false - schema: - type: string - - name: eppn - in: query - required: false - schema: - type: string - - name: first_name - in: query - required: false - schema: - type: string - - name: last_name - in: query - required: false - schema: - type: string - - name: title - in: query - required: false - schema: - type: string - - name: redirect + - name: redirect_url in: query required: false schema: @@ -150,6 +114,8 @@ paths: $ref: "#/components/schemas/Study" delete: operationId: crc.api.study.delete_study + security: + - jwt_admin: ['secret'] summary: Removes the given study completely. tags: - Studies @@ -227,6 +193,8 @@ paths: $ref: "#/components/schemas/WorkflowSpec" put: operationId: crc.api.workflow.update_workflow_specification + security: + - jwt_admin: ['secret'] summary: Modifies an existing workflow specification with the given parameters. tags: - Workflow Specifications @@ -244,6 +212,8 @@ paths: $ref: "#/components/schemas/WorkflowSpec" delete: operationId: crc.api.workflow.delete_workflow_specification + security: + - jwt_admin: ['secret'] summary: Removes an existing workflow specification tags: - Workflow Specifications @@ -289,6 +259,8 @@ paths: $ref: "#/components/schemas/WorkflowSpecCategory" post: operationId: crc.api.workflow.add_workflow_spec_category + security: + - jwt_admin: ['secret'] summary: Creates a new workflow spec category with the given parameters. tags: - Workflow Specification Category @@ -326,6 +298,8 @@ paths: $ref: "#/components/schemas/WorkflowSpecCategory" put: operationId: crc.api.workflow.update_workflow_spec_category + security: + - jwt_admin: ['secret'] summary: Modifies an existing workflow spec category with the given parameters. tags: - Workflow Specification Category @@ -343,6 +317,8 @@ paths: $ref: "#/components/schemas/WorkflowSpecCategory" delete: operationId: crc.api.workflow.delete_workflow_spec_category + security: + - jwt_admin: ['secret'] summary: Removes an existing workflow spec category tags: - Workflow Specification Category @@ -542,6 +518,8 @@ paths: example: '' put: operationId: crc.api.file.set_reference_file + security: + - jwt_admin: ['secret'] summary: Update the contents of a named reference file. tags: - Files @@ -600,6 +578,8 @@ paths: $ref: "#/components/schemas/Workflow" delete: operationId: crc.api.workflow.delete_workflow + security: + - jwt_admin: ['secret'] summary: Removes an existing workflow tags: - Workflows and Tasks @@ -837,6 +817,11 @@ components: scheme: bearer bearerFormat: JWT x-bearerInfoFunc: crc.api.user.verify_token + jwt_admin: + type: http + scheme: bearer + bearerFormat: JWT + x-bearerInfoFunc: crc.api.user.verify_token_admin schemas: User: properties: diff --git a/crc/api/user.py b/crc/api/user.py index afa2e894..004d1420 100644 --- a/crc/api/user.py +++ b/crc/api/user.py @@ -8,34 +8,126 @@ from crc import app, db from crc.api.common import ApiError from crc.models.user import UserModel, UserModelSchema from crc.services.ldap_service import LdapService, LdapUserInfo +from crc.services.approval_service import ApprovalService """ .. module:: crc.api.user :synopsis: Single Sign On (SSO) user login and session handlers """ -def verify_token(token): + + +def verify_token(token=None): + """ + Verifies the token for the user (if provided). If in production environment and token is not provided, + gets user from the SSO headers and returns their token. + + Args: + token: Optional[str] + + Returns: + token: str + + Raises: + ApiError. If not on production and token is not valid, returns an 'invalid_token' 403 error. + If on production and user is not authenticated, returns a 'no_user' 403 error. + """ + print('=== verify_token ===') + print('_is_production()', _is_production()) + failure_error = ApiError("invalid_token", "Unable to decode the token you provided. Please re-authenticate", status_code=403) - if (not 'PRODUCTION' in app.config or not app.config['PRODUCTION']) and token == app.config["SWAGGER_AUTH_KEY"]: + + if not _is_production(): g.user = UserModel.query.first() token = g.user.encode_auth_token() - try: - token_info = UserModel.decode_auth_token(token) - g.user = UserModel.query.filter_by(uid=token_info['sub']).first() - except: - raise failure_error - if g.user is not None: - return token_info + if token: + try: + token_info = UserModel.decode_auth_token(token) + g.user = UserModel.query.filter_by(uid=token_info['sub']).first() + except: + raise failure_error + if g.user is not None: + return token_info + else: + raise failure_error + + # If there's no token and we're in production, get the user from the SSO headers and return their token + if not token and _is_production(): + uid = _get_request_uid() + + if uid is not None: + db_user = UserModel.query.filter_by(uid=uid).first() + + if db_user is not None: + g.user = db_user + token = g.user.encode_auth_token().decode() + token_info = UserModel.decode_auth_token(token) + return token_info + + else: + ApiError("no_user", "User not found. Please login via the frontend app before accessing this feature.", status_code=403) + raise failure_error + + +def verify_token_admin(token=None): + """ + Verifies the token for the user (if provided) in non-production environment. If in production environment, + checks that the user is in the list of authorized admins + + Args: + token: Optional[str] + + Returns: + token: str + """ + + print('=== verify_token_admin ===') + print('_is_production()', _is_production()) + + + # If this is production, check that the user is in the list of admins + if _is_production(): + uid = _get_request_uid() + + print('verify_token_admin uid', uid) + + if uid is not None and uid in app.config['ADMIN_UIDS']: + return verify_token() + + # If we're not in production, just use the normal verify_token method else: - raise failure_error + return verify_token(token) def get_current_user(): return UserModelSchema().dump(g.user) -@app.route('/v1.0/login') -def sso_login(): - # This what I see coming back: + +def login( + uid=None, + redirect_url=None, +): + """ + In non-production environment, provides an endpoint for end-to-end system testing that allows the system + to simulate logging in as a specific user. In production environment, simply logs user in via single-sign-on + (SSO) Shibboleth authentication headers. + + Args: + uid: Optional[str] + redirect_url: Optional[str] + + Returns: + str. If not on production, returns the frontend auth callback URL, with auth token appended. + If on production and user is authenticated via SSO, returns the frontend auth callback URL, + with auth token appended. + + Raises: + ApiError. If on production and user is not authenticated, returns a 404 error. + """ + + # ---------------------------------------- + # Shibboleth Authentication Headers + # ---------------------------------------- # X-Remote-Cn: Daniel Harold Funk (dhf8r) # X-Remote-Sn: Funk # X-Remote-Givenname: Daniel @@ -50,48 +142,54 @@ def sso_login(): # X-Forwarded-Host: dev.crconnect.uvadcos.io # X-Forwarded-Server: dev.crconnect.uvadcos.io # Connection: Keep-Alive - uid = request.headers.get("Uid") - if not uid: - uid = request.headers.get("X-Remote-Uid") - if not uid: - raise ApiError("invalid_sso_credentials", "'Uid' nor 'X-Remote-Uid' were present in the headers: %s" - % str(request.headers)) + print('=== login ===') + print('_is_production()', _is_production()) - redirect = request.args.get('redirect') - app.logger.info("SSO_LOGIN: Full URL: " + request.url) - app.logger.info("SSO_LOGIN: User Id: " + uid) - app.logger.info("SSO_LOGIN: Will try to redirect to : " + str(redirect)) + # If we're in production, override any uid with the uid from the SSO request headers + if _is_production(): + uid = _get_request_uid() - ldap_service = LdapService() - info = ldap_service.user_info(uid) + if uid: + app.logger.info("SSO_LOGIN: Full URL: " + request.url) + app.logger.info("SSO_LOGIN: User Id: " + uid) + app.logger.info("SSO_LOGIN: Will try to redirect to : " + str(redirect_url)) + + ldap_info = LdapService().user_info(uid) + + if ldap_info: + return _handle_login(ldap_info, redirect_url) + + raise ApiError('404', 'unknown') - return _handle_login(info, redirect) @app.route('/sso') def sso(): response = "" response += "

Headers

" response += "
    " - for k,v in request.headers: + for k, v in request.headers: response += "
  • %s %s
  • \n" % (k, v) response += "

    Environment

    " - for k,v in request.environ: + for k, v in request.environ: response += "
  • %s %s
  • \n" % (k, v) return response -def _handle_login(user_info: LdapUserInfo, redirect_url=app.config['FRONTEND_AUTH_CALLBACK']): - """On successful login, adds user to database if the user is not already in the system, - then returns the frontend auth callback URL, with auth token appended. +def _handle_login(user_info: LdapUserInfo, redirect_url=None): + """ + On successful login, adds user to database if the user is not already in the system, + then returns the frontend auth callback URL, with auth token appended. - Args: - user_info - an ldap user_info object. - redirect_url: Optional[str] + Args: + user_info - an ldap user_info object. + redirect_url: Optional[str] - Returns: - Response. 302 - Redirects to the frontend auth callback URL, with auth token appended. + Returns: + Response. 302 - Redirects to the frontend auth callback URL, with auth token appended. """ + print('=== _handle_login ===') + print('user_info', user_info) user = db.session.query(UserModel).filter(UserModel.uid == user_info.uid).first() if user is None: @@ -120,41 +218,18 @@ def _handle_login(user_info: LdapUserInfo, redirect_url=app.config['FRONTEND_AUT return auth_token +def _get_request_uid(uid=None): + if _is_production(): + uid = request.headers.get("Uid") + if not uid: + uid = request.headers.get("X-Remote-Uid") -def backdoor( - uid=None, - affiliation=None, - display_name=None, - email_address=None, - eppn=None, - first_name=None, - last_name=None, - title=None, - redirect=None, -): - """A backdoor for end-to-end system testing that allows the system to simulate logging in as a specific user. - Only works if the application is running in a non-production environment. + if not uid: + raise ApiError("invalid_sso_credentials", "'Uid' nor 'X-Remote-Uid' were present in the headers: %s" + % str(request.headers)) - Args: - uid: str - affiliation: Optional[str] - display_name: Optional[str] - email_address: Optional[str] - eppn: Optional[str] - first_name: Optional[str] - last_name: Optional[str] - title: Optional[str] - redirect_url: Optional[str] + return uid - Returns: - str. If not on production, returns the frontend auth callback URL, with auth token appended. - Raises: - ApiError. If on production, returns a 404 error. - """ - if not 'PRODUCTION' in app.config or not app.config['PRODUCTION']: - - ldap_info = LdapService().user_info(uid) - return _handle_login(ldap_info, redirect) - else: - raise ApiError('404', 'unknown') +def _is_production(): + return 'PRODUCTION' in app.config and app.config['PRODUCTION'] diff --git a/crc/api/workflow.py b/crc/api/workflow.py index 8b3758d8..dbeff01b 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -127,7 +127,7 @@ def __get_workflow_api_model(processor: WorkflowProcessor, next_task = None): workflow_spec_id=processor.workflow_spec_id, spec_version=processor.get_version_string(), is_latest_spec=processor.is_latest_spec, - total_tasks=processor.workflow_model.total_tasks, + total_tasks=len(navigation), completed_tasks=processor.workflow_model.completed_tasks, last_updated=processor.workflow_model.last_updated ) @@ -235,4 +235,4 @@ def lookup(workflow_id, field_id, query, limit): """ workflow = session.query(WorkflowModel).filter(WorkflowModel.id == workflow_id).first() lookup_data = LookupService.lookup(workflow, field_id, query, limit) - return LookupDataSchema(many=True).dump(lookup_data) \ No newline at end of file + return LookupDataSchema(many=True).dump(lookup_data) diff --git a/tests/base_test.py b/tests/base_test.py index f0418343..e3245ebb 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -107,7 +107,7 @@ class BaseTest(unittest.TestCase): user_info = {'uid': user.uid} query_string = self.user_info_to_query_string(user_info, redirect_url) - rv = self.app.get("/v1.0/sso_backdoor%s" % query_string, follow_redirects=False) + rv = self.app.get("/v1.0/login%s" % query_string, follow_redirects=False) self.assertTrue(rv.status_code == 302) self.assertTrue(str.startswith(rv.location, redirect_url)) @@ -199,7 +199,7 @@ class BaseTest(unittest.TestCase): for key, value in items: query_string_list.append('%s=%s' % (key, urllib.parse.quote(value))) - query_string_list.append('redirect=%s' % redirect_url) + query_string_list.append('redirect_url=%s' % redirect_url) return '?%s' % '&'.join(query_string_list) diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 11b77d07..db0d25df 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -1,7 +1,11 @@ -from tests.base_test import BaseTest +import json +from datetime import timezone, datetime -from crc import db +from crc import db, app +from crc.models.study import StudySchema from crc.models.user import UserModel +from models.protocol_builder import ProtocolBuilderStatus +from tests.base_test import BaseTest class TestAuthentication(BaseTest): @@ -13,17 +17,17 @@ class TestAuthentication(BaseTest): self.assertTrue(isinstance(auth_token, bytes)) self.assertEqual("dhf8r", user.decode_auth_token(auth_token).get("sub")) - def test_backdoor_auth_creates_user(self): - new_uid = 'lb3dp' ## Assure this user id is in the fake responses from ldap. + def test_non_production_auth_creates_user(self): + new_uid = 'lb3dp' ## Assure this user id is in the fake responses from ldap. self.load_example_data() user = db.session.query(UserModel).filter(UserModel.uid == new_uid).first() self.assertIsNone(user) user_info = {'uid': new_uid, 'first_name': 'Cordi', 'last_name': 'Nator', - 'email_address': 'czn1z@virginia.edu'} + 'email_address': 'czn1z@virginia.edu'} redirect_url = 'http://worlds.best.website/admin' query_string = self.user_info_to_query_string(user_info, redirect_url) - url = '/v1.0/sso_backdoor%s' % query_string + url = '/v1.0/login%s' % query_string rv_1 = self.app.get(url, follow_redirects=False) self.assertTrue(rv_1.status_code == 302) self.assertTrue(str.startswith(rv_1.location, redirect_url)) @@ -38,14 +42,19 @@ class TestAuthentication(BaseTest): self.assertTrue(rv_2.status_code == 302) self.assertTrue(str.startswith(rv_2.location, redirect_url)) - def test_normal_auth_creates_user(self): - new_uid = 'lb3dp' # This user is in the test ldap system. + def test_production_auth_creates_user(self): + # Switch production mode on + app.config['PRODUCTION'] = True + + new_uid = 'lb3dp' # This user is in the test ldap system. self.load_example_data() user = db.session.query(UserModel).filter(UserModel.uid == new_uid).first() self.assertIsNone(user) redirect_url = 'http://worlds.best.website/admin' headers = dict(Uid=new_uid) + rv = self.app.get('v1.0/login', follow_redirects=False, headers=headers) + self.assert_success(rv) user = db.session.query(UserModel).filter(UserModel.uid == new_uid).first() self.assertIsNotNone(user) @@ -54,6 +63,8 @@ class TestAuthentication(BaseTest): self.assertEquals("lb3dp@virginia.edu", user.email_address) self.assertEquals("E0:Associate Professor of Systems and Information Engineering", user.title) + # Switch production mode back off + app.config['PRODUCTION'] = False def test_current_user_status(self): self.load_example_data() @@ -67,3 +78,89 @@ class TestAuthentication(BaseTest): user = UserModel(uid="dhf8r", first_name='Dan', last_name='Funk', email_address='dhf8r@virginia.edu') rv = self.app.get('/v1.0/user', headers=self.logged_in_headers(user, redirect_url='http://omg.edu/lolwut')) self.assert_success(rv) + + def test_admin_only_endpoints(self): + # Switch production mode on + app.config['PRODUCTION'] = True + + admin_uids = app.config['ADMIN_UIDS'] + self.assertGreater(len(admin_uids), 0) + + for uid in admin_uids: + admin_headers = dict(Uid=uid) + + rv = self.app.get( + 'v1.0/login', + follow_redirects=False, + headers=admin_headers + ) + self.assert_success(rv) + + admin_user = db.session.query(UserModel).filter_by(uid=uid).first() + self.assertIsNotNone(admin_user) + + admin_study = self._make_fake_study(uid) + print('admin_study', admin_study) + + rv_add_study = self.app.post( + '/v1.0/study', + content_type="application/json", + headers=self.logged_in_headers(user=admin_user), + data=json.dumps(StudySchema().dump(admin_study)) + ) + self.assert_success(rv_add_study, 'Admin user should be able to add a study') + + new_study = json.loads(rv.get_data(as_text=True)) + + rv_del_study = self.app.delete( + '/v1.0/study/%i' % new_study.id, + follow_redirects=False, + headers=self.logged_in_headers(user=admin_user) + ) + self.assert_success(rv_del_study, 'Admin user should be able to delete a study') + + + # Non-admin user should not be able to delete a study + non_admin_uid = 'lb3dp' + non_admin_headers = dict(Uid=non_admin_uid) + + rv = self.app.get( + 'v1.0/login', + follow_redirects=False, + headers=non_admin_headers + ) + self.assert_success(rv) + + non_admin_user = db.session.query(UserModel).filter_by(uid=non_admin_uid).first() + self.assertIsNotNone(non_admin_user) + non_admin_study = self._make_fake_study(non_admin_uid) + + rv_add_study = self.app.post( + '/v1.0/study', + content_type="application/json", + headers=self.logged_in_headers(user=non_admin_user), + data=json.dumps(StudySchema().dump(non_admin_study)) + ) + self.assert_success(rv_add_study, 'Non-admin user should be able to add a study') + + new_study = json.loads(rv.get_data(as_text=True)) + + rv_del_study = self.app.delete( + '/v1.0/study/%i' % new_study.id, + follow_redirects=False, + headers=self.logged_in_headers(user=non_admin_user) + ) + self.assert_failure(rv_del_study, 'Non-admin user should not be able to delete a study') + + # Switch production mode back off + app.config['PRODUCTION'] = False + + + def _make_fake_study(self, uid): + return { + "title": "blah", + "last_updated": datetime.now(tz=timezone.utc), + "protocol_builder_status": ProtocolBuilderStatus.ACTIVE, + "primary_investigator_id": uid, + "user_uid": uid, + } From 73137d0858c47190fb3540f5b0b48755f0b38f5b Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Sun, 31 May 2020 17:18:07 -0400 Subject: [PATCH 09/76] If an assertion fails, the tests stop at that point, never reaching the last line in the test. You have to handle any tear down, in the tearDown method. --- tests/test_authentication.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_authentication.py b/tests/test_authentication.py index db0d25df..bdad776a 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -1,15 +1,20 @@ import json from datetime import timezone, datetime +from tests.base_test import BaseTest from crc import db, app from crc.models.study import StudySchema from crc.models.user import UserModel -from models.protocol_builder import ProtocolBuilderStatus -from tests.base_test import BaseTest +from crc.models.protocol_builder import ProtocolBuilderStatus class TestAuthentication(BaseTest): + def tearDown(self): + # Assure we set the production flag back to false. + app.config['PRODUCTION'] = False + super().tearDown() + def test_auth_token(self): self.load_example_data() user = UserModel(uid="dhf8r") From c4a84ac509a38fadde2ffc28990db20138cd66b1 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sun, 31 May 2020 18:01:08 -0400 Subject: [PATCH 10/76] Work in progress: Trying to get Swagger to use verify_token_admin to protect admin endpoints. Not working for some reason, though. --- crc/api/user.py | 20 +++++++++++++------- tests/base_test.py | 4 ++++ tests/test_authentication.py | 10 +++++----- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/crc/api/user.py b/crc/api/user.py index 004d1420..c0118f54 100644 --- a/crc/api/user.py +++ b/crc/api/user.py @@ -53,7 +53,7 @@ def verify_token(token=None): # If there's no token and we're in production, get the user from the SSO headers and return their token if not token and _is_production(): - uid = _get_request_uid() + uid = _get_request_uid(request) if uid is not None: db_user = UserModel.query.filter_by(uid=uid).first() @@ -87,7 +87,7 @@ def verify_token_admin(token=None): # If this is production, check that the user is in the list of admins if _is_production(): - uid = _get_request_uid() + uid = _get_request_uid(request) print('verify_token_admin uid', uid) @@ -148,7 +148,9 @@ def login( # If we're in production, override any uid with the uid from the SSO request headers if _is_production(): - uid = _get_request_uid() + uid = _get_request_uid(request) + + print('login > uid', uid) if uid: app.logger.info("SSO_LOGIN: Full URL: " + request.url) @@ -218,15 +220,19 @@ def _handle_login(user_info: LdapUserInfo, redirect_url=None): return auth_token -def _get_request_uid(uid=None): +def _get_request_uid(req): + uid = None + if _is_production(): - uid = request.headers.get("Uid") + + print('req.headers', req.headers) + uid = req.headers.get("Uid") if not uid: - uid = request.headers.get("X-Remote-Uid") + uid = req.headers.get("X-Remote-Uid") if not uid: raise ApiError("invalid_sso_credentials", "'Uid' nor 'X-Remote-Uid' were present in the headers: %s" - % str(request.headers)) + % str(req.headers)) return uid diff --git a/tests/base_test.py b/tests/base_test.py index e3245ebb..2f9c721a 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -107,7 +107,11 @@ class BaseTest(unittest.TestCase): user_info = {'uid': user.uid} query_string = self.user_info_to_query_string(user_info, redirect_url) + print('query_string', query_string) rv = self.app.get("/v1.0/login%s" % query_string, follow_redirects=False) + + print('rv.status_code', rv.status_code) + self.assertTrue(rv.status_code == 302) self.assertTrue(str.startswith(rv.location, redirect_url)) diff --git a/tests/test_authentication.py b/tests/test_authentication.py index bdad776a..a27c7bb1 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -87,6 +87,7 @@ class TestAuthentication(BaseTest): def test_admin_only_endpoints(self): # Switch production mode on app.config['PRODUCTION'] = True + self.load_example_data() admin_uids = app.config['ADMIN_UIDS'] self.assertGreater(len(admin_uids), 0) @@ -105,12 +106,11 @@ class TestAuthentication(BaseTest): self.assertIsNotNone(admin_user) admin_study = self._make_fake_study(uid) - print('admin_study', admin_study) rv_add_study = self.app.post( '/v1.0/study', content_type="application/json", - headers=self.logged_in_headers(user=admin_user), + headers=admin_headers, data=json.dumps(StudySchema().dump(admin_study)) ) self.assert_success(rv_add_study, 'Admin user should be able to add a study') @@ -120,7 +120,7 @@ class TestAuthentication(BaseTest): rv_del_study = self.app.delete( '/v1.0/study/%i' % new_study.id, follow_redirects=False, - headers=self.logged_in_headers(user=admin_user) + headers=admin_headers ) self.assert_success(rv_del_study, 'Admin user should be able to delete a study') @@ -143,7 +143,7 @@ class TestAuthentication(BaseTest): rv_add_study = self.app.post( '/v1.0/study', content_type="application/json", - headers=self.logged_in_headers(user=non_admin_user), + headers=non_admin_headers, data=json.dumps(StudySchema().dump(non_admin_study)) ) self.assert_success(rv_add_study, 'Non-admin user should be able to add a study') @@ -153,7 +153,7 @@ class TestAuthentication(BaseTest): rv_del_study = self.app.delete( '/v1.0/study/%i' % new_study.id, follow_redirects=False, - headers=self.logged_in_headers(user=non_admin_user) + headers=non_admin_headers ) self.assert_failure(rv_del_study, 'Non-admin user should not be able to delete a study') From 2e54f070959d23b0a73a76fb6026257b9fca53ed Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Sun, 31 May 2020 17:24:23 -0600 Subject: [PATCH 11/76] Adding more info to files and renaming approval waiting status --- crc/api/approval.py | 1 + crc/models/approval.py | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crc/api/approval.py b/crc/api/approval.py index 739773c1..adc1fc78 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -11,6 +11,7 @@ def get_approvals(approver_uid = None): else: db_approvals = ApprovalService.get_approvals_per_user(approver_uid) approvals = [Approval.from_model(approval_model) for approval_model in db_approvals] + results = ApprovalSchema(many=True).dump(approvals) return results diff --git a/crc/models/approval.py b/crc/models/approval.py index c469025b..497a6bbf 100644 --- a/crc/models/approval.py +++ b/crc/models/approval.py @@ -11,6 +11,7 @@ from crc.models.file import FileDataModel from crc.models.study import StudyModel from crc.models.workflow import WorkflowModel from crc.services.ldap_service import LdapService +from crc.services.file_service import FileService class ApprovalStatus(enum.Enum): @@ -84,11 +85,29 @@ class Approval(object): instance.approver['title'] = user_info.title instance.approver['department'] = user_info.department + # TODO: Organize it properly, move it to services + doc_dictionary = FileService.get_reference_data(FileService.DOCUMENT_LIST, 'code', ['id']) + instance.associated_files = [] for approval_file in model.approval_files: + try: + extra_info = doc_dictionary[approval_file.file_data.file_model.irb_doc_code] + except: + extra_info = None associated_file = {} associated_file['id'] = approval_file.file_data.file_model.id - associated_file['name'] = approval_file.file_data.file_model.name + if extra_info: + categories = [extra_info['category1'], extra_info['category2'], extra_info['category3']] + # Clear empty values + categories = list(filter(lambda x: x != '' and x != 'NULL', categories)) + # Replace spaces with underscores and lowercase + categories = ['_'.join(category.split()) for category in categories] + categories = '_'.join(categories).lower() + associated_file['name'] = '_'.join((categories, approval_file.file_data.file_model.name)) + associated_file['description'] = extra_info + else: + associated_file['name'] = approval_file.file_data.file_model.name + associated_file['description'] = 'No description available' associated_file['content_type'] = approval_file.file_data.file_model.content_type instance.associated_files.append(associated_file) From dd6c1d2b42a12cd88bf3469be1d876219b075ef8 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Sun, 31 May 2020 18:16:42 -0600 Subject: [PATCH 12/76] Renaming approval files --- crc/models/approval.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/crc/models/approval.py b/crc/models/approval.py index 497a6bbf..1f7eed38 100644 --- a/crc/models/approval.py +++ b/crc/models/approval.py @@ -68,10 +68,10 @@ class Approval(object): if model.study: instance.title = model.study.title + principal_investigator_id = model.study.primary_investigator_id instance.approver = {} try: ldap_service = LdapService() - principal_investigator_id = model.study.primary_investigator_id user_info = ldap_service.user_info(principal_investigator_id) except (ApiError, LDAPSocketOpenError) as exception: user_info = None @@ -97,17 +97,13 @@ class Approval(object): associated_file = {} associated_file['id'] = approval_file.file_data.file_model.id if extra_info: - categories = [extra_info['category1'], extra_info['category2'], extra_info['category3']] - # Clear empty values - categories = list(filter(lambda x: x != '' and x != 'NULL', categories)) - # Replace spaces with underscores and lowercase - categories = ['_'.join(category.split()) for category in categories] - categories = '_'.join(categories).lower() - associated_file['name'] = '_'.join((categories, approval_file.file_data.file_model.name)) - associated_file['description'] = extra_info + irb_doc_code = approval_file.file_data.file_model.irb_doc_code + associated_file['name'] = '_'.join((irb_doc_code, approval_file.file_data.file_model.name)) + associated_file['description'] = extra_info['description'] else: associated_file['name'] = approval_file.file_data.file_model.name associated_file['description'] = 'No description available' + associated_file['name'] = '(' + principal_investigator_id + ')' + associated_file['name'] associated_file['content_type'] = approval_file.file_data.file_model.content_type instance.associated_files.append(associated_file) From 9c7de39b094d3cd0df9eaa3f8e4778a7e2d61184 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Sun, 31 May 2020 21:15:40 -0400 Subject: [PATCH 13/76] This adds additional file data details to the study model as well. --- crc/api/file.py | 5 +++-- crc/models/file.py | 14 ++++++++++++-- crc/models/study.py | 11 ++++++----- crc/services/file_service.py | 8 ++++++++ crc/services/study_service.py | 8 ++++++-- tests/test_study_api.py | 29 +++++++++++++++++++++++++++-- 6 files changed, 62 insertions(+), 13 deletions(-) diff --git a/crc/api/file.py b/crc/api/file.py index 07ced388..a537cfe5 100644 --- a/crc/api/file.py +++ b/crc/api/file.py @@ -12,8 +12,9 @@ from crc.services.file_service import FileService def to_file_api(file_model): - """Converts a FileModel object to something we can return via the aip""" - return File.from_models(file_model, FileService.get_file_data(file_model.id)) + """Converts a FileModel object to something we can return via the api""" + return File.from_models(file_model, FileService.get_file_data(file_model.id), + FileService.get_doc_dictionary()) def get_files(workflow_spec_id=None, workflow_id=None, form_field_key=None): diff --git a/crc/models/file.py b/crc/models/file.py index 184979e6..9cbfb7fc 100644 --- a/crc/models/file.py +++ b/crc/models/file.py @@ -86,7 +86,7 @@ class FileModel(db.Model): class File(object): @classmethod - def from_models(cls, model: FileModel, data_model: FileDataModel): + def from_models(cls, model: FileModel, data_model: FileDataModel, doc_dictionary): instance = cls() instance.id = model.id instance.name = model.name @@ -99,6 +99,15 @@ class File(object): instance.workflow_id = model.workflow_id instance.irb_doc_code = model.irb_doc_code instance.type = model.type + if model.irb_doc_code and model.irb_doc_code in doc_dictionary: + instance.category = "/".join(filter(None, [doc_dictionary[model.irb_doc_code]['category1'], + doc_dictionary[model.irb_doc_code]['category2'], + doc_dictionary[model.irb_doc_code]['category3']])) + instance.description = doc_dictionary[model.irb_doc_code]['description'] + instance.download_name = ".".join([instance.category, model.type.value]) + else: + instance.category = "" + instance.description = "" if data_model: instance.last_modified = data_model.date_created instance.latest_version = data_model.version @@ -122,7 +131,8 @@ class FileSchema(ma.Schema): model = File fields = ["id", "name", "is_status", "is_reference", "content_type", "primary", "primary_process_id", "workflow_spec_id", "workflow_id", - "irb_doc_code", "last_modified", "latest_version", "type"] + "irb_doc_code", "last_modified", "latest_version", "type", "categories", + "description", "category", "description", "download_name"] unknown = INCLUDE type = EnumField(FileType) diff --git a/crc/models/study.py b/crc/models/study.py index 38bd2f3b..21a670fe 100644 --- a/crc/models/study.py +++ b/crc/models/study.py @@ -5,7 +5,7 @@ from sqlalchemy import func from crc import db, ma from crc.api.common import ApiErrorSchema -from crc.models.file import FileModel, SimpleFileSchema +from crc.models.file import FileModel, SimpleFileSchema, FileSchema from crc.models.protocol_builder import ProtocolBuilderStatus, ProtocolBuilderStudy from crc.models.workflow import WorkflowSpecCategoryModel, WorkflowState, WorkflowStatus, WorkflowSpecModel, \ WorkflowModel @@ -106,7 +106,8 @@ class Study(object): def __init__(self, title, last_updated, primary_investigator_id, user_uid, id=None, protocol_builder_status=None, - sponsor="", hsr_number="", ind_number="", categories=[], **argsv): + sponsor="", hsr_number="", ind_number="", categories=[], + files=[], **argsv): self.id = id self.user_uid = user_uid self.title = title @@ -118,7 +119,7 @@ class Study(object): self.ind_number = ind_number self.categories = categories self.warnings = [] - self.files = [] + self.files = files @classmethod def from_model(cls, study_model: StudyModel): @@ -149,12 +150,12 @@ class StudySchema(ma.Schema): hsr_number = fields.String(allow_none=True) sponsor = fields.String(allow_none=True) ind_number = fields.String(allow_none=True) - files = fields.List(fields.Nested(SimpleFileSchema), dump_only=True) + files = fields.List(fields.Nested(FileSchema), dump_only=True) class Meta: model = Study additional = ["id", "title", "last_updated", "primary_investigator_id", "user_uid", - "sponsor", "ind_number"] + "sponsor", "ind_number", "files"] unknown = INCLUDE @marshmallow.post_load diff --git a/crc/services/file_service.py b/crc/services/file_service.py index 273460c1..9142a7c3 100644 --- a/crc/services/file_service.py +++ b/crc/services/file_service.py @@ -22,6 +22,14 @@ class FileService(object): DOCUMENT_LIST = "irb_documents.xlsx" INVESTIGATOR_LIST = "investigators.xlsx" + __doc_dictionary = None + + @staticmethod + def get_doc_dictionary(): + if not FileService.__doc_dictionary: + FileService.__doc_dictionary = FileService.get_reference_data(FileService.DOCUMENT_LIST, 'code', ['id']) + return FileService.__doc_dictionary + @staticmethod def add_workflow_spec_file(workflow_spec: WorkflowSpecModel, name, content_type, binary_data, primary=False, is_status=False): diff --git a/crc/services/study_service.py b/crc/services/study_service.py index 6dea83a9..1d61c5c8 100644 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -9,7 +9,7 @@ from ldap3.core.exceptions import LDAPSocketOpenError from crc import db, session, app from crc.api.common import ApiError -from crc.models.file import FileModel, FileModelSchema +from crc.models.file import FileModel, FileModelSchema, File from crc.models.protocol_builder import ProtocolBuilderStudy, ProtocolBuilderStatus from crc.models.stats import TaskEventModel from crc.models.study import StudyModel, Study, Category, WorkflowMetadata @@ -54,7 +54,11 @@ class StudyService(object): study = Study.from_model(study_model) study.categories = StudyService.get_categories() workflow_metas = StudyService.__get_workflow_metas(study_id) - study.files = FileService.get_files_for_study(study.id) + + files = FileService.get_files_for_study(study.id) + files = (File.from_models(model, FileService.get_file_data(model.id), + FileService.get_doc_dictionary()) for model in files) + study.files = list(files) # Calling this line repeatedly is very very slow. It creates the # master spec and runs it. diff --git a/tests/test_study_api.py b/tests/test_study_api.py index 7282ac10..2f72a7a7 100644 --- a/tests/test_study_api.py +++ b/tests/test_study_api.py @@ -1,5 +1,6 @@ import json from tests.base_test import BaseTest + from datetime import datetime, timezone from unittest.mock import patch @@ -8,8 +9,9 @@ from crc.models.protocol_builder import ProtocolBuilderStatus, \ ProtocolBuilderStudySchema from crc.models.stats import TaskEventModel from crc.models.study import StudyModel, StudySchema -from crc.models.workflow import WorkflowSpecModel, WorkflowModel, WorkflowSpecCategoryModel -from crc.services.protocol_builder import ProtocolBuilderService +from crc.models.workflow import WorkflowSpecModel, WorkflowModel +from crc.services.file_service import FileService +from crc.services.workflow_processor import WorkflowProcessor class TestStudyApi(BaseTest): @@ -68,6 +70,29 @@ class TestStudyApi(BaseTest): self.assertEqual(0, workflow["total_tasks"]) self.assertEqual(0, workflow["completed_tasks"]) + def test_get_study_has_details_about_files(self): + + # Set up the study and attach a file to it. + self.load_example_data() + self.create_reference_document() + workflow = self.create_workflow('file_upload_form') + processor = WorkflowProcessor(workflow) + task = processor.next_task() + irb_code = "UVACompl_PRCAppr" # The first file referenced in pb required docs. + FileService.add_workflow_file(workflow_id=workflow.id, + name="anything.png", content_type="png", + binary_data=b'1234', irb_doc_code=irb_code) + + api_response = self.app.get('/v1.0/study/%i' % workflow.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.assertEquals(1, len(study.files)) + self.assertEquals("UVA Compliance/PRC Approval", study.files[0]["category"]) + self.assertEquals("Cancer Center's PRC Approval Form", study.files[0]["description"]) + self.assertEquals("UVA Compliance/PRC Approval.png", study.files[0]["download_name"]) + + def test_add_study(self): self.load_example_data() study = self.add_test_study() From f0bd8d4f9eb74a9bc96cf0102fbe6f9d518510a8 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sun, 31 May 2020 22:46:17 -0400 Subject: [PATCH 14/76] Adds approvals to study schema. Adds approvals endpoint --- crc/api.yml | 24 ++++++++++++++++++++++++ crc/api/approval.py | 10 +++++++++- crc/api/study.py | 6 ++---- crc/models/api_models.py | 7 ++++--- crc/models/study.py | 8 +++++--- crc/services/approval_service.py | 6 ++++++ crc/services/study_service.py | 4 ++++ 7 files changed, 54 insertions(+), 11 deletions(-) diff --git a/crc/api.yml b/crc/api.yml index edc3861b..758169b7 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -173,6 +173,30 @@ paths: application/json: schema: $ref: "#/components/schemas/Study" + /study/{study_id}/approvals: + parameters: + - name: study_id + in: path + required: true + description: The id of the study for which workflows should be returned. + schema: + type: integer + format: int32 + get: + operationId: crc.api.approval.get_approvals_for_study + summary: Returns approvals for a single study + tags: + - Studies + - Approvals + responses: + '200': + description: An array of approvals + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Approval" /workflow-specification: get: operationId: crc.api.workflow.all_specifications diff --git a/crc/api/approval.py b/crc/api/approval.py index adc1fc78..32238cf0 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -5,7 +5,7 @@ from crc.models.approval import Approval, ApprovalModel, ApprovalSchema from crc.services.approval_service import ApprovalService -def get_approvals(approver_uid = None): +def get_approvals(approver_uid=None): if not approver_uid: db_approvals = ApprovalService.get_all_approvals() else: @@ -15,6 +15,14 @@ def get_approvals(approver_uid = None): results = ApprovalSchema(many=True).dump(approvals) return results + +def get_approvals_for_study(study_id=None): + db_approvals = ApprovalService.get_approvals_for_study(study_id) + approvals = [Approval.from_model(approval_model) for approval_model in db_approvals] + results = ApprovalSchema(many=True).dump(approvals) + return results + + def update_approval(approval_id, body): if approval_id is None: raise ApiError('unknown_approval', 'Please provide a valid Approval ID.') diff --git a/crc/api/study.py b/crc/api/study.py index 423f6fe2..e9a251f8 100644 --- a/crc/api/study.py +++ b/crc/api/study.py @@ -48,12 +48,10 @@ def update_study(study_id, body): def get_study(study_id): - study_service = StudyService() - study = study_service.get_study(study_id) + study = StudyService.get_study(study_id) if (study is None): raise ApiError("Study not found", status_code=404) - schema = StudySchema() - return schema.dump(study) + return StudySchema().dump(study) def delete_study(study_id): diff --git a/crc/models/api_models.py b/crc/models/api_models.py index eee6d5f5..b8b535a7 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): + 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. @@ -130,13 +130,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) @@ -147,7 +148,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/models/study.py b/crc/models/study.py index 21a670fe..1e1cf440 100644 --- a/crc/models/study.py +++ b/crc/models/study.py @@ -107,7 +107,7 @@ class Study(object): id=None, protocol_builder_status=None, sponsor="", hsr_number="", ind_number="", categories=[], - files=[], **argsv): + files=[], approvals=[], **argsv): self.id = id self.user_uid = user_uid self.title = title @@ -118,6 +118,7 @@ class Study(object): self.hsr_number = hsr_number self.ind_number = ind_number self.categories = categories + self.approvals = approvals self.warnings = [] self.files = files @@ -150,12 +151,13 @@ class StudySchema(ma.Schema): hsr_number = fields.String(allow_none=True) sponsor = fields.String(allow_none=True) ind_number = fields.String(allow_none=True) - files = fields.List(fields.Nested(FileSchema), dump_only=True) + files = fields.List(fields.Nested(SimpleFileSchema), dump_only=True) + approvals = fields.List(fields.Nested('ApprovalSchema'), dump_only=True) class Meta: model = Study additional = ["id", "title", "last_updated", "primary_investigator_id", "user_uid", - "sponsor", "ind_number", "files"] + "sponsor", "ind_number", "approvals", "files"] unknown = INCLUDE @marshmallow.post_load diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index bd272585..39886d62 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -19,6 +19,12 @@ class ApprovalService(object): db_approvals = session.query(ApprovalModel).filter_by(approver_uid=approver_uid).all() return db_approvals + @staticmethod + def get_approvals_for_study(study_id): + """Returns a list of all approvals for the given study""" + db_approvals = session.query(ApprovalModel).filter_by(study_id=study_id).all() + return db_approvals + @staticmethod def get_all_approvals(): """Returns a list of all approvlas""" diff --git a/crc/services/study_service.py b/crc/services/study_service.py index 1d61c5c8..8fd99109 100644 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -19,6 +19,8 @@ from crc.services.file_service import FileService from crc.services.ldap_service import LdapService from crc.services.protocol_builder import ProtocolBuilderService from crc.services.workflow_processor import WorkflowProcessor +from crc.services.approval_service import ApprovalService +from crc.models.approval import ApprovalSchema class StudyService(object): @@ -54,6 +56,8 @@ class StudyService(object): study = Study.from_model(study_model) study.categories = StudyService.get_categories() workflow_metas = StudyService.__get_workflow_metas(study_id) + study.files = FileService.get_files_for_study(study.id) + study.approvals = ApprovalService.get_approvals_for_study(study.id) files = FileService.get_files_for_study(study.id) files = (File.from_models(model, FileService.get_file_data(model.id), From cd16b984e00fef328ec31682dd161e5b75424af2 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sun, 31 May 2020 22:46:56 -0400 Subject: [PATCH 15/76] Adds workflow spec title to workflow api schema --- crc/api/workflow.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crc/api/workflow.py b/crc/api/workflow.py index 8b3758d8..81252056 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -119,6 +119,8 @@ def __get_workflow_api_model(processor: WorkflowProcessor, next_task = None): 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(), @@ -129,7 +131,8 @@ def __get_workflow_api_model(processor: WorkflowProcessor, next_task = None): is_latest_spec=processor.is_latest_spec, total_tasks=processor.workflow_model.total_tasks, completed_tasks=processor.workflow_model.completed_tasks, - last_updated=processor.workflow_model.last_updated + 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. @@ -235,4 +238,4 @@ def lookup(workflow_id, field_id, query, limit): """ workflow = session.query(WorkflowModel).filter(WorkflowModel.id == workflow_id).first() lookup_data = LookupService.lookup(workflow, field_id, query, limit) - return LookupDataSchema(many=True).dump(lookup_data) \ No newline at end of file + return LookupDataSchema(many=True).dump(lookup_data) From b2e56f797b6c8306e268054b89a119d31de941b9 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Sun, 31 May 2020 21:02:47 -0600 Subject: [PATCH 16/76] Converting ApprovalModel to Approval in order to serialize properly the result --- crc/services/study_service.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crc/services/study_service.py b/crc/services/study_service.py index 8fd99109..5807fb24 100644 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -20,7 +20,7 @@ from crc.services.ldap_service import LdapService from crc.services.protocol_builder import ProtocolBuilderService from crc.services.workflow_processor import WorkflowProcessor from crc.services.approval_service import ApprovalService -from crc.models.approval import ApprovalSchema +from crc.models.approval import Approval class StudyService(object): @@ -57,7 +57,8 @@ class StudyService(object): study.categories = StudyService.get_categories() workflow_metas = StudyService.__get_workflow_metas(study_id) study.files = FileService.get_files_for_study(study.id) - study.approvals = ApprovalService.get_approvals_for_study(study.id) + approvals = ApprovalService.get_approvals_for_study(study.id) + study.approvals = [Approval.from_model(approval_model) for approval_model in approvals] files = FileService.get_files_for_study(study.id) files = (File.from_models(model, FileService.get_file_data(model.id), From 311e180c65f4945e3208ee51b0aedcb602c65bb1 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sun, 31 May 2020 23:16:14 -0400 Subject: [PATCH 17/76] Comments out failing test for now. Adds a placeholder test for study approvals --- tests/test_study_api.py | 42 +++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/tests/test_study_api.py b/tests/test_study_api.py index 2f72a7a7..10f4cb6e 100644 --- a/tests/test_study_api.py +++ b/tests/test_study_api.py @@ -72,26 +72,32 @@ class TestStudyApi(BaseTest): def test_get_study_has_details_about_files(self): - # Set up the study and attach a file to it. - self.load_example_data() - self.create_reference_document() - workflow = self.create_workflow('file_upload_form') - processor = WorkflowProcessor(workflow) - task = processor.next_task() - irb_code = "UVACompl_PRCAppr" # The first file referenced in pb required docs. - FileService.add_workflow_file(workflow_id=workflow.id, - name="anything.png", content_type="png", - binary_data=b'1234', irb_doc_code=irb_code) + # # Set up the study and attach a file to it. + # self.load_example_data() + # self.create_reference_document() + # workflow = self.create_workflow('file_upload_form') + # processor = WorkflowProcessor(workflow) + # task = processor.next_task() + # irb_code = "UVACompl_PRCAppr" # The first file referenced in pb required docs. + # FileService.add_workflow_file(workflow_id=workflow.id, + # name="anything.png", content_type="png", + # binary_data=b'1234', irb_doc_code=irb_code) + # + # api_response = self.app.get('/v1.0/study/%i' % workflow.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.assertEquals(1, len(study.files)) + # self.assertEquals("UVA Compliance/PRC Approval", study.files[0]["category"]) + # self.assertEquals("Cancer Center's PRC Approval Form", study.files[0]["description"]) + # self.assertEquals("UVA Compliance/PRC Approval.png", study.files[0]["download_name"]) - api_response = self.app.get('/v1.0/study/%i' % workflow.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.assertEquals(1, len(study.files)) - self.assertEquals("UVA Compliance/PRC Approval", study.files[0]["category"]) - self.assertEquals("Cancer Center's PRC Approval Form", study.files[0]["description"]) - self.assertEquals("UVA Compliance/PRC Approval.png", study.files[0]["download_name"]) + # TODO: WRITE A TEST FOR STUDY FILES + pass + def test_get_study_has_details_about_approvals(self): + # TODO: WRITE A TEST FOR STUDY APPROVALS + pass def test_add_study(self): self.load_example_data() From bec11980eb589b4a8b0b56a6253b16ff2c6829cf Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Sun, 31 May 2020 22:00:52 -0600 Subject: [PATCH 18/76] Fixing broken test by using proper FileSchema --- crc/models/study.py | 2 +- crc/services/study_service.py | 1 - tests/test_study_api.py | 39 +++++++++++++++++------------------ 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/crc/models/study.py b/crc/models/study.py index 1e1cf440..540ee018 100644 --- a/crc/models/study.py +++ b/crc/models/study.py @@ -151,7 +151,7 @@ class StudySchema(ma.Schema): hsr_number = fields.String(allow_none=True) sponsor = fields.String(allow_none=True) ind_number = fields.String(allow_none=True) - files = fields.List(fields.Nested(SimpleFileSchema), dump_only=True) + files = fields.List(fields.Nested(FileSchema), dump_only=True) approvals = fields.List(fields.Nested('ApprovalSchema'), dump_only=True) class Meta: diff --git a/crc/services/study_service.py b/crc/services/study_service.py index 5807fb24..e6ef5291 100644 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -56,7 +56,6 @@ class StudyService(object): study = Study.from_model(study_model) study.categories = StudyService.get_categories() workflow_metas = StudyService.__get_workflow_metas(study_id) - study.files = FileService.get_files_for_study(study.id) approvals = ApprovalService.get_approvals_for_study(study.id) study.approvals = [Approval.from_model(approval_model) for approval_model in approvals] diff --git a/tests/test_study_api.py b/tests/test_study_api.py index 10f4cb6e..61e42543 100644 --- a/tests/test_study_api.py +++ b/tests/test_study_api.py @@ -72,28 +72,27 @@ class TestStudyApi(BaseTest): def test_get_study_has_details_about_files(self): - # # Set up the study and attach a file to it. - # self.load_example_data() - # self.create_reference_document() - # workflow = self.create_workflow('file_upload_form') - # processor = WorkflowProcessor(workflow) - # task = processor.next_task() - # irb_code = "UVACompl_PRCAppr" # The first file referenced in pb required docs. - # FileService.add_workflow_file(workflow_id=workflow.id, - # name="anything.png", content_type="png", - # binary_data=b'1234', irb_doc_code=irb_code) - # - # api_response = self.app.get('/v1.0/study/%i' % workflow.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.assertEquals(1, len(study.files)) - # self.assertEquals("UVA Compliance/PRC Approval", study.files[0]["category"]) - # self.assertEquals("Cancer Center's PRC Approval Form", study.files[0]["description"]) - # self.assertEquals("UVA Compliance/PRC Approval.png", study.files[0]["download_name"]) + # Set up the study and attach a file to it. + self.load_example_data() + self.create_reference_document() + workflow = self.create_workflow('file_upload_form') + processor = WorkflowProcessor(workflow) + task = processor.next_task() + irb_code = "UVACompl_PRCAppr" # The first file referenced in pb required docs. + FileService.add_workflow_file(workflow_id=workflow.id, + name="anything.png", content_type="png", + binary_data=b'1234', irb_doc_code=irb_code) + + api_response = self.app.get('/v1.0/study/%i' % workflow.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.assertEquals(1, len(study.files)) + self.assertEquals("UVA Compliance/PRC Approval", study.files[0]["category"]) + self.assertEquals("Cancer Center's PRC Approval Form", study.files[0]["description"]) + self.assertEquals("UVA Compliance/PRC Approval.png", study.files[0]["download_name"]) # TODO: WRITE A TEST FOR STUDY FILES - pass def test_get_study_has_details_about_approvals(self): # TODO: WRITE A TEST FOR STUDY APPROVALS From 2d5eb740efa766668abacd88de2e0e29859d3fe9 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 1 Jun 2020 00:07:50 -0400 Subject: [PATCH 19/76] Updates RRTworkflow spec files --- .../research_rampup/ResearchRampUpPlan.docx | Bin 58518 -> 58311 bytes .../bpmn/research_rampup/research_rampup.bpmn | 242 +++++++----------- 2 files changed, 96 insertions(+), 146 deletions(-) diff --git a/crc/static/bpmn/research_rampup/ResearchRampUpPlan.docx b/crc/static/bpmn/research_rampup/ResearchRampUpPlan.docx index 2ff0ed801e4f0fa2ffcfc06ef5901eb86ef1689f..0c555fdb102d4f49e72dfc17265a2d7db360182b 100644 GIT binary patch delta 22049 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 diff --git a/crc/static/bpmn/research_rampup/research_rampup.bpmn b/crc/static/bpmn/research_rampup/research_rampup.bpmn index 93c4dd6d..cfd41423 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 @@ -61,9 +61,6 @@ Enter the following information for the PI submitting this request - - - @@ -127,7 +124,7 @@ Enter the following information for the PI submitting this request - + @@ -139,7 +136,7 @@ Enter the following information for the PI submitting this request #### People for whom you are requesting access -Provide information on all researchers you are requesting approval for reentry into the previously entered lab, workspace and/or office space(s) for conducting research on-Grounds. (If there are personnel already working in the space, include them). +Provide information on all researchers you are requesting approval for reentry into the previously entered lab/research and/or office space(s) for conducting research on-Grounds. (If there are personnel already working in the space, include them). **Note: no undergraduates will be allowed to work on-Grounds during Phase I.** @@ -159,21 +156,13 @@ No shared space entered {% endfor %} - - - - - - - - + - @@ -181,7 +170,7 @@ No shared space entered - + @@ -195,7 +184,7 @@ No shared space entered - + @@ -206,12 +195,17 @@ No shared space entered - + + + + + + Flow_1eiud85 @@ -258,7 +252,7 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a - + @@ -285,7 +279,7 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a - + @@ -313,7 +307,6 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a - @@ -355,9 +348,9 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a Submit one entry for each space the PI is the exclusive investigator. If all space is shared with one or more other investigators, Click Save to skip this section and proceed to the Shared Space section. - + - + @@ -376,7 +369,7 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - + @@ -384,18 +377,17 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - - + - + @@ -422,12 +414,11 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - + - @@ -465,7 +456,7 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - + #### Distancing requirements: Maintain social distancing by designing space between people to be at least 9 feet during prolonged work which will be accomplished by restricting the number of people in the lab to a density of ~250 sq. ft. /person in lab areas. When moving around, a minimum of 6 feet social distancing is required. Ideally only one person per lab bench and not more than one person can work at the same time in the same bay. @@ -488,7 +479,7 @@ Maintain social distancing by designing space between people to be at least 9 fe - Flow_097fpi3 + Flow_1nbjr72 Flow_0p2r1bo Flow_0mkh1wn Flow_1yqkpgu @@ -501,7 +492,8 @@ Maintain social distancing by designing space between people to be at least 9 fe - Indicate total square footage for every lab/space that you are requesting adding personnel to in this application. If you would like help obtaining a floor plan for your lab, your department or deans office can help. You can also create a hand drawing/block diagram of your space and the location of objects on a graph paper. - Upload your physical layout and workspace organization in the form of a jpg image or a pdf file. This can be hand-drawn or actual floor plans. - Show and/or describe designated work location for each member (during their shift) in the lab when multiple members are present at a time to meet the distancing guidelines. -- Provide a foot traffic plan (on the schematic) to indicate how people can move around while maintaining distancing requirements. This can be a freeform sketch on your floor plan showing where foot traffic can occur in your lab, and conditions, if any, to ensure distancing at all times. (e.g., direction to walk around a lab bench, rules for using shared equipment located in the lab, certain areas of lab prohibited from access, etc.). +- Provide a foot traffic plan (on the schematic) to indicate how people can move around while maintaining distancing requirements. This can be a freeform sketch on your floor plan showing where foot traffic can occur in your lab, and conditions, if any, to ensure distancing at all times. (e.g., direction to walk around a lab bench, rules for using shared equipment located in the lab, certain areas of lab prohibited from access, etc.). +- Provide your initial weekly laboratory schedule (see excel template) for all members that you are requesting access for, indicating all shifts as necessary. If schedule changes, please submit your revised schedule through the web portal. @@ -520,7 +512,7 @@ Maintain social distancing by designing space between people to be at least 9 fe Flow_0zrsh65 - + #### Health Safety Requirements: Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/url?q=http://ehs.virginia.edu/files/Lab-Safety-Plan-During-COVID-19.docx&source=gmail&ust=1590687968958000&usg=AFQjCNE83uGDFtxGkKaxjuXGhTocu-FDmw) to create and upload a copy of your laboratory policy statement to all members which includes at a minimum the following details: - Laboratory face covering rules, use of other PPE use as required @@ -531,7 +523,7 @@ Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/ur - Where and how to obtain PPE including face covering - + Flow_1yqkpgu @@ -596,7 +588,7 @@ Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/ur - + Flow_0zrsh65 Flow_0tz5c2v @@ -604,16 +596,12 @@ Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/ur Flow_0qbi47d Flow_06873ag - - #### Script Task - - -This step is internal to the system and do not require and user interaction + Flow_06873ag Flow_0aqgwvu CompleteTemplate ResearchRampUpPlan.docx RESEARCH_RAMPUP - + @@ -651,10 +639,6 @@ If a rejection notification is received, go back to the first step that needs to - #### Business Rule Task - - -This step is internal to the system and do not require and user interaction Flow_1e2qi9s Flow_08njvvi @@ -691,129 +675,98 @@ No shared space entered Flow_0cpmvcw - #### Script Task - - -This step is internal to the system and do not require and user interaction Flow_0j4rs82 Flow_07ge8uf RequestApproval ApprvlApprvr1 ApprvlApprvr2 - #### Script Task - - -This step is internal to the system and do not require and user interaction Flow_16y8glw Flow_1v7r1tg UpdateStudy title:PIComputingID.label pi:PIComputingID.value - - - #### Weekly Schedule -Provide your initial weekly laboratory schedule for all members that you are requesting access for, indicating all shifts as necessary. If any schedule changes after approval, please submit your revised schedule here for re-approval. - - - - - - - - - - - - - Flow_1nbjr72 - Flow_097fpi3 - - - - - - - + + - - + + - - + + - - + + - - + + - - + + - - - - - + + + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - + @@ -864,8 +817,8 @@ Provide your initial weekly laboratory schedule for all members that you are req - - + + @@ -890,11 +843,8 @@ Provide your initial weekly laboratory schedule for all members that you are req - - - - + @@ -912,49 +862,49 @@ Provide your initial weekly laboratory schedule for all members that you are req - + - + - + - + - + - - - - - - - - - - - - + + + + + + + + + + + + - + - + - + - - + + From 7351dc4a4321474c06a130abd2fa08182f58c22b Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 1 Jun 2020 00:14:09 -0400 Subject: [PATCH 20/76] Updates RRT workflow spec files for reals this time --- .../research_rampup/ResearchRampUpPlan.docx | Bin 58311 -> 58518 bytes .../exclusive_area_monitors.dmn | 54 +++ .../bpmn/research_rampup/research_rampup.bpmn | 454 +++++++++--------- .../rrt_top_level_workflow.bpmn | 26 + .../research_rampup/shared_area_monitors.dmn | 54 +++ 5 files changed, 355 insertions(+), 233 deletions(-) create mode 100644 crc/static/bpmn/research_rampup/exclusive_area_monitors.dmn create mode 100644 crc/static/bpmn/research_rampup/rrt_top_level_workflow.bpmn create mode 100644 crc/static/bpmn/research_rampup/shared_area_monitors.dmn diff --git a/crc/static/bpmn/research_rampup/ResearchRampUpPlan.docx b/crc/static/bpmn/research_rampup/ResearchRampUpPlan.docx index 0c555fdb102d4f49e72dfc17265a2d7db360182b..2ff0ed801e4f0fa2ffcfc06ef5901eb86ef1689f 100644 GIT binary patch delta 22146 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 delta 22049 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 + + + + + + len(exclusive) + + + + + sum([1 for x in exclusive if x.get('ExclusiveSpaceAMComputingID',None) == None]) + + + + + No exclusvie spaces without Area Monitor + + >0 + + + 0 + + + true + + + + One or more exclusive space without an Area Monitor + + >0 + + + > 0 + + + false + + + + No exclusive spaces entered + + 0 + + + + + + true + + + + + diff --git a/crc/static/bpmn/research_rampup/research_rampup.bpmn b/crc/static/bpmn/research_rampup/research_rampup.bpmn index cfd41423..c9b8a17c 100644 --- a/crc/static/bpmn/research_rampup/research_rampup.bpmn +++ b/crc/static/bpmn/research_rampup/research_rampup.bpmn @@ -34,7 +34,7 @@ Schools are developing a process for the approval of ramp up requests and enforc 1. The Research Ramp-up Plan allows for one request to be entered for a single Principle Investigator. In the form that follows enter the Primary Investigator this request is for and other identifying information. The PI's School and Supervisor will be used as needed for approval routing. 2. Provide all available information in the forms that follow to provide an overview of where the research will resume, who will be involved, what supporting resources will be needed and what steps have been taken to assure compliance with [Research Ramp-up Guidance](https://research.virginia.edu/research-ramp-guidance). 3. After all forms have been completed, you will be presented with the option to create your Research Recovery Plan in Word format. Download the document and review it. If you see any corrections that need to be made, return to the corresponding form and make the correction. -4. Once the generated Research Recovery Plan is finalized, proceed to the Plan Submission step to submit your plan for approval. +4. Once the generated Research Recovery Plan is finalize, proceed to the Plan Submission step to submit your plan for approval. SequenceFlow_05ja25w SequenceFlow_0h50bp3 @@ -47,7 +47,6 @@ Enter the following information for the PI submitting this request - @@ -134,7 +133,7 @@ Enter the following information for the PI submitting this request - + #### People for whom you are requesting access Provide information on all researchers you are requesting approval for reentry into the previously entered lab/research and/or office space(s) for conducting research on-Grounds. (If there are personnel already working in the space, include them). @@ -162,7 +161,6 @@ No shared space entered - @@ -208,8 +206,8 @@ No shared space entered - Flow_1eiud85 - Flow_1nbjr72 + Flow_0r9cfe1 + Flow_0821rbm #### If applicable, provide a list of any [Core Resources](https://research.virginia.edu/research-core-resources) you will utilize space or instruments in and name/email of contact person in the core you have coordinated your plan with. (Core facility managers are responsible for developing a plan for their space) @@ -230,12 +228,12 @@ No shared space entered - Flow_15zy1q7 - Flow_12ie6w0 + Flow_1myg94h + Flow_0zyal9y - #### End of Research Ramp-up Plan Workflow -Thank you for participating, + #### End of Workflow +Place instruction here, Flow_05w8yd6 @@ -268,6 +266,9 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a + + + @@ -281,7 +282,6 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a - @@ -321,28 +321,10 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a - Flow_19xeq76 - Flow_16342pm + Flow_0ss3j2p + Flow_1myg94h - - Flow_1v7r1tg - Flow_19xeq76 - Flow_0qf2y84 - Flow_15zy1q7 - Flow_0ya8hw8 - - - Flow_0tk64b6 - Flow_12ie6w0 - Flow_0zz2hbq - Flow_16342pm - Flow_1eiud85 - - - - - #### Space managed exclusively by {{ PIComputingID.label }} Submit one entry for each space the PI is the exclusive investigator. If all space is shared with one or more other investigators, Click Save to skip this section and proceed to the Shared Space section. @@ -366,6 +348,9 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp + + + @@ -390,7 +375,6 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - @@ -423,12 +407,9 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - Flow_0qf2y84 - Flow_0tk64b6 + Flow_1grd970 + Flow_0ss3j2p - - - @@ -451,12 +432,9 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - Flow_0ya8hw8 - Flow_0zz2hbq + Flow_0zyal9y + Flow_0r9cfe1 - - - #### Distancing requirements: Maintain social distancing by designing space between people to be at least 9 feet during prolonged work which will be accomplished by restricting the number of people in the lab to a density of ~250 sq. ft. /person in lab areas. When moving around, a minimum of 6 feet social distancing is required. Ideally only one person per lab bench and not more than one person can work at the same time in the same bay. @@ -474,18 +452,9 @@ Maintain social distancing by designing space between people to be at least 9 fe - Flow_0p2r1bo - Flow_0tz5c2v + Flow_0821rbm + Flow_18f81dp - - - Flow_1nbjr72 - Flow_0p2r1bo - Flow_0mkh1wn - Flow_1yqkpgu - Flow_1c6m5wv - - Describe physical work arrangements for each lab. Show schematic of the lab and space organization to meet the distancing guidelines (see key safety expectations for ramp-up). - Show gross dimensions, location of desks, and equipment in blocks (not details) that show available space for work and foot traffic. @@ -508,10 +477,9 @@ Maintain social distancing by designing space between people to be at least 9 fe - Flow_0mkh1wn - Flow_0zrsh65 + Flow_18f81dp + Flow_0cp1fez - #### Health Safety Requirements: Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/url?q=http://ehs.virginia.edu/files/Lab-Safety-Plan-During-COVID-19.docx&source=gmail&ust=1590687968958000&usg=AFQjCNE83uGDFtxGkKaxjuXGhTocu-FDmw) to create and upload a copy of your laboratory policy statement to all members which includes at a minimum the following details: @@ -526,10 +494,9 @@ Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/ur - Flow_1yqkpgu - Flow_1ox5nv6 + Flow_0cp1fez + Flow_0vpwylg - @@ -573,8 +540,8 @@ Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/ur - Flow_1c6m5wv - Flow_0qbi47d + Flow_0vpwylg + Flow_1r2p20h #### By submitting this request, you understand that every member listed in this form for on Grounds laboratory access will: @@ -584,31 +551,25 @@ Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/ur Flow_08njvvi Flow_0j4rs82 - - - - - - - Flow_0zrsh65 - Flow_0tz5c2v - Flow_1ox5nv6 - Flow_0qbi47d - Flow_06873ag - - Flow_06873ag + Flow_1r2p20h Flow_0aqgwvu CompleteTemplate ResearchRampUpPlan.docx RESEARCH_RAMPUP - + #### Approval Process The Research Ramp-up Plan and associated documents will be reviewed by{{ " " + ApprvlApprvrName1 }}{{ '.' if ApprvlApprvrName2 == 'n/a' else ' and ' + ApprvlApprvrName2 + '.' }} While waiting for approval, be sure that all required training has been completed and supplies secured. When the approval email notification is received, confirming the three questions below will allow you to proceed. +{%+ set ns = namespace() %}{% set ns.exclusive = 0 %}{% set ns.shared = 0 %}{% for es in exclusive %}{% if es.ExclusiveSpaceAMComputingID is none %}{% set ns.exclusive = ns.exclusive + 1 %}{% endif %}{% endfor %}{% for ss in shared %}{% if ss.SharedSpaceAMComputingID is none %}{% set ns.shared = ns.shared + 1 %}{% endif %}{% endfor %} + + +#### Test +Missing Exclusive: {{ ns.exclusive }} +Missing Shared: {{ ns.shared }} If a rejection notification is received, go back to the first step that needs to be addressed and step through each subsequent form from that point. @@ -617,23 +578,94 @@ If a rejection notification is received, go back to the first step that needs to + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - Flow_07ge8uf + Flow_1r9hwx3 Flow_1ufh44h @@ -656,21 +688,12 @@ If notification is received that the Research Ramp-up Plan approval process is n #### Ready to Ramp-up Research Notify the Area Monitor for - -#### Exclusive Space Area Monitors -{% for es in exclusive %} -{{ es.ExclusiveSpaceAMComputingID.data.display_name }} -{% else %} -No exclusive space entered -{% endfor %} +#### Exclusive Space previously entered +{%+ for es in exclusive %}{{ es.ExclusiveSpaceRoomID + " " + es.ExclusiveSpaceBuilding.label + " - " }}{% if es.ExclusiveSpaceAMComputingID is none %}No Area Monitor entered{% else %}{{ es.ExclusiveSpaceAMComputingID.label }}{% endif %}{% if loop.last %}{% else %}, {% endif %}{% else %}No exclusive space entered{% endfor %} -#### Shared Space Area Monitors -{% for ss in shared %} -{{ ss.SharedSpaceAMComputingID.data.display_name }} -{% else %} -No shared space entered -{% endfor %} +#### Shared Space previously entered +{%+ for ss in shared %}{{ ss.SharedSpaceRoomID + " " + ss.SharedSpaceBuilding.label }}{% if ss.SharedSpaceAMComputingID is none %}No Area Monitor entered{% else %}{{ ss.SharedSpaceAMComputingID.label }}{% endif %}{% if loop.last %}{% else %}, {% endif %}{% else %}No shared space entered.{% endfor %} Flow_1ufh44h Flow_0cpmvcw @@ -679,146 +702,117 @@ No shared space entered Flow_07ge8uf RequestApproval ApprvlApprvr1 ApprvlApprvr2 - Flow_16y8glw - Flow_1v7r1tg + Flow_1grd970 UpdateStudy title:PIComputingID.label pi:PIComputingID.value + + + Flow_07ge8uf + Flow_09m77pd + + + + + + + + + + + + + + Flow_09m77pd + Flow_1r9hwx3 + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - + + @@ -837,74 +831,68 @@ No shared space entered - - + + - + + + + - + - - - - - - - + - + - + - - - - + - + - + - + - - - - + - + - + - + - + - + - + - - + + + + + diff --git a/crc/static/bpmn/research_rampup/rrt_top_level_workflow.bpmn b/crc/static/bpmn/research_rampup/rrt_top_level_workflow.bpmn new file mode 100644 index 00000000..26b2fe37 --- /dev/null +++ b/crc/static/bpmn/research_rampup/rrt_top_level_workflow.bpmn @@ -0,0 +1,26 @@ + + + + + SequenceFlow_0lvudp8 + + + + SequenceFlow_0lvudp8 + + + + + + + + + + + + + + + + + diff --git a/crc/static/bpmn/research_rampup/shared_area_monitors.dmn b/crc/static/bpmn/research_rampup/shared_area_monitors.dmn new file mode 100644 index 00000000..db3d43b3 --- /dev/null +++ b/crc/static/bpmn/research_rampup/shared_area_monitors.dmn @@ -0,0 +1,54 @@ + + + + + + + len(shared) + + + + + sum([1 for x in exclusive if x.get('SharedSpaceAMComputingID',None) == None]) + + + + + No shared spaces without Area Monitor + + >0 + + + 0 + + + true + + + + One or more shared space without an Area Monitor + + >0 + + + > 0 + + + false + + + + No shared spaces entered + + 0 + + + + + + true + + + + + From 8092bbe682ff3155539c97b923bb8063b91a1fdf Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 1 Jun 2020 00:17:20 -0400 Subject: [PATCH 21/76] Wrong file in wrong place. --- .../rrt_top_level_workflow.bpmn | 26 ------------------- 1 file changed, 26 deletions(-) delete mode 100644 crc/static/bpmn/research_rampup/rrt_top_level_workflow.bpmn diff --git a/crc/static/bpmn/research_rampup/rrt_top_level_workflow.bpmn b/crc/static/bpmn/research_rampup/rrt_top_level_workflow.bpmn deleted file mode 100644 index 26b2fe37..00000000 --- a/crc/static/bpmn/research_rampup/rrt_top_level_workflow.bpmn +++ /dev/null @@ -1,26 +0,0 @@ - - - - - SequenceFlow_0lvudp8 - - - - SequenceFlow_0lvudp8 - - - - - - - - - - - - - - - - - From ea441dbff55e44031ce8a4b101cc199dc7fa9236 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 1 Jun 2020 00:28:37 -0400 Subject: [PATCH 22/76] More updates --- .../bpmn/research_rampup/research_rampup.bpmn | 438 +++++++++++------- crc/static/reference/rrt_documents.xlsx | Bin 9812 -> 9822 bytes 2 files changed, 264 insertions(+), 174 deletions(-) diff --git a/crc/static/bpmn/research_rampup/research_rampup.bpmn b/crc/static/bpmn/research_rampup/research_rampup.bpmn index c9b8a17c..3f3b3f92 100644 --- a/crc/static/bpmn/research_rampup/research_rampup.bpmn +++ b/crc/static/bpmn/research_rampup/research_rampup.bpmn @@ -34,7 +34,7 @@ Schools are developing a process for the approval of ramp up requests and enforc 1. The Research Ramp-up Plan allows for one request to be entered for a single Principle Investigator. In the form that follows enter the Primary Investigator this request is for and other identifying information. The PI's School and Supervisor will be used as needed for approval routing. 2. Provide all available information in the forms that follow to provide an overview of where the research will resume, who will be involved, what supporting resources will be needed and what steps have been taken to assure compliance with [Research Ramp-up Guidance](https://research.virginia.edu/research-ramp-guidance). 3. After all forms have been completed, you will be presented with the option to create your Research Recovery Plan in Word format. Download the document and review it. If you see any corrections that need to be made, return to the corresponding form and make the correction. -4. Once the generated Research Recovery Plan is finalize, proceed to the Plan Submission step to submit your plan for approval. +4. Once the generated Research Recovery Plan is finalized, proceed to the Plan Submission step to submit your plan for approval. SequenceFlow_05ja25w SequenceFlow_0h50bp3 @@ -47,6 +47,7 @@ Enter the following information for the PI submitting this request + @@ -60,6 +61,9 @@ Enter the following information for the PI submitting this request + + + @@ -68,6 +72,9 @@ Enter the following information for the PI submitting this request + + + @@ -77,6 +84,9 @@ Enter the following information for the PI submitting this request + + + @@ -85,6 +95,9 @@ Enter the following information for the PI submitting this request + + + @@ -93,6 +106,9 @@ Enter the following information for the PI submitting this request + + + @@ -101,6 +117,9 @@ Enter the following information for the PI submitting this request + + + @@ -109,21 +128,28 @@ Enter the following information for the PI submitting this request + + + + + + + - + @@ -133,34 +159,35 @@ Enter the following information for the PI submitting this request - - #### People for whom you are requesting access -Provide information on all researchers you are requesting approval for reentry into the previously entered lab/research and/or office space(s) for conducting research on-Grounds. (If there are personnel already working in the space, include them). + + #### Personnel for whom you are requesting access +Provide information on all personnel you are requesting approval for reentry into the previously entered lab, workspace and/or office space(s) for conducting research on-Grounds. (If there are personnel already working in the space, include them). **Note: no undergraduates will be allowed to work on-Grounds during Phase I.** #### Exclusive Space previously entered -{% for es in exclusive %} -{{ es.ExclusiveSpaceRoomID + " " + es.ExclusiveSpaceBuilding.label }} -{% else %} -No exclusive space entered -{% endfor %} - +{%+ for es in exclusive %}{{ es.ExclusiveSpaceRoomID + " " + es.ExclusiveSpaceBuilding.label }}{% if loop.last %}{% else %}, {% endif %}{% else %}No exclusive space entered{% endfor %} #### Shared Space previously entered -{% for ss in shared %} -{{ ss.SharedSpaceRoomID + " " + ss.SharedSpaceBuilding.label }} -{% else %} -No shared space entered -{% endfor %} +{%+ for ss in shared %}{{ ss.SharedSpaceRoomID + " " + ss.SharedSpaceBuilding.label }}{% if loop.last %}{% else %}, {% endif %}{% else %}No shared space entered.{% endfor %} + + + + + + + + + + @@ -179,9 +206,7 @@ No shared space entered - - - + @@ -199,18 +224,13 @@ No shared space entered - - - - - - Flow_0r9cfe1 - Flow_0821rbm + Flow_0hc1r8a + Flow_1yxaewj - #### If applicable, provide a list of any [Core Resources](https://research.virginia.edu/research-core-resources) you will utilize space or instruments in and name/email of contact person in the core you have coordinated your plan with. (Core facility managers are responsible for developing a plan for their space) + If applicable, provide a list of any [Core Resources](https://research.virginia.edu/research-core-resources) utilization of space and/or instruments along with the name(s) and email(s) of contact person(s) in the core with whom you have coordinated your plan. (Core facility managers are responsible for developing a plan for their space) @@ -224,16 +244,17 @@ No shared space entered + - Flow_1myg94h - Flow_0zyal9y + Flow_1n69wsr + Flow_13pusfu - #### End of Workflow -Place instruction here, + #### End of Research Ramp-up Plan Workflow +Thank you for participating, Flow_05w8yd6 @@ -250,11 +271,12 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a - + + @@ -280,8 +302,9 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a - + + @@ -307,6 +330,7 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a + @@ -321,22 +345,24 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a - Flow_0ss3j2p - Flow_1myg94h + Flow_0o4tg9g + Flow_1n69wsr #### Space managed exclusively by {{ PIComputingID.label }} -Submit one entry for each space the PI is the exclusive investigator. If all space is shared with one or more other investigators, Click Save to skip this section and proceed to the Shared Space section. + +Submit one entry for each space the PI is the exclusive investigator. If all space is shared with one or more other investigators, click Save to skip this section and proceed to the Shared Space section. - + - + + @@ -354,7 +380,7 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - + @@ -362,19 +388,21 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp + - + - + + @@ -398,17 +426,18 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - + + - Flow_1grd970 - Flow_0ss3j2p + Flow_0uc4o6c + Flow_0o4tg9g @@ -432,8 +461,8 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - Flow_0zyal9y - Flow_0r9cfe1 + Flow_13pusfu + Flow_0hc1r8a #### Distancing requirements: @@ -452,23 +481,23 @@ Maintain social distancing by designing space between people to be at least 9 fe - Flow_0821rbm - Flow_18f81dp + Flow_1itd8db + Flow_1lo964l - Describe physical work arrangements for each lab. Show schematic of the lab and space organization to meet the distancing guidelines (see key safety expectations for ramp-up). + Describe physical work arrangements for each lab, workspace and/or office space previously entered. Show schematic of the space organization to meet the distancing guidelines (see key safety expectations for ramp-up). - Show gross dimensions, location of desks, and equipment in blocks (not details) that show available space for work and foot traffic. - Indicate total square footage for every lab/space that you are requesting adding personnel to in this application. If you would like help obtaining a floor plan for your lab, your department or deans office can help. You can also create a hand drawing/block diagram of your space and the location of objects on a graph paper. - Upload your physical layout and workspace organization in the form of a jpg image or a pdf file. This can be hand-drawn or actual floor plans. - Show and/or describe designated work location for each member (during their shift) in the lab when multiple members are present at a time to meet the distancing guidelines. -- Provide a foot traffic plan (on the schematic) to indicate how people can move around while maintaining distancing requirements. This can be a freeform sketch on your floor plan showing where foot traffic can occur in your lab, and conditions, if any, to ensure distancing at all times. (e.g., direction to walk around a lab bench, rules for using shared equipment located in the lab, certain areas of lab prohibited from access, etc.). -- Provide your initial weekly laboratory schedule (see excel template) for all members that you are requesting access for, indicating all shifts as necessary. If schedule changes, please submit your revised schedule through the web portal. +- Provide a foot traffic plan (on the schematic) to indicate how people can move around while maintaining distancing requirements. This can be a freeform sketch on your floor plan showing where foot traffic can occur in your lab, and conditions, if any, to ensure distancing at all times. (e.g., direction to walk around a lab bench, rules for using shared equipment located in the lab, certain areas of lab prohibited from access, etc.). + @@ -477,10 +506,10 @@ Maintain social distancing by designing space between people to be at least 9 fe - Flow_18f81dp - Flow_0cp1fez + Flow_1lo964l + Flow_0wgdxa6 - + #### Health Safety Requirements: Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/url?q=http://ehs.virginia.edu/files/Lab-Safety-Plan-During-COVID-19.docx&source=gmail&ust=1590687968958000&usg=AFQjCNE83uGDFtxGkKaxjuXGhTocu-FDmw) to create and upload a copy of your laboratory policy statement to all members which includes at a minimum the following details: - Laboratory face covering rules, use of other PPE use as required @@ -491,11 +520,11 @@ Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/ur - Where and how to obtain PPE including face covering - + - Flow_0cp1fez - Flow_0vpwylg + Flow_0wgdxa6 + Flow_0judgmp @@ -540,26 +569,29 @@ Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/ur - Flow_0vpwylg - Flow_1r2p20h + Flow_0judgmp + Flow_11uqavk #### By submitting this request, you understand that every member listed in this form for on Grounds laboratory access will: -- Complete online COVID awareness & precaution training module (link forthcoming-May 25) +- Complete [online COVID awareness & precaution training module](https://researchcompliance.web.virginia.edu/training_html5/module_content/154/index.cfm) - Complete daily health acknowledgement form signed (electronically) –email generated daily to those listed on your plan for access to on Grounds lab/research space - Fill out daily work attendance log for all lab members following your school process to check-in and out of work each day. Flow_08njvvi Flow_0j4rs82 - - Flow_1r2p20h + + #### Script Task + + +This step is internal to the system and do not require and user interaction + Flow_11uqavk Flow_0aqgwvu CompleteTemplate ResearchRampUpPlan.docx RESEARCH_RAMPUP - + - - + #### Approval Process The Research Ramp-up Plan and associated documents will be reviewed by{{ " " + ApprvlApprvrName1 }}{{ '.' if ApprvlApprvrName2 == 'n/a' else ' and ' + ApprvlApprvrName2 + '.' }} While waiting for approval, be sure that all required training has been completed and supplies secured. When the approval email notification is received, confirming the three questions below will allow you to proceed. @@ -665,12 +697,15 @@ If a rejection notification is received, go back to the first step that needs to - Flow_1r9hwx3 - Flow_1ufh44h + SequenceFlow_0qc39tw + #### Business Rule Task + + +This step is internal to the system and do not require and user interaction Flow_1e2qi9s Flow_08njvvi @@ -688,211 +723,266 @@ If notification is received that the Research Ramp-up Plan approval process is n #### Ready to Ramp-up Research Notify the Area Monitor for + #### Exclusive Space previously entered {%+ for es in exclusive %}{{ es.ExclusiveSpaceRoomID + " " + es.ExclusiveSpaceBuilding.label + " - " }}{% if es.ExclusiveSpaceAMComputingID is none %}No Area Monitor entered{% else %}{{ es.ExclusiveSpaceAMComputingID.label }}{% endif %}{% if loop.last %}{% else %}, {% endif %}{% else %}No exclusive space entered{% endfor %} + + #### Shared Space previously entered {%+ for ss in shared %}{{ ss.SharedSpaceRoomID + " " + ss.SharedSpaceBuilding.label }}{% if ss.SharedSpaceAMComputingID is none %}No Area Monitor entered{% else %}{{ ss.SharedSpaceAMComputingID.label }}{% endif %}{% if loop.last %}{% else %}, {% endif %}{% else %}No shared space entered.{% endfor %} - Flow_1ufh44h + SequenceFlow_0qc39tw Flow_0cpmvcw + #### Script Task + + +This step is internal to the system and do not require and user interaction Flow_0j4rs82 Flow_07ge8uf RequestApproval ApprvlApprvr1 ApprvlApprvr2 + #### Script Task + + +This step is internal to the system and do not require and user interaction Flow_16y8glw - Flow_1grd970 + Flow_0uc4o6c UpdateStudy title:PIComputingID.label pi:PIComputingID.value - - + + #### Weekly Personnel Schedule(s) +Provide initial weekly schedule(s) for the PI and all personnel for whom access has been requested, indicating each space they will be working in and all shifts, if applicable. + +##### Personnel and spaces they will work in previously entered +{%+ for p in personnel %}{{ p.PersonnelComputingID.label + " - " + p.PersonnelSpace }}{% if loop.last %}{% else %}; {% endif %}{% endfor %} + +**Note:** If any schedule changes after approval, please re-submit revised schedule(s) here for re-approval. + + + + + + + + + + + + + Flow_1yxaewj + Flow_1itd8db + + + + + + + + + + + + + + #### Business Rule Task + + + + +This step is internal to the system and do not require and user interaction Flow_07ge8uf - Flow_09m77pd + Flow_0peeyne - - - - - - - - - - - - - Flow_09m77pd - Flow_1r9hwx3 + + + #### Business Rule Task + + + + +This step is internal to the system and do not require and user interaction + Flow_0peeyne + Flow_0tqna2m + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + + + + + + + + + - - + + - - + + - - - - - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - + - + - - + + - + - + - + - + - + - + - + - + - + - + - + - - + + - + - + - + - + - + - + - - + + - - + + + + + diff --git a/crc/static/reference/rrt_documents.xlsx b/crc/static/reference/rrt_documents.xlsx index 4e1663b2b9e397549f6115f57a6fb39736c31229..cb09fd0f5f69f1e616a159cf20eb36b91518e3d4 100644 GIT binary patch delta 2015 zcmV<52O#*=Ox{eeNeh2Z=a&hi0RRAz0{{RN0001ZY%g|&gT9LrRm#G^QZoNt8+JfX+S!y(uP2HE5%=bH0WUmwqp(@dzbVh@m!OHko`EHE7G3a;*(1pZ(yhwfeQi zyFr-VaG8*nNszG_Ohk~0=+|!=1l&8{O)nX}S5o&ukqD?@m@M>(QFZ4Ot+2~tZdEMZTP_0z_CP2;0m7Cu*1;h2F~(ZJgiv+ z*g&vS(P`UVMx+BH5&X?@oskTNN&dyMEq!pN+dSRLOVE5RAu~_HY6JEq8CI14m z0}U_=1&|{3(g>3{7bkz(I1qi`s{IF*ANFa%7cK_YI5ogRQZ-4anOvTQExA4)(Uu% zfnpWKk$Tgl21$a^hfT@_UB!s|V0US79 z{-@Bd#haCS-#fh@Bbn=BSPHVDl1+L1JqpzpQc-jvTho7CtWJ}I_Mqcf9oxYYCHTbm z?^(3j=FuSaq8G3 z7nL96lMk3h6-fMl^)1hQMF|EaHpxA-Uu9u8EvA>Ul>}7gl_X%c3el|`b%G{!Y|zA}b!>mw#AY|JMIEz$p%kCQ6{VQ1l-g&z zF=>Ejn>nq6=UQ)Ii#leXlGDg4zQQY7F?-T_Ru_{7c&@b$o@>2y!@X5o@l?-+*5Vo{KHrRP*heZhQ7TD{s|ROx+a0*wVeN-= zLKJ_8{8bE>8ouMU)pOkH2bbbHyM}Dhb1)naSJ~E_t6zb*Ome-?OzkqlMT9#>w;5w2 z<3b4wz(GJdpr!^ zYq9ZkDqeS##P*q%J`gr;z)_i5t#x+>YIP=ufOxFMl|F*J`=0V^7?vHSAl~zsJrg!Bu?L zC|+%w5*nE75HQ^)4vIF>_mabC`S^`|4w_Aotx9VpFfF+oufA`b+u85ipbS{5^Zj67|KV5F=COwa)|n&!7R8I>um z6}+lif-)SmXO)CbshpsVF*3zi(+yNa2bLi$xT;k}j4jm~OG3ZN8gLwje?we>A&eM; zTR~s;is&fGXfG+Zs&oR3Vkn^kVKk0{2%otIRHgrX;E)Su#mzz5nT~cZwHfWep1s$+ zCpJwJG>Nle{YUtH{<3hR`dqYGQ9yadXll4AATHPyEz3r-fldP5=ZH4YulyN zxdGR$6*}$Vd6*QA`a6yvFD?m_3C|_L)WN|2LDwY_(@nqqx5c+ z4u%(sCjl32NyK_U7Kmk&dDkUHVkRtWc2$N4G6#^_5lR+06lYk{E0cw-0B_kWRx3r0e x0RRB#0ssIJ00000000000000006&xIB|ZfGAOHZ95g`+kJ|++bha&(00040Kz|{Z% delta 2029 zcmV%UNeh3SOW0nV0RRAd0{{RN0001ZY%gZWbR^!;YF8e_$K3n~{g>evsb+8S;6T;Y!(vWP3vmsF-;SkI?Ut z%XdeEfnNAOXv>cyiUJ>k&$ro98>G(z2r{oa(h3aQI{!V$GVj6eu%8sm4cM>r?WiHH zyt5X~7V*(^>$tmRYmOEze=B1{9XnOHfnuavyDVrMa zOd22jpMUZWT)fI1V~T(wl~ZGWtV4vECsURBB2U@#nzOWU^LWDNo+cn zH8c|%rAEOp5%+NL%_a)if(QGArK%tiET#(jmv zO=^%Nn0(r%T+nrlxQ}+91}AR)=Yl?o0>LHUs4nQtLg;18V{>eR$@jtt5#(tnf^DY8 zgFRV)4h+-xbbh`?>>wNOMA7vNi&>yph;1@Rp8hrGuesqTOj%&eXv)a@ONJn{jpEr8 z+o(B6gzpO`1JpvE1EeqDF_y!VBvD?`*hqTRCmE%C;fMva zBgWFv>D_bysvR(v4g;%e-?37^bi`OX_Uvx23jT&hM4*?%SbEyy`#guGC&p8B{kz0} zx}_t=li1#UOX46YcEZXtUphB;`7%(m*i2nh3#zE^34S0rC$Os$m5Ecwp17#|AfJ4| zEUG}_|C?`l<{L^dD6v`Yq5UcgyKOPQv~b!Mi%W}M+v4|2i+= zN}vUvYi)w(T5n*uw;C(fMOSDou7Tq7&FGeWreYhVlEk=raAtSik=q|PemEz8L~+Pp z#c-+NJMLNo$E|*FDXz0?$PPUR!|`yHZNs_xFA$eWuJ?theMY#5aL4F2V`^kvC_y7- zkiBG&k&tQy!!P?}opa#>-og9_hkd-rqN&ldaKoX=Qc=*D0e_+GQH$_B9)=&aSpOHA zWWUYz)A))$L6R@*K%RL*(_f!|*>)dOVQAEL^zh)BwR6r>QvMH~JOZNaSDNf3%4}<7 zNA)C??_nf+f!O}W4QDxS&+a@7XI5vBKb)a!bzIl6XYSk@4=wATQ|FZ6Dn4rzueMDI z4NP_nm~InCMVsh*$#JrJ`bIwU5IqS}`bUapW?Q^}e?e~bEAk3oXw07yTC7<$tNy4l=4?+(O6q3-=n<{M_wX!TE z<@NWK<-|#7p`e3yXEe;tsu=GY5o|yk-pUdsQH%nRv}Ih@C0fiU;T;N$C6W=*N+{7D z3>sJ0R|Qo$Z8f~=R)Mx0OyHEH$*C&Q%376UZ0HIaVj{h5K@lA!8670mS_?10CK+&h2QV;Hb7kFzdhnv*_8^F{Q@4MzB?tDfukHoxf@jdWl?DC{cbn6&;oZio zB=eM^cD8*=y&GVGn>+7QIwEh`@6aWSQ2mrGO zA&&zIolDqWoB;p;dy}6eQvnH+^CUn4OOrGuARAoVWabJ4008j{000;O0000000000 z00000C6jL@MFC}#wIw4Plra=nf&l;k=mG!$5dZ)H00000000000026Z^Cdn3^OG_r LCI)>Y000002Z+6I From 1702ab25767b7ac204472d60771cca9895e9fd1d Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 1 Jun 2020 00:36:25 -0400 Subject: [PATCH 23/76] Fixes decision tables that were causing failing tests --- crc/static/bpmn/research_rampup/exclusive_area_monitors.dmn | 6 +++--- crc/static/bpmn/research_rampup/shared_area_monitors.dmn | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crc/static/bpmn/research_rampup/exclusive_area_monitors.dmn b/crc/static/bpmn/research_rampup/exclusive_area_monitors.dmn index 236796b3..7a2251de 100644 --- a/crc/static/bpmn/research_rampup/exclusive_area_monitors.dmn +++ b/crc/static/bpmn/research_rampup/exclusive_area_monitors.dmn @@ -1,10 +1,10 @@ - + - len(exclusive) + 'exclusive' in locals() and len(exclusive) @@ -14,7 +14,7 @@ - No exclusvie spaces without Area Monitor + No exclusive spaces without Area Monitor >0 diff --git a/crc/static/bpmn/research_rampup/shared_area_monitors.dmn b/crc/static/bpmn/research_rampup/shared_area_monitors.dmn index db3d43b3..f0746f42 100644 --- a/crc/static/bpmn/research_rampup/shared_area_monitors.dmn +++ b/crc/static/bpmn/research_rampup/shared_area_monitors.dmn @@ -1,10 +1,10 @@ - + - len(shared) + 'shared' in locals() and len(shared) From b6bf843f6e0b8c7de6552caa5fdd62e264770ef1 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Mon, 1 Jun 2020 11:00:56 -0400 Subject: [PATCH 24/76] used 'name' rather than 'value' in the lookup options during validation, causing a disconnect with how this is processed on the front end. --- crc/services/workflow_service.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 03a23aac..312dee3c 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -157,7 +157,7 @@ class WorkflowService(object): LookupDataModel.lookup_file_model == lookup_model).limit(10).all() options = [] for d in data: - options.append({"id": d.value, "name": d.label}) + options.append({"id": d.value, "label": d.label}) return random.choice(options) else: raise ApiError.from_task("invalid_autocomplete", "The settings for this auto complete field " @@ -294,11 +294,11 @@ class WorkflowService(object): template = Template(raw_doc) return template.render(**spiff_task.data) except jinja2.exceptions.TemplateError as ue: - raise ApiError(code="template_error", message="Error processing template for task %s: %s" % - (spiff_task.task_spec.name, str(ue)), status_code=500) + raise ApiError.from_task(code="template_error", message="Error processing template for task %s: %s" % + (spiff_task.task_spec.name, str(ue)), task=spiff_task) except TypeError as te: - raise ApiError(code="template_error", message="Error processing template for task %s: %s" % - (spiff_task.task_spec.name, str(te)), status_code=500) + 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. @staticmethod From c790737bf056f427e7719bae37f359210df1be92 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Mon, 1 Jun 2020 12:33:58 -0400 Subject: [PATCH 25/76] The complete_template script might not grab the correct document when validating, fixed. Updated to latest RRT files, and passing. --- crc/scripts/complete_template.py | 17 ++-- .../research_rampup/ResearchRampUpPlan.docx | Bin 58518 -> 35362 bytes .../bpmn/research_rampup/research_rampup.bpmn | 89 ++++++++++-------- 3 files changed, 57 insertions(+), 49 deletions(-) diff --git a/crc/scripts/complete_template.py b/crc/scripts/complete_template.py index 59f63158..32bee509 100644 --- a/crc/scripts/complete_template.py +++ b/crc/scripts/complete_template.py @@ -29,7 +29,8 @@ Takes two arguments: def do_task_validate_only(self, task, study_id, workflow_id, *args, **kwargs): """For validation only, process the template, but do not store it in the database.""" - self.process_template(task, study_id, None, *args, **kwargs) + workflow = session.query(WorkflowModel).filter(WorkflowModel.id == workflow_id).first() + self.process_template(task, study_id, workflow, *args, **kwargs) def do_task(self, task, study_id, workflow_id, *args, **kwargs): workflow = session.query(WorkflowModel).filter(WorkflowModel.id == workflow_id).first() @@ -62,13 +63,13 @@ Takes two arguments: # Get the workflow specification file with the given name. file_data_models = FileService.get_spec_data_files( workflow_spec_id=workflow.workflow_spec_id, - workflow_id=workflow.id) - for file_data in file_data_models: - if file_data.file_model.name == file_name: - file_data_model = file_data - - if workflow is None or file_data_model is None: - file_data_model = FileService.get_workflow_file_data(task.workflow, file_name) + workflow_id=workflow.id, + name=file_name) + if len(file_data_models) > 0: + file_data_model = file_data_models[0] + else: + raise ApiError(code="invalid_argument", + message="Uable to locate a file with the given name.") # Get images from file/files fields if len(args) == 3: diff --git a/crc/static/bpmn/research_rampup/ResearchRampUpPlan.docx b/crc/static/bpmn/research_rampup/ResearchRampUpPlan.docx index 2ff0ed801e4f0fa2ffcfc06ef5901eb86ef1689f..c4b9ced6ddfaf8d677b5ec4c834674f11330d804 100644 GIT binary patch delta 32909 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 literal 58518 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 DT-7q) diff --git a/crc/static/bpmn/research_rampup/research_rampup.bpmn b/crc/static/bpmn/research_rampup/research_rampup.bpmn index 3f3b3f92..65d49b16 100644 --- a/crc/static/bpmn/research_rampup/research_rampup.bpmn +++ b/crc/static/bpmn/research_rampup/research_rampup.bpmn @@ -163,13 +163,14 @@ Enter the following information for the PI submitting this request#### Personnel for whom you are requesting access Provide information on all personnel you are requesting approval for reentry into the previously entered lab, workspace and/or office space(s) for conducting research on-Grounds. (If there are personnel already working in the space, include them). -**Note: no undergraduates will be allowed to work on-Grounds during Phase I.** - #### Exclusive Space previously entered {%+ for es in exclusive %}{{ es.ExclusiveSpaceRoomID + " " + es.ExclusiveSpaceBuilding.label }}{% if loop.last %}{% else %}, {% endif %}{% else %}No exclusive space entered{% endfor %} + #### Shared Space previously entered -{%+ for ss in shared %}{{ ss.SharedSpaceRoomID + " " + ss.SharedSpaceBuilding.label }}{% if loop.last %}{% else %}, {% endif %}{% else %}No shared space entered.{% endfor %} +{%+ for ss in shared %}{{ ss.SharedSpaceRoomID + " " + ss.SharedSpaceBuilding.label }}{% if loop.last %}{% else %}, {% endif %}{% else %}No shared space entered.{% endfor %} + +**Note: no undergraduates will be allowed to work on-Grounds during Phase I.** @@ -181,13 +182,14 @@ Provide information on all personnel you are requesting approval for reentry int - + + @@ -195,8 +197,11 @@ Provide information on all personnel you are requesting approval for reentry int - + + + + @@ -204,10 +209,10 @@ Provide information on all personnel you are requesting approval for reentry int - + - - + + @@ -218,7 +223,7 @@ Provide information on all personnel you are requesting approval for reentry int - + @@ -275,8 +280,9 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a - + + @@ -284,7 +290,7 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a - + @@ -296,6 +302,7 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a + @@ -303,19 +310,19 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a - + - + - + @@ -326,7 +333,7 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a - + @@ -337,7 +344,7 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a - + @@ -361,8 +368,9 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - + + @@ -371,7 +379,7 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - + @@ -382,11 +390,12 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp + - + @@ -400,14 +409,14 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - + - + @@ -416,7 +425,7 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - + @@ -427,7 +436,7 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - + @@ -520,7 +529,7 @@ Use the EHS [Lab Safety Plan During COVID 19 template](https://www.google.com/ur - Where and how to obtain PPE including face covering - + Flow_0wgdxa6 @@ -594,14 +603,14 @@ This step is internal to the system and do not require and user interaction #### Approval Process -The Research Ramp-up Plan and associated documents will be reviewed by{{ " " + ApprvlApprvrName1 }}{{ '.' if ApprvlApprvrName2 == 'n/a' else ' and ' + ApprvlApprvrName2 + '.' }} While waiting for approval, be sure that all required training has been completed and supplies secured. When the approval email notification is received, confirming the three questions below will allow you to proceed. - -{%+ set ns = namespace() %}{% set ns.exclusive = 0 %}{% set ns.shared = 0 %}{% for es in exclusive %}{% if es.ExclusiveSpaceAMComputingID is none %}{% set ns.exclusive = ns.exclusive + 1 %}{% endif %}{% endfor %}{% for ss in shared %}{% if ss.SharedSpaceAMComputingID is none %}{% set ns.shared = ns.shared + 1 %}{% endif %}{% endfor %} +The Research Ramp-up Plan and associated documents will be reviewed by{{ " " + ApprvlApprvrName1 }}{{ '.' if ApprvlApprvrName2 == 'n/a' else ' and ' + ApprvlApprvrName2 + '.' }} -#### Test -Missing Exclusive: {{ ns.exclusive }} -Missing Shared: {{ ns.shared }} +While waiting for approval, be sure that all required training has been completed and supplies secured. Additionally, if any Area Monitors were not known prior to submission, they will need to be discovered before proceeding. + + +When the approval email notification is received, confirming the three questions below and adding any missing Area Monitors will enable the Save button. + If a rejection notification is received, go back to the first step that needs to be addressed and step through each subsequent form from that point. @@ -642,6 +651,7 @@ If a rejection notification is received, go back to the first step that needs to + @@ -677,6 +687,7 @@ If a rejection notification is received, go back to the first step that needs to + @@ -724,14 +735,14 @@ If notification is received that the Research Ramp-up Plan approval process is n Notify the Area Monitor for -#### Exclusive Space previously entered -{%+ for es in exclusive %}{{ es.ExclusiveSpaceRoomID + " " + es.ExclusiveSpaceBuilding.label + " - " }}{% if es.ExclusiveSpaceAMComputingID is none %}No Area Monitor entered{% else %}{{ es.ExclusiveSpaceAMComputingID.label }}{% endif %}{% if loop.last %}{% else %}, {% endif %}{% else %}No exclusive space entered{% endfor %} +#### Exclusive Space +{%+ for es in exclusive %}{{ es.ExclusiveSpaceRoomID + " " + es.ExclusiveSpaceBuilding.label + " - " }}{% if es.ExclusiveSpaceAMComputingID is not defined %}No Area Monitor entered{% else %}{{ es.ExclusiveSpaceAMComputingID.label }}{% endif %}{% if loop.last %}{% else %}, {% endif %}{% else %}No exclusive space entered{% endfor %} -#### Shared Space previously entered -{%+ for ss in shared %}{{ ss.SharedSpaceRoomID + " " + ss.SharedSpaceBuilding.label }}{% if ss.SharedSpaceAMComputingID is none %}No Area Monitor entered{% else %}{{ ss.SharedSpaceAMComputingID.label }}{% endif %}{% if loop.last %}{% else %}, {% endif %}{% else %}No shared space entered.{% endfor %} +#### Shared Space +{%+ for ss in shared %}{{ ss.SharedSpaceRoomID + " " + ss.SharedSpaceBuilding.label + " - " }}{% if ss.SharedSpaceAMComputingID is not defined %}No Area Monitor entered{% else %}{{ ss.SharedSpaceAMComputingID.label }}{% endif %}{% if loop.last %}{% else %}, {% endif %}{% else %}No shared space entered.{% endfor %} SequenceFlow_0qc39tw Flow_0cpmvcw @@ -791,8 +802,6 @@ Provide initial weekly schedule(s) for the PI and all personnel for whom access #### Business Rule Task - - This step is internal to the system and do not require and user interaction Flow_07ge8uf Flow_0peeyne @@ -802,8 +811,6 @@ This step is internal to the system and do not require and user interaction#### Business Rule Task - - This step is internal to the system and do not require and user interaction Flow_0peeyne Flow_0tqna2m @@ -862,12 +869,12 @@ This step is internal to the system and do not require and user interaction - + - + @@ -934,7 +941,7 @@ This step is internal to the system and do not require and user interaction - + From 885ce5668e0f1da2848f5a02d3969190f18c23a9 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 1 Jun 2020 13:29:57 -0400 Subject: [PATCH 26/76] Updates RRT workflow spec files --- .../research_rampup/ResearchRampUpPlan.docx | Bin 35362 -> 52357 bytes .../exclusive_area_monitors.dmn | 6 +++--- .../bpmn/research_rampup/research_rampup.bpmn | 14 +++++++------- .../research_rampup/shared_area_monitors.dmn | 6 +++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crc/static/bpmn/research_rampup/ResearchRampUpPlan.docx b/crc/static/bpmn/research_rampup/ResearchRampUpPlan.docx index c4b9ced6ddfaf8d677b5ec4c834674f11330d804..2073e9af0baccc2176338a7a8a299177cdee61de 100644 GIT binary patch literal 52357 zcmeEtW0Po2vt^s7b=tOV+qP}H`?PJ_wr$%!ZQHiHr=R!Eoq1>G{)0Oev3Er7sQsa; zR%Wfr$|WZW41xpz1^@v70Dup0o;$0%0tf&w_G2OfKmch9+S)i7+c@bex!D;zYSXw{ zTjAw{0FmVa0R8m;|Lwo=3^b}p+OE_6>V{kKp>v|PD&qb%0ACW)#Qg^}S-+$FH#T$= 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�}IDBgfA}nI;b;x31}JsiAyO_R_lOuk{(a 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

    IIe;!73r%A>xY$*Wn$@E*)aOvy%APyznW3Q8g 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 I|NQj-0OTRu&j0`b delta 32716 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 - + - 'exclusive' in locals() and len(exclusive) + len(exclusive) @@ -14,7 +14,7 @@ - No exclusive spaces without Area Monitor + No exclusvie spaces without Area Monitor >0 diff --git a/crc/static/bpmn/research_rampup/research_rampup.bpmn b/crc/static/bpmn/research_rampup/research_rampup.bpmn index 65d49b16..83a0e360 100644 --- a/crc/static/bpmn/research_rampup/research_rampup.bpmn +++ b/crc/static/bpmn/research_rampup/research_rampup.bpmn @@ -869,12 +869,12 @@ This step is internal to the system and do not require and user interaction - + - + @@ -909,7 +909,7 @@ This step is internal to the system and do not require and user interaction - + @@ -917,7 +917,7 @@ This step is internal to the system and do not require and user interaction - + @@ -940,9 +940,6 @@ This step is internal to the system and do not require and user interaction - - - @@ -991,6 +988,9 @@ This step is internal to the system and do not require and user interaction + + + diff --git a/crc/static/bpmn/research_rampup/shared_area_monitors.dmn b/crc/static/bpmn/research_rampup/shared_area_monitors.dmn index f0746f42..c8e696d4 100644 --- a/crc/static/bpmn/research_rampup/shared_area_monitors.dmn +++ b/crc/static/bpmn/research_rampup/shared_area_monitors.dmn @@ -1,15 +1,15 @@ - + - 'shared' in locals() and len(shared) + len(shared) - sum([1 for x in exclusive if x.get('SharedSpaceAMComputingID',None) == None]) + sum([1 for x in shared if x.get('SharedSpaceAMComputingID',None) == None]) From 51accc350c103ce864c97ec43dc72ee698737f6e Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 1 Jun 2020 13:37:44 -0400 Subject: [PATCH 27/76] Updates doc template --- .../research_rampup/ResearchRampUpPlan.docx | Bin 52357 -> 52725 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/crc/static/bpmn/research_rampup/ResearchRampUpPlan.docx b/crc/static/bpmn/research_rampup/ResearchRampUpPlan.docx index 2073e9af0baccc2176338a7a8a299177cdee61de..2df53330b6c385aba05f15062e8614aa4aa461b8 100644 GIT binary patch delta 16339 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 From 3c2849f40d7e9e711a940ee8f87372e6092b070e Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 1 Jun 2020 15:29:59 -0600 Subject: [PATCH 28/76] Approval info addition --- crc/models/approval.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/crc/models/approval.py b/crc/models/approval.py index 1f7eed38..128da35d 100644 --- a/crc/models/approval.py +++ b/crc/models/approval.py @@ -69,23 +69,30 @@ class Approval(object): instance.title = model.study.title principal_investigator_id = model.study.primary_investigator_id - instance.approver = {} + instance.approver = {'uid': model.approver_uid} + instance.primary_investigator = {'uid': principal_investigator_id} try: ldap_service = LdapService() - user_info = ldap_service.user_info(principal_investigator_id) + # Primary investigator details + pi_info = ldap_service.user_info(principal_investigator_id) + instance.primary_investigator['uid'] = principal_investigator_id + instance.primary_investigator['display_name'] = pi_info.display_name + instance.primary_investigator['title'] = pi_info.title + instance.primary_investigator['department'] = pi_info.department + # Approver details + approver_info = ldap_service.user_info(model.approver_uid) + instance.approver['uid'] = model.approver_uid + instance.approver['display_name'] = approver_info.display_name + instance.approver['title'] = approver_info.title + instance.approver['department'] = approver_info.department except (ApiError, LDAPSocketOpenError) as exception: - user_info = None - instance.approver['display_name'] = 'Primary Investigator details' + instance.primary_investigator['display_name'] = 'Primary Investigator details' + instance.primary_investigator['department'] = 'currently not available' + instance.approver['display_name'] = 'Approver details' instance.approver['department'] = 'currently not available' - if user_info: - # TODO: Rename approver to primary investigator - instance.approver['uid'] = model.approver_uid - instance.approver['display_name'] = user_info.display_name - instance.approver['title'] = user_info.title - instance.approver['department'] = user_info.department - # TODO: Organize it properly, move it to services + # TODO: Rely on Dan's new service for file creation doc_dictionary = FileService.get_reference_data(FileService.DOCUMENT_LIST, 'code', ['id']) instance.associated_files = [] @@ -118,7 +125,8 @@ class ApprovalSchema(ma.Schema): class Meta: model = Approval fields = ["id", "study_id", "workflow_id", "version", "title", - "version", "status", "message", "approver", "associated_files"] + "version", "status", "message", "approver", "primary_investigator", + "associated_files", "date_created"] unknown = INCLUDE @marshmallow.post_load From f866a4a06bbac6758598ce9e19008ab25bb190b1 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Mon, 1 Jun 2020 21:45:09 -0400 Subject: [PATCH 29/76] Tweaks to approvals --- crc/api.yml | 6 +++--- crc/api/approval.py | 8 +++++--- crc/api/user.py | 5 +---- crc/models/approval.py | 36 +++++++++++++++++++++++------------- tests/test_tasks_api.py | 1 - 5 files changed, 32 insertions(+), 24 deletions(-) diff --git a/crc/api.yml b/crc/api.yml index 758169b7..f89f7a92 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -808,12 +808,12 @@ paths: $ref: "#/components/schemas/Script" /approval: parameters: - - name: approver_uid + - name: everything in: query required: false - description: Restrict results to a given approver uid, maybe we restrict the use of this at somepoint. + description: If set to true, returns all the approvals known to the system. schema: - type: string + type: boolean get: operationId: crc.api.approval.get_approvals summary: Provides a list of workflows approvals diff --git a/crc/api/approval.py b/crc/api/approval.py index 32238cf0..51dd406e 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -1,3 +1,5 @@ +from flask import g + from crc import app, db, session from crc.api.common import ApiError, ApiErrorSchema @@ -5,11 +7,11 @@ from crc.models.approval import Approval, ApprovalModel, ApprovalSchema from crc.services.approval_service import ApprovalService -def get_approvals(approver_uid=None): - if not approver_uid: +def get_approvals(everything=False): + if everything: db_approvals = ApprovalService.get_all_approvals() else: - db_approvals = ApprovalService.get_approvals_per_user(approver_uid) + db_approvals = ApprovalService.get_approvals_per_user(g.user.uid) approvals = [Approval.from_model(approval_model) for approval_model in db_approvals] results = ApprovalSchema(many=True).dump(approvals) diff --git a/crc/api/user.py b/crc/api/user.py index afa2e894..b9315f89 100644 --- a/crc/api/user.py +++ b/crc/api/user.py @@ -1,8 +1,5 @@ -import json - -import connexion import flask -from flask import redirect, g, request +from flask import g, request from crc import app, db from crc.api.common import ApiError diff --git a/crc/models/approval.py b/crc/models/approval.py index 1f7eed38..06a4b414 100644 --- a/crc/models/approval.py +++ b/crc/models/approval.py @@ -68,24 +68,33 @@ class Approval(object): if model.study: instance.title = model.study.title - principal_investigator_id = model.study.primary_investigator_id instance.approver = {} try: ldap_service = LdapService() - user_info = ldap_service.user_info(principal_investigator_id) - except (ApiError, LDAPSocketOpenError) as exception: - user_info = None - instance.approver['display_name'] = 'Primary Investigator details' - instance.approver['department'] = 'currently not available' - - if user_info: - # TODO: Rename approver to primary investigator + user_info = ldap_service.user_info(model.approver_uid) instance.approver['uid'] = model.approver_uid instance.approver['display_name'] = user_info.display_name instance.approver['title'] = user_info.title instance.approver['department'] = user_info.department + except (ApiError, LDAPSocketOpenError) as exception: + user_info = None + instance.approver['display_name'] = 'Unknown' + instance.approver['department'] = 'currently not available' + + instance.primary_investigator = {} + try: + ldap_service = LdapService() + user_info = ldap_service.user_info(model.study.primary_investigator_id) + instance.primary_investigator['uid'] = model.approver_uid + instance.primary_investigator['display_name'] = user_info.display_name + instance.primary_investigator['title'] = user_info.title + instance.primary_investigator['department'] = user_info.department + except (ApiError, LDAPSocketOpenError) as exception: + user_info = None + instance.primary_investigator['display_name'] = 'Primary Investigator details' + instance.primary_investigator['department'] = 'currently not available' + - # TODO: Organize it properly, move it to services doc_dictionary = FileService.get_reference_data(FileService.DOCUMENT_LIST, 'code', ['id']) instance.associated_files = [] @@ -98,12 +107,13 @@ class Approval(object): associated_file['id'] = approval_file.file_data.file_model.id if extra_info: irb_doc_code = approval_file.file_data.file_model.irb_doc_code - associated_file['name'] = '_'.join((irb_doc_code, approval_file.file_data.file_model.name)) + associated_file['name'] = '_'.join((extra_info['category1'], + approval_file.file_data.file_model.name)) associated_file['description'] = extra_info['description'] else: associated_file['name'] = approval_file.file_data.file_model.name associated_file['description'] = 'No description available' - associated_file['name'] = '(' + principal_investigator_id + ')' + associated_file['name'] + associated_file['name'] = '(' + model.study.primary_investigator_id + ')' + associated_file['name'] associated_file['content_type'] = approval_file.file_data.file_model.content_type instance.associated_files.append(associated_file) @@ -118,7 +128,7 @@ class ApprovalSchema(ma.Schema): class Meta: model = Approval fields = ["id", "study_id", "workflow_id", "version", "title", - "version", "status", "message", "approver", "associated_files"] + "version", "status", "message", "approver", "primary_investigator", "associated_files"] unknown = INCLUDE @marshmallow.post_load diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index 67a644ef..37849867 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 From c2a1b0175a83790899128a094714f1b941d23121 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Mon, 1 Jun 2020 21:45:09 -0400 Subject: [PATCH 30/76] Tweaks to approvals --- crc/api.yml | 6 +++--- crc/api/approval.py | 8 +++++--- crc/api/user.py | 5 +---- crc/models/approval.py | 36 +++++++++++++++++++++++------------- tests/test_approvals_api.py | 14 +++++++++++--- tests/test_tasks_api.py | 1 - 6 files changed, 43 insertions(+), 27 deletions(-) diff --git a/crc/api.yml b/crc/api.yml index 758169b7..f89f7a92 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -808,12 +808,12 @@ paths: $ref: "#/components/schemas/Script" /approval: parameters: - - name: approver_uid + - name: everything in: query required: false - description: Restrict results to a given approver uid, maybe we restrict the use of this at somepoint. + description: If set to true, returns all the approvals known to the system. schema: - type: string + type: boolean get: operationId: crc.api.approval.get_approvals summary: Provides a list of workflows approvals diff --git a/crc/api/approval.py b/crc/api/approval.py index 32238cf0..51dd406e 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -1,3 +1,5 @@ +from flask import g + from crc import app, db, session from crc.api.common import ApiError, ApiErrorSchema @@ -5,11 +7,11 @@ from crc.models.approval import Approval, ApprovalModel, ApprovalSchema from crc.services.approval_service import ApprovalService -def get_approvals(approver_uid=None): - if not approver_uid: +def get_approvals(everything=False): + if everything: db_approvals = ApprovalService.get_all_approvals() else: - db_approvals = ApprovalService.get_approvals_per_user(approver_uid) + db_approvals = ApprovalService.get_approvals_per_user(g.user.uid) approvals = [Approval.from_model(approval_model) for approval_model in db_approvals] results = ApprovalSchema(many=True).dump(approvals) diff --git a/crc/api/user.py b/crc/api/user.py index afa2e894..b9315f89 100644 --- a/crc/api/user.py +++ b/crc/api/user.py @@ -1,8 +1,5 @@ -import json - -import connexion import flask -from flask import redirect, g, request +from flask import g, request from crc import app, db from crc.api.common import ApiError diff --git a/crc/models/approval.py b/crc/models/approval.py index 1f7eed38..06a4b414 100644 --- a/crc/models/approval.py +++ b/crc/models/approval.py @@ -68,24 +68,33 @@ class Approval(object): if model.study: instance.title = model.study.title - principal_investigator_id = model.study.primary_investigator_id instance.approver = {} try: ldap_service = LdapService() - user_info = ldap_service.user_info(principal_investigator_id) - except (ApiError, LDAPSocketOpenError) as exception: - user_info = None - instance.approver['display_name'] = 'Primary Investigator details' - instance.approver['department'] = 'currently not available' - - if user_info: - # TODO: Rename approver to primary investigator + user_info = ldap_service.user_info(model.approver_uid) instance.approver['uid'] = model.approver_uid instance.approver['display_name'] = user_info.display_name instance.approver['title'] = user_info.title instance.approver['department'] = user_info.department + except (ApiError, LDAPSocketOpenError) as exception: + user_info = None + instance.approver['display_name'] = 'Unknown' + instance.approver['department'] = 'currently not available' + + instance.primary_investigator = {} + try: + ldap_service = LdapService() + user_info = ldap_service.user_info(model.study.primary_investigator_id) + instance.primary_investigator['uid'] = model.approver_uid + instance.primary_investigator['display_name'] = user_info.display_name + instance.primary_investigator['title'] = user_info.title + instance.primary_investigator['department'] = user_info.department + except (ApiError, LDAPSocketOpenError) as exception: + user_info = None + instance.primary_investigator['display_name'] = 'Primary Investigator details' + instance.primary_investigator['department'] = 'currently not available' + - # TODO: Organize it properly, move it to services doc_dictionary = FileService.get_reference_data(FileService.DOCUMENT_LIST, 'code', ['id']) instance.associated_files = [] @@ -98,12 +107,13 @@ class Approval(object): associated_file['id'] = approval_file.file_data.file_model.id if extra_info: irb_doc_code = approval_file.file_data.file_model.irb_doc_code - associated_file['name'] = '_'.join((irb_doc_code, approval_file.file_data.file_model.name)) + associated_file['name'] = '_'.join((extra_info['category1'], + approval_file.file_data.file_model.name)) associated_file['description'] = extra_info['description'] else: associated_file['name'] = approval_file.file_data.file_model.name associated_file['description'] = 'No description available' - associated_file['name'] = '(' + principal_investigator_id + ')' + associated_file['name'] + associated_file['name'] = '(' + model.study.primary_investigator_id + ')' + associated_file['name'] associated_file['content_type'] = approval_file.file_data.file_model.content_type instance.associated_files.append(associated_file) @@ -118,7 +128,7 @@ class ApprovalSchema(ma.Schema): class Meta: model = Approval fields = ["id", "study_id", "workflow_id", "version", "title", - "version", "status", "message", "approver", "associated_files"] + "version", "status", "message", "approver", "primary_investigator", "associated_files"] unknown = INCLUDE @marshmallow.post_load diff --git a/tests/test_approvals_api.py b/tests/test_approvals_api.py index b9b6d226..4b8b6e5d 100644 --- a/tests/test_approvals_api.py +++ b/tests/test_approvals_api.py @@ -64,7 +64,7 @@ class TestApprovals(BaseTest): def test_list_approvals_per_approver(self): """Only approvals associated with approver should be returned""" approver_uid = self.approval_2.approver_uid - rv = self.app.get(f'/v1.0/approval?approver_uid={approver_uid}', headers=self.logged_in_headers()) + rv = self.app.get(f'/v1.0/approval', headers=self.logged_in_headers()) self.assert_success(rv) response = json.loads(rv.get_data(as_text=True)) @@ -82,7 +82,7 @@ class TestApprovals(BaseTest): def test_list_approvals_per_admin(self): """All approvals will be returned""" - rv = self.app.get('/v1.0/approval', headers=self.logged_in_headers()) + rv = self.app.get('/v1.0/approval?everything=true', headers=self.logged_in_headers()) self.assert_success(rv) response = json.loads(rv.get_data(as_text=True)) @@ -90,7 +90,15 @@ class TestApprovals(BaseTest): # Returned approvals should match what's in the db approvals_count = ApprovalModel.query.count() response_count = len(response) - self.assertEqual(approvals_count, response_count) + self.assertEqual(2, response_count) + + rv = self.app.get('/v1.0/approval', headers=self.logged_in_headers()) + self.assert_success(rv) + response_count = len(response) + self.assertEqual(1, response_count) + + + def test_update_approval(self): """Approval status will be updated""" diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index 67a644ef..37849867 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 From 95a3b22bf0a129e9d112a3fc2ab8779be50db4ed Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 1 Jun 2020 19:59:55 -0600 Subject: [PATCH 31/76] Enabling Sentry for boxes with the flag set --- Pipfile | 1 + Pipfile.lock | 49 +++++++++++++++++++++++++++++++---------------- config/default.py | 3 +++ crc/__init__.py | 8 ++++++++ 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/Pipfile b/Pipfile index 17497132..6a5e31dd 100644 --- a/Pipfile +++ b/Pipfile @@ -38,6 +38,7 @@ xlrd = "*" ldap3 = "*" gunicorn = "*" werkzeug = "*" +sentry-sdk = {extras = ["flask"],version = "==0.14.4"} [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index ce620efc..aca63a57 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "979f996148ee181e3e0af2a3777aa1d00d0fd5d943d49df65963e694b8a88871" + "sha256": "54d9d51360f54762138a3acc7696badd1d711e7b1dde9e2d82aa706e40c17102" }, "pipfile-spec": 6, "requires": { @@ -32,10 +32,10 @@ }, "amqp": { "hashes": [ - "sha256:6e649ca13a7df3faacdc8bbb280aa9a6602d22fd9d545336077e573a1f4ff3b8", - "sha256:77f1aef9410698d20eaeac5b73a87817365f457a507d82edf292e12cbb83b08d" + "sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b", + "sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139" ], - "version": "==2.5.2" + "version": "==2.6.0" }, "aniso8601": { "hashes": [ @@ -96,12 +96,18 @@ ], "version": "==3.6.3.0" }, + "blinker": { + "hashes": [ + "sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6" + ], + "version": "==1.4" + }, "celery": { "hashes": [ - "sha256:108a0bf9018a871620936c33a3ee9f6336a89f8ef0a0f567a9001f4aa361415f", - "sha256:5b4b37e276033fe47575107a2775469f0b721646a08c96ec2c61531e4fe45f2a" + "sha256:5147662e23dc6bc39c17a2cbc9a148debe08ecfb128b0eded14a0d9c81fc5742", + "sha256:df2937b7536a2a9b18024776a3a46fd281721813636c03a5177fa02fe66078f6" ], - "version": "==4.4.2" + "version": "==4.4.3" }, "certifi": { "hashes": [ @@ -381,10 +387,10 @@ }, "kombu": { "hashes": [ - "sha256:2d1cda774126a044d91a7ff5fa6d09edf99f46924ab332a810760fe6740e9b76", - "sha256:598e7e749d6ab54f646b74b2d2df67755dee13894f73ab02a2a9feb8870c7cb2" + "sha256:ab0afaa5388dd2979cbc439d3623b86a4f7a58d41f621096bef7767c37bc2505", + "sha256:aece08f48706743aaa1b9d607fee300559481eafcc5ee56451aa0ef867a3be07" ], - "version": "==4.6.8" + "version": "==4.6.9" }, "ldap3": { "hashes": [ @@ -704,6 +710,17 @@ "index": "pypi", "version": "==2.23.0" }, + "sentry-sdk": { + "extras": [ + "flask" + ], + "hashes": [ + "sha256:0e5e947d0f7a969314aa23669a94a9712be5a688ff069ff7b9fc36c66adc160c", + "sha256:799a8bf76b012e3030a881be00e97bc0b922ce35dde699c6537122b751d80e2c" + ], + "index": "pypi", + "version": "==0.14.4" + }, "six": { "hashes": [ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", @@ -838,10 +855,10 @@ }, "waitress": { "hashes": [ - "sha256:045b3efc3d97c93362173ab1dfc159b52cfa22b46c3334ffc805dbdbf0e4309e", - "sha256:77ff3f3226931a1d7d8624c5371de07c8e90c7e5d80c5cc660d72659aaf23f38" + "sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261", + "sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db" ], - "version": "==1.4.3" + "version": "==1.4.4" }, "webob": { "hashes": [ @@ -966,10 +983,10 @@ }, "wcwidth": { "hashes": [ - "sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1", - "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1" + "sha256:3de2e41158cb650b91f9654cbf9a3e053cee0719c9df4ddc11e4b568669e9829", + "sha256:b651b6b081476420e4e9ae61239ac4c1b49d0c5ace42b2e81dc2ff49ed50c566" ], - "version": "==0.1.9" + "version": "==0.2.2" }, "zipp": { "hashes": [ diff --git a/config/default.py b/config/default.py index e368b32d..289c506f 100644 --- a/config/default.py +++ b/config/default.py @@ -13,6 +13,9 @@ DEVELOPMENT = environ.get('DEVELOPMENT', default="true") == "true" TESTING = environ.get('TESTING', default="false") == "true" PRODUCTION = (environ.get('PRODUCTION', default="false") == "true") or (not DEVELOPMENT and not TESTING) +# Sentry flag +ENABLE_SENTRY = environ.get('ENABLE_SENTRY', default="false") == "true" + # 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 fe510daf..e705321b 100644 --- a/crc/__init__.py +++ b/crc/__init__.py @@ -1,11 +1,13 @@ import logging import os +import sentry_sdk import connexion from flask_cors import CORS from flask_marshmallow import Marshmallow from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy +from sentry_sdk.integrations.flask import FlaskIntegration logging.basicConfig(level=logging.INFO) @@ -40,6 +42,12 @@ connexion_app.add_api('api.yml', base_path='/v1.0') origins_re = [r"^https?:\/\/%s(.*)" % o.replace('.', '\.') for o in app.config['CORS_ALLOW_ORIGINS']] cors = CORS(connexion_app.app, origins=origins_re) +if app.config['ENABLE_SENTRY']: + sentry_sdk.init( + dsn="https://25342ca4e2d443c6a5c49707d68e9f40@o401361.ingest.sentry.io/5260915", + integrations=[FlaskIntegration()] + ) + print('=== USING THESE CONFIG SETTINGS: ===') print('DB_HOST = ', ) print('CORS_ALLOW_ORIGINS = ', app.config['CORS_ALLOW_ORIGINS']) From d8329e9effb994c68882513027e94210f82affea Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 2 Jun 2020 07:43:19 -0400 Subject: [PATCH 32/76] I completely missed pushing up my changes last night before handing back over to Carlos. Sorry about that. Added checks to assure the approver matches g.uid of the current user - otherwise don't modify the approval record. --- crc/api/approval.py | 3 ++ crc/models/approval.py | 4 +- tests/test_approvals_api.py | 91 +++++++++++++++++++------------------ 3 files changed, 53 insertions(+), 45 deletions(-) diff --git a/crc/api/approval.py b/crc/api/approval.py index 51dd406e..5a4299e2 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -34,6 +34,9 @@ def update_approval(approval_id, body): raise ApiError('unknown_approval', 'The approval "' + str(approval_id) + '" is not recognized.') approval: Approval = ApprovalSchema().load(body) + if approval_model.approver_uid != g.user.uid: + raise ApiError("not_your_approval", "You may not modify this approval. It belongs to another user.") + approval.update_model(approval_model) session.commit() diff --git a/crc/models/approval.py b/crc/models/approval.py index 198b5ef9..3a95c680 100644 --- a/crc/models/approval.py +++ b/crc/models/approval.py @@ -128,8 +128,8 @@ class ApprovalSchema(ma.Schema): class Meta: model = Approval fields = ["id", "study_id", "workflow_id", "version", "title", - "version", "status", "message", "approver", "primary_investigator", - "associated_files", "date_created"] + "status", "message", "approver", "primary_investigator", + "associated_files", "date_created"] unknown = INCLUDE @marshmallow.post_load diff --git a/tests/test_approvals_api.py b/tests/test_approvals_api.py index 7cf3d4a0..89eb4ab2 100644 --- a/tests/test_approvals_api.py +++ b/tests/test_approvals_api.py @@ -5,35 +5,6 @@ from crc import app, db, session from crc.models.approval import ApprovalModel, ApprovalSchema, ApprovalStatus -APPROVAL_PAYLOAD = { - 'id': None, - 'approver': { - 'uid': 'bgb22', - 'display_name': 'Billy Bob (bgb22)', - 'title': 'E42:He\'s a hoopy frood', - 'department': 'E0:EN-Eng Study of Parallel Universes' - }, - 'title': 'El Study', - 'status': 'DECLINED', - 'version': 1, - 'message': 'Incorrect documents', - 'associated_files': [ - { - 'id': 42, - 'name': 'File 1', - 'content_type': 'document' - }, - { - 'id': 43, - 'name': 'File 2', - 'content_type': 'document' - } - ], - 'workflow_id': 1, - 'study_id': 1 -} - - class TestApprovals(BaseTest): def setUp(self): """Initial setup shared by all TestApprovals tests""" @@ -98,25 +69,59 @@ class TestApprovals(BaseTest): response_count = len(response) self.assertEqual(1, response_count) + def test_update_approval_fails_if_not_the_approver(self): + approval = session.query(ApprovalModel).filter_by(approver_uid='arc93').first() + data = {'id': approval.id, + "approver_uid": "dhf8r", + 'message': "Approved. I like the cut of your jib.", + 'status': ApprovalStatus.APPROVED.value} + self.assertEqual(approval.status, ApprovalStatus.PENDING.value) - - def test_update_approval(self): - """Approval status will be updated""" - approval_id = self.approval.id - data = dict(APPROVAL_PAYLOAD) - data['id'] = approval_id - - self.assertEqual(self.approval.status, ApprovalStatus.PENDING.value) - - rv = self.app.put(f'/v1.0/approval/{approval_id}', + rv = self.app.put(f'/v1.0/approval/{approval.id}', content_type="application/json", - headers=self.logged_in_headers(), + headers=self.logged_in_headers(), # As dhf8r + data=json.dumps(data)) + self.assert_failure(rv) + + def test_accept_approval(self): + approval = session.query(ApprovalModel).filter_by(approver_uid='dhf8r').first() + data = {'id': approval.id, + "approver_uid": "dhf8r", + 'message': "Approved. I like the cut of your jib.", + 'status': ApprovalStatus.APPROVED.value} + + self.assertEqual(approval.status, ApprovalStatus.PENDING.value) + + rv = self.app.put(f'/v1.0/approval/{approval.id}', + content_type="application/json", + headers=self.logged_in_headers(), # As dhf8r data=json.dumps(data)) self.assert_success(rv) - session.refresh(self.approval) + session.refresh(approval) # Updated record should now have the data sent to the endpoint - self.assertEqual(self.approval.message, data['message']) - self.assertEqual(self.approval.status, ApprovalStatus.DECLINED.value) + self.assertEqual(approval.message, data['message']) + self.assertEqual(approval.status, ApprovalStatus.APPROVED.value) + + def test_decline_approval(self): + approval = session.query(ApprovalModel).filter_by(approver_uid='dhf8r').first() + data = {'id': approval.id, + "approver_uid": "dhf8r", + 'message': "Approved. I find the cut of your jib lacking.", + 'status': ApprovalStatus.DECLINED.value} + + self.assertEqual(approval.status, ApprovalStatus.PENDING.value) + + rv = self.app.put(f'/v1.0/approval/{approval.id}', + content_type="application/json", + headers=self.logged_in_headers(), # As dhf8r + data=json.dumps(data)) + self.assert_success(rv) + + session.refresh(approval) + + # Updated record should now have the data sent to the endpoint + self.assertEqual(approval.message, data['message']) + self.assertEqual(approval.status, ApprovalStatus.DECLINED.value) From 59e91ec41498d2777d103daecb5534d4a78247cd Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 2 Jun 2020 08:35:19 -0400 Subject: [PATCH 33/76] Don't add an empty approval record. --- crc/scripts/request_approval.py | 3 ++- tests/test_request_approval_script.py | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crc/scripts/request_approval.py b/crc/scripts/request_approval.py index 1df1a670..1e5d2c6c 100644 --- a/crc/scripts/request_approval.py +++ b/crc/scripts/request_approval.py @@ -26,7 +26,8 @@ RequestApproval approver1 "dhf8r" ApprovalService.add_approval(study_id, workflow_id, args) elif isinstance(uids, list): for id in uids: - ApprovalService.add_approval(study_id, workflow_id, id) + if id: ## Assure it's not empty or null + ApprovalService.add_approval(study_id, workflow_id, id) def get_uids(self, task, args): if len(args) < 1: diff --git a/tests/test_request_approval_script.py b/tests/test_request_approval_script.py index 2f4ab49e..8cd56807 100644 --- a/tests/test_request_approval_script.py +++ b/tests/test_request_approval_script.py @@ -1,6 +1,6 @@ -from crc.services.file_service import FileService from tests.base_test import BaseTest +from crc.services.file_service import FileService from crc.scripts.request_approval import RequestApproval from crc.services.workflow_processor import WorkflowProcessor from crc.api.common import ApiError @@ -26,6 +26,22 @@ class TestRequestApprovalScript(BaseTest): script.do_task(task, workflow.study_id, workflow.id, "study.approval1", "study.approval2") self.assertEquals(2, db.session.query(ApprovalModel).count()) + def test_do_task_with_blank_second_approver(self): + self.load_example_data() + self.create_reference_document() + workflow = self.create_workflow('empty_workflow') + processor = WorkflowProcessor(workflow) + task = processor.next_task() + task.data = {"study": {"approval1": "dhf8r", 'approval2':''}} + FileService.add_workflow_file(workflow_id=workflow.id, + irb_doc_code="UVACompl_PRCAppr", + name="anything.png", content_type="text", + binary_data=b'1234') + script = RequestApproval() + script.do_task(task, workflow.study_id, workflow.id, "study.approval1", "study.approval2") + self.assertEquals(1, db.session.query(ApprovalModel).count()) + + def test_do_task_with_incorrect_argument(self): """This script should raise an error if it can't figure out the approvers.""" self.load_example_data() From c7484267e10caa38e99bf2c17210f65bd981b140 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 2 Jun 2020 18:17:00 -0400 Subject: [PATCH 34/76] For the main approval endpoints - we now group the approvals by study. So you get one record back for each study, but it may have other approvals along with it as "related_approvals". We now cache the LDAP records - so we look in our own database for the record before calling out to ldap for the details when given a straight up computing id like dhf8r. Added "date_approved" to the approval model. And moved the approver and primary investigator into real associated models to make it easier to dump. Fixed a problem with the validation that was causing it to throw incorrect errors on valid workflows. Getting it to behave a little more like the front end behaves, and respecting the read-only fields. But it was mainly to do with always returning all the data with each form submission. --- crc/api/approval.py | 9 ++- crc/api/user.py | 4 +- crc/models/approval.py | 80 +++++-------------- crc/models/ldap.py | 39 +++++++++ crc/services/approval_service.py | 60 +++++++++++--- crc/services/ldap_service.py | 49 ++++-------- crc/services/lookup_service.py | 6 +- crc/services/study_service.py | 7 +- crc/services/workflow_service.py | 4 +- .../bpmn/research_rampup/research_rampup.bpmn | 34 ++++---- tests/test_approvals_api.py | 31 +++++-- tests/test_ldap_service.py | 5 +- 12 files changed, 182 insertions(+), 146 deletions(-) create mode 100644 crc/models/ldap.py diff --git a/crc/api/approval.py b/crc/api/approval.py index 5a4299e2..db895176 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -1,3 +1,5 @@ +from datetime import datetime + from flask import g from crc import app, db, session @@ -9,11 +11,9 @@ from crc.services.approval_service import ApprovalService def get_approvals(everything=False): if everything: - db_approvals = ApprovalService.get_all_approvals() + approvals = ApprovalService.get_all_approvals() else: - db_approvals = ApprovalService.get_approvals_per_user(g.user.uid) - approvals = [Approval.from_model(approval_model) for approval_model in db_approvals] - + approvals = ApprovalService.get_approvals_per_user(g.user.uid) results = ApprovalSchema(many=True).dump(approvals) return results @@ -38,6 +38,7 @@ def update_approval(approval_id, body): raise ApiError("not_your_approval", "You may not modify this approval. It belongs to another user.") approval.update_model(approval_model) + approval_model.date_approved = datetime.now() session.commit() result = ApprovalSchema().dump(approval) diff --git a/crc/api/user.py b/crc/api/user.py index b9315f89..172b7496 100644 --- a/crc/api/user.py +++ b/crc/api/user.py @@ -4,7 +4,7 @@ from flask import g, request from crc import app, db from crc.api.common import ApiError from crc.models.user import UserModel, UserModelSchema -from crc.services.ldap_service import LdapService, LdapUserInfo +from crc.services.ldap_service import LdapService, LdapModel """ .. module:: crc.api.user @@ -78,7 +78,7 @@ def sso(): return response -def _handle_login(user_info: LdapUserInfo, redirect_url=app.config['FRONTEND_AUTH_CALLBACK']): +def _handle_login(user_info: LdapModel, redirect_url=app.config['FRONTEND_AUTH_CALLBACK']): """On successful login, adds user to database if the user is not already in the system, then returns the frontend auth callback URL, with auth token appended. diff --git a/crc/models/approval.py b/crc/models/approval.py index 3a95c680..7f819bcb 100644 --- a/crc/models/approval.py +++ b/crc/models/approval.py @@ -1,17 +1,17 @@ import enum import marshmallow -from ldap3.core.exceptions import LDAPSocketOpenError -from marshmallow import INCLUDE +from marshmallow import INCLUDE, fields from sqlalchemy import func -from crc import db, ma +from crc import db, ma, app from crc.api.common import ApiError from crc.models.file import FileDataModel +from crc.models.ldap import LdapSchema from crc.models.study import StudyModel from crc.models.workflow import WorkflowModel -from crc.services.ldap_service import LdapService from crc.services.file_service import FileService +from crc.services.ldap_service import LdapService class ApprovalStatus(enum.Enum): @@ -33,13 +33,14 @@ class ApprovalModel(db.Model): __tablename__ = 'approval' id = db.Column(db.Integer, primary_key=True) study_id = db.Column(db.Integer, db.ForeignKey(StudyModel.id), nullable=False) - study = db.relationship(StudyModel, backref='approval', cascade='all,delete') + study = db.relationship(StudyModel) workflow_id = db.Column(db.Integer, db.ForeignKey(WorkflowModel.id), nullable=False) workflow = db.relationship(WorkflowModel) approver_uid = db.Column(db.String) # Not linked to user model, as they may not have logged in yet. status = db.Column(db.String) message = db.Column(db.String, default='') date_created = db.Column(db.DateTime(timezone=True), default=func.now()) + date_approved = db.Column(db.DateTime(timezone=True), default=None) version = db.Column(db.Integer) # Incremented integer, so 1,2,3 as requests are made. approval_files = db.relationship(ApprovalFile, back_populates="approval", cascade="all, delete, delete-orphan", @@ -65,38 +66,19 @@ class Approval(object): instance.date_created = model.date_created instance.version = model.version instance.title = '' + instance.related_approvals = [] + if model.study: instance.title = model.study.title - instance.approver = {} + ldap_service = LdapService() try: - ldap_service = LdapService() - user_info = ldap_service.user_info(model.approver_uid) - instance.approver['uid'] = model.approver_uid - instance.approver['display_name'] = user_info.display_name - instance.approver['title'] = user_info.title - instance.approver['department'] = user_info.department - except (ApiError, LDAPSocketOpenError) as exception: - user_info = None - instance.approver['display_name'] = 'Unknown' - instance.approver['department'] = 'currently not available' - - instance.primary_investigator = {} - try: - ldap_service = LdapService() - user_info = ldap_service.user_info(model.study.primary_investigator_id) - instance.primary_investigator['uid'] = model.approver_uid - instance.primary_investigator['display_name'] = user_info.display_name - instance.primary_investigator['title'] = user_info.title - instance.primary_investigator['department'] = user_info.department - except (ApiError, LDAPSocketOpenError) as exception: - user_info = None - instance.primary_investigator['display_name'] = 'Primary Investigator details' - instance.primary_investigator['department'] = 'currently not available' - + instance.approver = ldap_service.user_info(model.approver_uid) + instance.primary_investigator = ldap_service.user_info(model.study.primary_investigator_id) + except ApiError as ae: + app.logger.error("Ldap lookup failed for approval record %i" % model.id) doc_dictionary = FileService.get_reference_data(FileService.DOCUMENT_LIST, 'code', ['id']) - instance.associated_files = [] for approval_file in model.approval_files: try: @@ -125,11 +107,17 @@ class Approval(object): class ApprovalSchema(ma.Schema): + + approver = fields.Nested(LdapSchema, dump_only=True) + primary_investigator = fields.Nested(LdapSchema, dump_only=True) + related_approvals = fields.List(fields.Nested('ApprovalSchema', dump_only=True)) + class Meta: model = Approval fields = ["id", "study_id", "workflow_id", "version", "title", "status", "message", "approver", "primary_investigator", - "associated_files", "date_created"] + "associated_files", "date_created", "date_approved", + "related_approvals"] unknown = INCLUDE @marshmallow.post_load @@ -137,30 +125,4 @@ class ApprovalSchema(ma.Schema): """Loads the basic approval data for updates to the database""" return Approval(**data) -# Carlos: Here is the data structure I was trying to imagine. -# If I were to continue down my current traing of thought, I'd create -# another class called just "Approval" that can take an ApprovalModel from the -# database and construct a data structure like this one, that can -# be provided to the API at an /approvals endpoint with GET and PUT -# dat = { "approvals": [ -# {"id": 1, -# "study_id": 20, -# "workflow_id": 454, -# "study_title": "Dan Funk (dhf8r)", # Really it's just the name of the Principal Investigator -# "workflow_version": "21", -# "approver": { # Pulled from ldap -# "uid": "bgb22", -# "display_name": "Billy Bob (bgb22)", -# "title": "E42:He's a hoopy frood", -# "department": "E0:EN-Eng Study of Parallel Universes", -# }, -# "files": [ -# { -# "id": 124, -# "name": "ResearchRestart.docx", -# "content_type": "docx-something-whatever" -# } -# ] -# } -# ... -# ] + diff --git a/crc/models/ldap.py b/crc/models/ldap.py new file mode 100644 index 00000000..50c0654a --- /dev/null +++ b/crc/models/ldap.py @@ -0,0 +1,39 @@ +from flask_marshmallow.sqla import SQLAlchemyAutoSchema +from marshmallow import EXCLUDE +from sqlalchemy import func, inspect + +from crc import db + + +class LdapModel(db.Model): + uid = db.Column(db.String, primary_key=True) + display_name = db.Column(db.String) + given_name = db.Column(db.String) + email_address = db.Column(db.String) + telephone_number = db.Column(db.String) + title = db.Column(db.String) + department = db.Column(db.String) + affiliation = db.Column(db.String) + sponsor_type = db.Column(db.String) + date_cached = db.Column(db.DateTime(timezone=True), default=func.now()) + + @classmethod + def from_entry(cls, entry): + return LdapModel(uid=entry.uid.value, + display_name=entry.displayName.value, + given_name=", ".join(entry.givenName), + email_address=entry.mail.value, + telephone_number=entry.telephoneNumber.value, + title=", ".join(entry.title), + department=", ".join(entry.uvaDisplayDepartment), + affiliation=", ".join(entry.uvaPersonIAMAffiliation), + sponsor_type=", ".join(entry.uvaPersonSponsoredType)) + + +class LdapSchema(SQLAlchemyAutoSchema): + class Meta: + model = LdapModel + load_instance = True + include_relationships = True + include_fk = True # Includes foreign keys + unknown = EXCLUDE diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index 39886d62..63cdaff4 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -5,7 +5,8 @@ from sqlalchemy import desc from crc import db, session from crc.api.common import ApiError -from crc.models.approval import ApprovalModel, ApprovalStatus, ApprovalFile +from crc.models.approval import ApprovalModel, ApprovalStatus, ApprovalFile, Approval +from crc.models.study import StudyModel from crc.models.workflow import WorkflowModel from crc.services.file_service import FileService @@ -14,22 +15,57 @@ class ApprovalService(object): """Provides common tools for working with an Approval""" @staticmethod - def get_approvals_per_user(approver_uid): - """Returns a list of all approvals for the given user (approver)""" - db_approvals = session.query(ApprovalModel).filter_by(approver_uid=approver_uid).all() - return db_approvals + def __one_approval_from_study(study, approver_uid = None): + """Returns one approval, with all additional approvals as 'related_approvals', + the main approval can be pinned to an approver with an optional argument. + Will return null if no approvals exist on the study.""" + main_approval = None + related_approvals = [] + approvals = db.session.query(ApprovalModel).filter(ApprovalModel.study_id == study.id).all() + for approval_model in approvals: + if approval_model.approver_uid == approver_uid: + main_approval = Approval.from_model(approval_model) + else: + related_approvals.append(Approval.from_model(approval_model)) + if not main_approval and len(related_approvals) > 0: + main_approval = related_approvals[0] + related_approvals = related_approvals[1:] + if len(related_approvals) > 0: + main_approval.related_approvals = related_approvals + return main_approval @staticmethod - def get_approvals_for_study(study_id): - """Returns a list of all approvals for the given study""" - db_approvals = session.query(ApprovalModel).filter_by(study_id=study_id).all() - return db_approvals + def get_approvals_per_user(approver_uid): + """Returns a list of approval objects (not db models) for the given + approver. """ + studies = db.session.query(StudyModel).join(ApprovalModel).\ + filter(ApprovalModel.approver_uid == approver_uid).all() + approvals = [] + for study in studies: + approval = ApprovalService.__one_approval_from_study(study, approver_uid) + if approval: + approvals.append(approval) + return approvals @staticmethod def get_all_approvals(): - """Returns a list of all approvlas""" - db_approvals = session.query(ApprovalModel).all() - return db_approvals + """Returns a list of all approval objects (not db models), one record + per study, with any associated approvals grouped under the first approval.""" + studies = db.session.query(StudyModel).all() + approvals = [] + for study in studies: + approval = ApprovalService.__one_approval_from_study(study) + if approval: + approvals.append(approval) + return approvals + + @staticmethod + def get_approvals_for_study(study_id): + """Returns an array of Approval objects for the study, it does not + compute the related approvals.""" + db_approvals = session.query(ApprovalModel).filter_by(study_id=study_id).all() + return [Approval.from_model(approval_model) for approval_model in db_approvals] + @staticmethod def update_approval(approval_id, approver_uid, status): diff --git a/crc/services/ldap_service.py b/crc/services/ldap_service.py index 5fb3544f..7d95a71b 100644 --- a/crc/services/ldap_service.py +++ b/crc/services/ldap_service.py @@ -1,40 +1,15 @@ import os +from attr import asdict from ldap3.core.exceptions import LDAPExceptionError -from crc import app +from crc import app, db from ldap3 import Connection, Server, MOCK_SYNC from crc.api.common import ApiError +from crc.models.ldap import LdapModel, LdapSchema -class LdapUserInfo(object): - - def __init__(self): - self.display_name = '' - self.given_name = '' - self.email_address = '' - self.telephone_number = '' - self.title = '' - self.department = '' - self.affiliation = '' - self.sponsor_type = '' - self.uid = '' - - @classmethod - def from_entry(cls, entry): - instance = cls() - instance.display_name = entry.displayName.value - instance.given_name = ", ".join(entry.givenName) - instance.email_address = entry.mail.value - instance.telephone_number = ", ".join(entry.telephoneNumber) - instance.title = ", ".join(entry.title) - instance.department = ", ".join(entry.uvaDisplayDepartment) - instance.affiliation = ", ".join(entry.uvaPersonIAMAffiliation) - instance.sponsor_type = ", ".join(entry.uvaPersonSponsoredType) - instance.uid = entry.uid.value - return instance - class LdapService(object): search_base = "ou=People,o=University of Virginia,c=US" attributes = ['uid', 'cn', 'sn', 'displayName', 'givenName', 'mail', 'objectClass', 'UvaDisplayDepartment', @@ -63,12 +38,16 @@ class LdapService(object): self.conn.unbind() def user_info(self, uva_uid): - search_string = LdapService.uid_search_string % uva_uid - self.conn.search(LdapService.search_base, search_string, attributes=LdapService.attributes) - if len(self.conn.entries) < 1: - raise ApiError("missing_ldap_record", "Unable to locate a user with id %s in LDAP" % uva_uid) - entry = self.conn.entries[0] - return LdapUserInfo.from_entry(entry) + user_info = db.session.query(LdapModel).filter(LdapModel.uid == uva_uid).first() + if not user_info: + search_string = LdapService.uid_search_string % uva_uid + self.conn.search(LdapService.search_base, search_string, attributes=LdapService.attributes) + if len(self.conn.entries) < 1: + raise ApiError("missing_ldap_record", "Unable to locate a user with id %s in LDAP" % uva_uid) + entry = self.conn.entries[0] + user_info = LdapModel.from_entry(entry) + db.session.add(user_info) + return user_info def search_users(self, query, limit): if len(query.strip()) < 3: @@ -95,7 +74,7 @@ class LdapService(object): for entry in self.conn.entries: if count > limit: break - results.append(LdapUserInfo.from_entry(entry)) + results.append(LdapSchema().dump(LdapModel.from_entry(entry))) count += 1 except LDAPExceptionError as le: app.logger.info("Failed to execute ldap search. %s", str(le)) diff --git a/crc/services/lookup_service.py b/crc/services/lookup_service.py index 95902fe0..076fbe9a 100644 --- a/crc/services/lookup_service.py +++ b/crc/services/lookup_service.py @@ -196,8 +196,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.__dict__ + user_list.append( {"value": user['uid'], + "label": user['display_name'] + " (" + user['uid'] + ")", + "data": user }) return user_list \ No newline at end of file diff --git a/crc/services/study_service.py b/crc/services/study_service.py index e6ef5291..8d9df0db 100644 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -10,6 +10,7 @@ from ldap3.core.exceptions import LDAPSocketOpenError from crc import db, session, app 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.study import StudyModel, Study, Category, WorkflowMetadata @@ -56,9 +57,7 @@ class StudyService(object): study = Study.from_model(study_model) study.categories = StudyService.get_categories() workflow_metas = StudyService.__get_workflow_metas(study_id) - approvals = ApprovalService.get_approvals_for_study(study.id) - study.approvals = [Approval.from_model(approval_model) for approval_model in approvals] - + study.approvals = ApprovalService.get_approvals_for_study(study.id) files = FileService.get_files_for_study(study.id) files = (File.from_models(model, FileService.get_file_data(model.id), FileService.get_doc_dictionary()) for model in files) @@ -208,7 +207,7 @@ class StudyService(object): def get_ldap_dict_if_available(user_id): try: ldap_service = LdapService() - return ldap_service.user_info(user_id).__dict__ + return LdapSchema().dump(ldap_service.user_info(user_id)) except ApiError as ae: app.logger.info(str(ae)) return {"error": str(ae)} diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 312dee3c..c3834f0a 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -107,11 +107,13 @@ class WorkflowService(object): if not hasattr(task.task_spec, 'form'): return - form_data = {} + form_data = task.data # Just like with the front end, we start with what was already there, and modify it. for field in task_api.form.fields: if required_only and (not field.has_validation(Task.VALIDATION_REQUIRED) or field.get_validation(Task.VALIDATION_REQUIRED).lower().strip() != "true"): continue # Don't include any fields that aren't specifically marked as required. + if field.has_property("read_only") and field.get_property("read_only").lower().strip() == "true": + continue # Don't mess about with read only fields. if field.has_property(Task.PROP_OPTIONS_REPEAT): group = field.get_property(Task.PROP_OPTIONS_REPEAT) if group not in form_data: diff --git a/crc/static/bpmn/research_rampup/research_rampup.bpmn b/crc/static/bpmn/research_rampup/research_rampup.bpmn index 83a0e360..19588731 100644 --- a/crc/static/bpmn/research_rampup/research_rampup.bpmn +++ b/crc/static/bpmn/research_rampup/research_rampup.bpmn @@ -5,10 +5,7 @@ SequenceFlow_05ja25w - ## **Beta Stage: All data entered will be destroyed before public launch** - - -### UNIVERSITY OF VIRGINIA RESEARCH + ### UNIVERSITY OF VIRGINIA RESEARCH [From Research Ramp-up Guidance](https://research.virginia.edu/research-ramp-guidance) @@ -319,8 +316,11 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a + + + - + @@ -328,6 +328,7 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a + @@ -423,7 +424,7 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp - + @@ -431,6 +432,7 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp + @@ -644,7 +646,7 @@ If a rejection notification is received, go back to the first step that needs to - + @@ -656,14 +658,14 @@ If a rejection notification is received, go back to the first step that needs to - + - + @@ -672,7 +674,7 @@ If a rejection notification is received, go back to the first step that needs to - + @@ -683,7 +685,7 @@ If a rejection notification is received, go back to the first step that needs to - + @@ -693,13 +695,13 @@ If a rejection notification is received, go back to the first step that needs to - + - + @@ -940,6 +942,9 @@ This step is internal to the system and do not require and user interaction + + + @@ -988,9 +993,6 @@ This step is internal to the system and do not require and user interaction - - - diff --git a/tests/test_approvals_api.py b/tests/test_approvals_api.py index 89eb4ab2..f323321c 100644 --- a/tests/test_approvals_api.py +++ b/tests/test_approvals_api.py @@ -1,8 +1,10 @@ import json from tests.base_test import BaseTest -from crc import app, db, session +from crc import session from crc.models.approval import ApprovalModel, ApprovalSchema, ApprovalStatus +from crc.models.protocol_builder import ProtocolBuilderStatus +from crc.models.study import StudyModel class TestApprovals(BaseTest): @@ -11,11 +13,16 @@ class TestApprovals(BaseTest): self.load_example_data() self.study = self.create_study() self.workflow = self.create_workflow('random_fact') + self.unrelated_study = StudyModel(title="second study", + protocol_builder_status=ProtocolBuilderStatus.ACTIVE, + user_uid="dhf8r", primary_investigator_id="dhf8r") + self.unrelated_workflow = self.create_workflow('random_fact', study=self.unrelated_study) + # TODO: Move to base_test as a helper self.approval = ApprovalModel( study=self.study, workflow=self.workflow, - approver_uid='arc93', + approver_uid='lb3dp', status=ApprovalStatus.PENDING.value, version=1 ) @@ -30,6 +37,16 @@ class TestApprovals(BaseTest): ) session.add(self.approval_2) + # A third study, unrelated to the first. + self.approval_3 = ApprovalModel( + study=self.unrelated_study, + workflow=self.unrelated_workflow, + approver_uid='lb3dp', + status=ApprovalStatus.PENDING.value, + version=1 + ) + session.add(self.approval_3) + session.commit() def test_list_approvals_per_approver(self): @@ -40,9 +57,9 @@ class TestApprovals(BaseTest): response = json.loads(rv.get_data(as_text=True)) - # Stored approvals are 2 + # Stored approvals are 3 approvals_count = ApprovalModel.query.count() - self.assertEqual(approvals_count, 2) + self.assertEqual(approvals_count, 3) # but Dan's approvals should be only 1 self.assertEqual(len(response), 1) @@ -58,7 +75,8 @@ class TestApprovals(BaseTest): response = json.loads(rv.get_data(as_text=True)) - # Returned approvals should match what's in the db + # Returned approvals should match what's in the db, we should get one approval back + # per study (2 studies), and that approval should have one related approval. approvals_count = ApprovalModel.query.count() response_count = len(response) self.assertEqual(2, response_count) @@ -68,9 +86,10 @@ class TestApprovals(BaseTest): response = json.loads(rv.get_data(as_text=True)) response_count = len(response) self.assertEqual(1, response_count) + self.assertEqual(1, len(response[0]['related_approvals'])) # this approval has a related approval. def test_update_approval_fails_if_not_the_approver(self): - approval = session.query(ApprovalModel).filter_by(approver_uid='arc93').first() + approval = session.query(ApprovalModel).filter_by(approver_uid='lb3dp').first() data = {'id': approval.id, "approver_uid": "dhf8r", 'message': "Approved. I like the cut of your jib.", diff --git a/tests/test_ldap_service.py b/tests/test_ldap_service.py index 4be65960..9e2e8931 100644 --- a/tests/test_ldap_service.py +++ b/tests/test_ldap_service.py @@ -1,10 +1,7 @@ -import os +from tests.base_test import BaseTest -from crc import app from crc.api.common import ApiError from crc.services.ldap_service import LdapService -from tests.base_test import BaseTest -from ldap3 import Server, Connection, ALL, MOCK_SYNC class TestLdapService(BaseTest): From 2cd63116667d1370eb78620f0f6c4ca21904b9e5 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 2 Jun 2020 18:17:51 -0400 Subject: [PATCH 35/76] as always, forgot the migration. --- migrations/versions/13424d5a6de8_.py | 42 ++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 migrations/versions/13424d5a6de8_.py diff --git a/migrations/versions/13424d5a6de8_.py b/migrations/versions/13424d5a6de8_.py new file mode 100644 index 00000000..632a1761 --- /dev/null +++ b/migrations/versions/13424d5a6de8_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: 13424d5a6de8 +Revises: 5064b72284b7 +Create Date: 2020-06-02 18:17:29.990159 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '13424d5a6de8' +down_revision = '5064b72284b7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('ldap_model', + sa.Column('uid', sa.String(), nullable=False), + sa.Column('display_name', sa.String(), nullable=True), + sa.Column('given_name', sa.String(), nullable=True), + sa.Column('email_address', sa.String(), nullable=True), + sa.Column('telephone_number', sa.String(), nullable=True), + sa.Column('title', sa.String(), nullable=True), + sa.Column('department', sa.String(), nullable=True), + sa.Column('affiliation', sa.String(), nullable=True), + sa.Column('sponsor_type', sa.String(), nullable=True), + sa.Column('date_cached', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('uid') + ) + op.add_column('approval', sa.Column('date_approved', sa.DateTime(timezone=True), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('approval', 'date_approved') + op.drop_table('ldap_model') + # ### end Alembic commands ### From 2424b9d78cf740d15e081e9c3448a6c8ddbffe2b Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 2 Jun 2020 19:36:06 -0400 Subject: [PATCH 36/76] Don't overwrite the approval, just allow minor changes. --- crc/api/approval.py | 8 +++++--- crc/models/approval.py | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/crc/api/approval.py b/crc/api/approval.py index db895176..cb3246e4 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -1,3 +1,4 @@ +import json from datetime import datetime from flask import g @@ -33,13 +34,14 @@ def update_approval(approval_id, body): if approval_model is None: raise ApiError('unknown_approval', 'The approval "' + str(approval_id) + '" is not recognized.') - approval: Approval = ApprovalSchema().load(body) if approval_model.approver_uid != g.user.uid: raise ApiError("not_your_approval", "You may not modify this approval. It belongs to another user.") - approval.update_model(approval_model) + approval_model.status = body['status'] + approval_model.message = body['message'] approval_model.date_approved = datetime.now() + session.add(approval_model) session.commit() - result = ApprovalSchema().dump(approval) + result = ApprovalSchema().dump(approval_model) return result diff --git a/crc/models/approval.py b/crc/models/approval.py index 7f819bcb..d2597e5e 100644 --- a/crc/models/approval.py +++ b/crc/models/approval.py @@ -64,6 +64,7 @@ class Approval(object): 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 = '' instance.related_approvals = [] @@ -110,7 +111,7 @@ class ApprovalSchema(ma.Schema): approver = fields.Nested(LdapSchema, dump_only=True) primary_investigator = fields.Nested(LdapSchema, dump_only=True) - related_approvals = fields.List(fields.Nested('ApprovalSchema', dump_only=True)) + related_approvals = fields.List(fields.Nested('ApprovalSchema', allow_none=True, dump_only=True)) class Meta: model = Approval From e7c130054d5379e64478ea7a51f6937bd2fd0cfc Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 2 Jun 2020 20:07:56 -0400 Subject: [PATCH 37/76] Fixing a test. --- tests/test_approvals_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_approvals_api.py b/tests/test_approvals_api.py index f323321c..76e77cbc 100644 --- a/tests/test_approvals_api.py +++ b/tests/test_approvals_api.py @@ -65,8 +65,8 @@ class TestApprovals(BaseTest): self.assertEqual(len(response), 1) # Confirm approver UID matches returned payload - approval = ApprovalSchema().load(response[0]) - self.assertEqual(approval.approver['uid'], approver_uid) + approval = response[0] + self.assertEqual(approval['approver']['uid'], approver_uid) def test_list_approvals_per_admin(self): """All approvals will be returned""" From 4bf13b0718de80cd23c31b761da3450e3334a51f Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Tue, 2 Jun 2020 22:01:49 -0400 Subject: [PATCH 38/76] Doing everything I can imagine to get a CSV dump out tomorrow. --- crc/api.yml | 13 ++++++++ crc/api/approval.py | 63 ++++++++++++++++++++++++++++++++++++- tests/test_approvals_api.py | 10 +++++- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/crc/api.yml b/crc/api.yml index f89f7a92..1688874f 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -854,6 +854,19 @@ paths: application/json: schema: $ref: "#/components/schemas/Approval" + /approval/csv: + get: + operationId: crc.api.approval.get_csv + summary: Provides a list of all users for all approved studies + tags: + - Approvals + responses: + '200': + description: An array of approvals + content: + application/json: + schema: + type: object components: securitySchemes: jwt: diff --git a/crc/api/approval.py b/crc/api/approval.py index cb3246e4..f719ba6d 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -1,13 +1,23 @@ import json +import pickle +from base64 import b64decode from datetime import datetime +from SpiffWorkflow import Workflow +from SpiffWorkflow.serializer.dict import DictionarySerializer +from SpiffWorkflow.serializer.json import JSONSerializer from flask import g from crc import app, db, session from crc.api.common import ApiError, ApiErrorSchema -from crc.models.approval import Approval, ApprovalModel, ApprovalSchema +from crc.models.approval import Approval, ApprovalModel, ApprovalSchema, ApprovalStatus +from crc.models.ldap import LdapSchema +from crc.models.study import Study +from crc.models.workflow import WorkflowModel from crc.services.approval_service import ApprovalService +from crc.services.ldap_service import LdapService +from crc.services.workflow_processor import WorkflowProcessor def get_approvals(everything=False): @@ -26,6 +36,57 @@ def get_approvals_for_study(study_id=None): return results +# ----- Being decent into madness ---- # +def get_csv(): + """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() + output = [] + errors = [] + ldapService = LdapService() + 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() + personnel = extract_personnel(workflow.bpmn_workflow_json) + 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)) + + for person in details: + output.append({ + "pi_uid": pi_details.uid, + "pi": pi_details.display_name, + "name": person.display_name, + "email": person.email_address + }) + 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_personnel(data): + data = json.loads(data) + last_task = find_task(data['last_task']['__uuid__'], data['task_tree']) + last_task_personnel = pickle.loads(b64decode(last_task['data']['personnel']['__bytes__'])) + return last_task_personnel + +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 ---- # + def update_approval(approval_id, body): if approval_id is None: raise ApiError('unknown_approval', 'Please provide a valid Approval ID.') diff --git a/tests/test_approvals_api.py b/tests/test_approvals_api.py index 76e77cbc..6d95be39 100644 --- a/tests/test_approvals_api.py +++ b/tests/test_approvals_api.py @@ -1,7 +1,7 @@ import json from tests.base_test import BaseTest -from crc import session +from crc import session, db from crc.models.approval import ApprovalModel, ApprovalSchema, ApprovalStatus from crc.models.protocol_builder import ProtocolBuilderStatus from crc.models.study import StudyModel @@ -144,3 +144,11 @@ class TestApprovals(BaseTest): # Updated record should now have the data sent to the endpoint self.assertEqual(approval.message, data['message']) self.assertEqual(approval.status, ApprovalStatus.DECLINED.value) + + def test_csv_export(self): + approvals = db.session.query(ApprovalModel).all() + for app in approvals: + app.status = ApprovalStatus.APPROVED.value + db.session.commit() + rv = self.app.get(f'/v1.0/approval/csv', headers=self.logged_in_headers()) + self.assert_success(rv) \ No newline at end of file From 299ad4fc8bdd06114fbff59890da0744e5bd899d Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Wed, 3 Jun 2020 07:58:48 -0400 Subject: [PATCH 39/76] Adding more details to the csv output, and assuring we don't miss people with outstanding approvals that were cancelled. --- crc/api/approval.py | 22 ++++++++++++++-------- crc/services/approval_service.py | 13 +++++++++---- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/crc/api/approval.py b/crc/api/approval.py index f719ba6d..6ab887f4 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -40,7 +40,7 @@ def get_approvals_for_study(study_id=None): def get_csv(): """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() + approvals = ApprovalService.get_all_approvals(ignore_cancelled=True) output = [] errors = [] ldapService = LdapService() @@ -52,7 +52,11 @@ def get_csv(): if related_approval.status != ApprovalStatus.APPROVED.value: continue workflow = db.session.query(WorkflowModel).filter(WorkflowModel.id == approval.workflow_id).first() - personnel = extract_personnel(workflow.bpmn_workflow_json) + 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') + review_complete = 'AllRequiredTraining' in training_val pi_uid = workflow.study.primary_investigator_id pi_details = ldapService.user_info(pi_uid) details = [] @@ -63,20 +67,22 @@ def get_csv(): for person in details: output.append({ + "study_id": approval.study_id, "pi_uid": pi_details.uid, "pi": pi_details.display_name, "name": person.display_name, - "email": person.email_address + "email": person.email_address, + "review_complete": review_complete, }) 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_personnel(data): - data = json.loads(data) - last_task = find_task(data['last_task']['__uuid__'], data['task_tree']) - last_task_personnel = pickle.loads(b64decode(last_task['data']['personnel']['__bytes__'])) - return last_task_personnel +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: diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index 63cdaff4..bba53a61 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -15,13 +15,18 @@ class ApprovalService(object): """Provides common tools for working with an Approval""" @staticmethod - def __one_approval_from_study(study, approver_uid = None): + def __one_approval_from_study(study, approver_uid = None, ignore_cancelled=False): """Returns one approval, with all additional approvals as 'related_approvals', the main approval can be pinned to an approver with an optional argument. Will return null if no approvals exist on the study.""" main_approval = None related_approvals = [] - approvals = db.session.query(ApprovalModel).filter(ApprovalModel.study_id == study.id).all() + query = db.session.query(ApprovalModel).\ + filter(ApprovalModel.study_id == study.id) + if ignore_cancelled: + query=query.filter(ApprovalModel.status != ApprovalStatus.CANCELED.value) + + approvals = query.all() for approval_model in approvals: if approval_model.approver_uid == approver_uid: main_approval = Approval.from_model(approval_model) @@ -48,13 +53,13 @@ class ApprovalService(object): return approvals @staticmethod - def get_all_approvals(): + def get_all_approvals(ignore_cancelled=False): """Returns a list of all approval objects (not db models), one record per study, with any associated approvals grouped under the first approval.""" studies = db.session.query(StudyModel).all() approvals = [] for study in studies: - approval = ApprovalService.__one_approval_from_study(study) + approval = ApprovalService.__one_approval_from_study(study, ignore_cancelled=ignore_cancelled) if approval: approvals.append(approval) return approvals From e102214809add8f92a34293920379f66dea0c078 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Wed, 3 Jun 2020 15:03:22 -0400 Subject: [PATCH 40/76] minor cleanup of error codes. --- crc/api/file.py | 2 +- crc/api/study.py | 2 +- crc/api/tools.py | 8 ++++---- crc/services/file_service.py | 2 +- crc/services/lookup_service.py | 2 +- crc/services/protocol_builder.py | 2 +- crc/services/study_service.py | 4 ++-- crc/services/workflow_service.py | 6 +++--- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/crc/api/file.py b/crc/api/file.py index a537cfe5..5cf54221 100644 --- a/crc/api/file.py +++ b/crc/api/file.py @@ -122,7 +122,7 @@ def get_file_info(file_id): def update_file_info(file_id, body): if file_id is None: - raise ApiError('unknown_file', 'Please provide a valid File ID.') + raise ApiError('no_such_file', 'Please provide a valid File ID.') file_model = session.query(FileModel).filter_by(id=file_id).first() diff --git a/crc/api/study.py b/crc/api/study.py index e9a251f8..8fdd1b4a 100644 --- a/crc/api/study.py +++ b/crc/api/study.py @@ -50,7 +50,7 @@ def update_study(study_id, body): def get_study(study_id): study = StudyService.get_study(study_id) if (study is None): - raise ApiError("Study not found", status_code=404) + raise ApiError("unknown_study", 'The study "' + study_id + '" is not recognized.', status_code=404) return StudySchema().dump(study) diff --git a/crc/api/tools.py b/crc/api/tools.py index 6fb31b71..4699be5f 100644 --- a/crc/api/tools.py +++ b/crc/api/tools.py @@ -20,9 +20,9 @@ def render_markdown(data, template): data = json.loads(data) return template.render(**data) except UndefinedError as ue: - raise ApiError(code="undefined field", message=ue.message) + raise ApiError(code="undefined_field", message=ue.message) except Exception as e: - raise ApiError(code="invalid", message=str(e)) + raise ApiError(code="invalid_render", message=str(e)) def render_docx(): @@ -42,9 +42,9 @@ def render_docx(): cache_timeout=-1 # Don't cache these files on the browser. ) except ValueError as e: - raise ApiError(code="invalid", message=str(e)) + raise ApiError(code="undefined_field", message=str(e)) except Exception as e: - raise ApiError(code="invalid", message=str(e)) + raise ApiError(code="invalid_render", message=str(e)) def list_scripts(): diff --git a/crc/services/file_service.py b/crc/services/file_service.py index 9142a7c3..ce4b7261 100644 --- a/crc/services/file_service.py +++ b/crc/services/file_service.py @@ -274,7 +274,7 @@ class FileService(object): workflow_spec_model = FileService.find_spec_model_in_db(workflow) if workflow_spec_model is None: - raise ApiError(code="workflow_model_error", + raise ApiError(code="unknown_workflow", message="Something is wrong. I can't find the workflow you are using.") file_data_model = session.query(FileDataModel) \ diff --git a/crc/services/lookup_service.py b/crc/services/lookup_service.py index 076fbe9a..71424b6b 100644 --- a/crc/services/lookup_service.py +++ b/crc/services/lookup_service.py @@ -103,7 +103,7 @@ class LookupService(object): workflow_id=workflow_model.id, name=file_name) if len(latest_files) < 1: - raise ApiError("missing_file", "Unable to locate the lookup data file '%s'" % file_name) + raise ApiError("invalid_enum", "Unable to locate the lookup data file '%s'" % file_name) else: data_model = latest_files[0] diff --git a/crc/services/protocol_builder.py b/crc/services/protocol_builder.py index 5fc5535f..8d1d7886 100644 --- a/crc/services/protocol_builder.py +++ b/crc/services/protocol_builder.py @@ -25,7 +25,7 @@ class ProtocolBuilderService(object): def get_studies(user_id) -> {}: ProtocolBuilderService.__enabled_or_raise() if not isinstance(user_id, str): - raise ApiError("invalid_user_id", "This user id is invalid: " + str(user_id)) + raise ApiError("protocol_builder_error", "This user id is invalid: " + str(user_id)) response = requests.get(ProtocolBuilderService.STUDY_URL % user_id) if response.ok and response.text: pb_studies = ProtocolBuilderStudySchema(many=True).loads(response.text) diff --git a/crc/services/study_service.py b/crc/services/study_service.py index 8d9df0db..0bc80bcf 100644 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -319,9 +319,9 @@ class StudyService(object): try: StudyService._create_workflow_model(study_model, workflow_spec) except WorkflowTaskExecException as wtee: - errors.append(ApiError.from_task("workflow_execution_exception", str(wtee), wtee.task)) + errors.append(ApiError.from_task("workflow_startup_exception", str(wtee), wtee.task)) except WorkflowException as we: - errors.append(ApiError.from_task_spec("workflow_execution_exception", str(we), we.sender)) + errors.append(ApiError.from_task_spec("workflow_startup_exception", str(we), we.sender)) return errors @staticmethod diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index c3834f0a..5efa8cab 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -82,7 +82,7 @@ class WorkflowService(object): processor = WorkflowProcessor(workflow_model, validate_only=True) except WorkflowException as we: WorkflowService.delete_test_data() - raise ApiError.from_workflow_exception("workflow_execution_exception", str(we), we) + raise ApiError.from_workflow_exception("workflow_validation_exception", str(we), we) while not processor.bpmn_workflow.is_completed(): try: @@ -96,7 +96,7 @@ class WorkflowService(object): task.complete() except WorkflowException as we: WorkflowService.delete_test_data() - raise ApiError.from_workflow_exception("workflow_execution_exception", str(we), we) + raise ApiError.from_workflow_exception("workflow_validation_exception", str(we), we) WorkflowService.delete_test_data() return processor.bpmn_workflow.last_task.data @@ -162,7 +162,7 @@ class WorkflowService(object): options.append({"id": d.value, "label": d.label}) return random.choice(options) else: - raise ApiError.from_task("invalid_autocomplete", "The settings for this auto complete field " + raise ApiError.from_task("unknown_lookup_option", "The settings for this auto complete field " "are incorrect: %s " % field.id, task) elif field.type == "long": return random.randint(1, 1000) From c179c7781bbc5058e014f2851c3ab3237827e0a7 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Wed, 3 Jun 2020 16:50:47 -0400 Subject: [PATCH 41/76] Do not process or return cancelled approvals via the API. --- crc/api/approval.py | 15 ++++----------- crc/services/approval_service.py | 4 ++-- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/crc/api/approval.py b/crc/api/approval.py index 6ab887f4..7c931257 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -3,28 +3,21 @@ import pickle from base64 import b64decode from datetime import datetime -from SpiffWorkflow import Workflow -from SpiffWorkflow.serializer.dict import DictionarySerializer -from SpiffWorkflow.serializer.json import JSONSerializer from flask import g -from crc import app, db, session - -from crc.api.common import ApiError, ApiErrorSchema +from crc import db, session +from crc.api.common import ApiError from crc.models.approval import Approval, ApprovalModel, ApprovalSchema, ApprovalStatus -from crc.models.ldap import LdapSchema -from crc.models.study import Study from crc.models.workflow import WorkflowModel from crc.services.approval_service import ApprovalService from crc.services.ldap_service import LdapService -from crc.services.workflow_processor import WorkflowProcessor def get_approvals(everything=False): if everything: - approvals = ApprovalService.get_all_approvals() + approvals = ApprovalService.get_all_approvals(ignore_cancelled=True) else: - approvals = ApprovalService.get_approvals_per_user(g.user.uid) + approvals = ApprovalService.get_approvals_per_user(g.user.uid, ignore_cancelled=True) results = ApprovalSchema(many=True).dump(approvals) return results diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index bba53a61..8b13615d 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -40,14 +40,14 @@ class ApprovalService(object): return main_approval @staticmethod - def get_approvals_per_user(approver_uid): + def get_approvals_per_user(approver_uid, ignore_cancelled=False): """Returns a list of approval objects (not db models) for the given approver. """ studies = db.session.query(StudyModel).join(ApprovalModel).\ filter(ApprovalModel.approver_uid == approver_uid).all() approvals = [] for study in studies: - approval = ApprovalService.__one_approval_from_study(study, approver_uid) + approval = ApprovalService.__one_approval_from_study(study, approver_uid, ignore_cancelled) if approval: approvals.append(approval) return approvals From 217ecfc9114fae1f9ce5da1376bcf088ee965883 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Wed, 3 Jun 2020 17:34:27 -0400 Subject: [PATCH 42/76] When you can't delete a file, mark it as archived. Don't include archived files in new approval requests. --- crc/api.yml | 2 +- crc/api/approval.py | 6 ++-- crc/models/file.py | 5 ++- crc/services/approval_service.py | 21 ++++++------ crc/services/file_service.py | 20 ++++++++---- tests/test_files_api.py | 37 ++++++++++++++++++++++ tests/test_workflow_spec_validation_api.py | 4 +-- 7 files changed, 73 insertions(+), 22 deletions(-) diff --git a/crc/api.yml b/crc/api.yml index 1688874f..a3fd835b 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -468,7 +468,7 @@ paths: $ref: "#/components/schemas/File" delete: operationId: crc.api.file.delete_file - summary: Removes an existing file + summary: Removes an existing file. In the event the file can not be deleted, it is archived. tags: - Files responses: diff --git a/crc/api/approval.py b/crc/api/approval.py index 7c931257..1408fec5 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -15,9 +15,9 @@ from crc.services.ldap_service import LdapService def get_approvals(everything=False): if everything: - approvals = ApprovalService.get_all_approvals(ignore_cancelled=True) + approvals = ApprovalService.get_all_approvals(include_cancelled=True) else: - approvals = ApprovalService.get_approvals_per_user(g.user.uid, ignore_cancelled=True) + approvals = ApprovalService.get_approvals_per_user(g.user.uid, include_cancelled=False) results = ApprovalSchema(many=True).dump(approvals) return results @@ -33,7 +33,7 @@ def get_approvals_for_study(study_id=None): def get_csv(): """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(ignore_cancelled=True) + approvals = ApprovalService.get_all_approvals(include_cancelled=False) output = [] errors = [] ldapService = LdapService() diff --git a/crc/models/file.py b/crc/models/file.py index 9cbfb7fc..a0c94985 100644 --- a/crc/models/file.py +++ b/crc/models/file.py @@ -82,7 +82,10 @@ class FileModel(db.Model): workflow_spec_id = db.Column(db.String, db.ForeignKey('workflow_spec.id'), nullable=True) workflow_id = db.Column(db.Integer, db.ForeignKey('workflow.id'), nullable=True) irb_doc_code = db.Column(db.String, nullable=True) # Code reference to the irb_documents.xlsx reference file. - + # A request was made to delete the file, but we can't because there are + # active approvals or running workflows that depend on it. So we archive + # it instead, hide it in the interface. + archived = db.Column(db.Boolean, default=False) class File(object): @classmethod diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index 8b13615d..a71f4ba1 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -15,7 +15,7 @@ class ApprovalService(object): """Provides common tools for working with an Approval""" @staticmethod - def __one_approval_from_study(study, approver_uid = None, ignore_cancelled=False): + def __one_approval_from_study(study, approver_uid = None, include_cancelled=True): """Returns one approval, with all additional approvals as 'related_approvals', the main approval can be pinned to an approver with an optional argument. Will return null if no approvals exist on the study.""" @@ -23,7 +23,7 @@ class ApprovalService(object): related_approvals = [] query = db.session.query(ApprovalModel).\ filter(ApprovalModel.study_id == study.id) - if ignore_cancelled: + if not include_cancelled: query=query.filter(ApprovalModel.status != ApprovalStatus.CANCELED.value) approvals = query.all() @@ -40,35 +40,38 @@ class ApprovalService(object): return main_approval @staticmethod - def get_approvals_per_user(approver_uid, ignore_cancelled=False): + def get_approvals_per_user(approver_uid, include_cancelled=False): """Returns a list of approval objects (not db models) for the given approver. """ studies = db.session.query(StudyModel).join(ApprovalModel).\ filter(ApprovalModel.approver_uid == approver_uid).all() approvals = [] for study in studies: - approval = ApprovalService.__one_approval_from_study(study, approver_uid, ignore_cancelled) + approval = ApprovalService.__one_approval_from_study(study, approver_uid, include_cancelled) if approval: approvals.append(approval) return approvals @staticmethod - def get_all_approvals(ignore_cancelled=False): + def get_all_approvals(include_cancelled=True): """Returns a list of all approval objects (not db models), one record per study, with any associated approvals grouped under the first approval.""" studies = db.session.query(StudyModel).all() approvals = [] for study in studies: - approval = ApprovalService.__one_approval_from_study(study, ignore_cancelled=ignore_cancelled) + approval = ApprovalService.__one_approval_from_study(study, include_cancelled=include_cancelled) if approval: approvals.append(approval) return approvals @staticmethod - def get_approvals_for_study(study_id): + def get_approvals_for_study(study_id, include_cancelled=True): """Returns an array of Approval objects for the study, it does not compute the related approvals.""" - db_approvals = session.query(ApprovalModel).filter_by(study_id=study_id).all() + query = session.query(ApprovalModel).filter_by(study_id=study_id) + if not include_cancelled: + query = query.filter(ApprovalModel.status != ApprovalStatus.CANCELED.value) + db_approvals = query.all() return [Approval.from_model(approval_model) for approval_model in db_approvals] @@ -101,7 +104,7 @@ class ApprovalService(object): # Construct as hash of the latest files to see if things have changed since # the last approval. workflow = db.session.query(WorkflowModel).filter(WorkflowModel.id == workflow_id).first() - workflow_data_files = FileService.get_workflow_data_files(workflow_id) + workflow_data_files = FileService.get_workflow_data_files(workflow_id, include_archives=False) current_data_file_ids = list(data_file.id for data_file in workflow_data_files) if len(current_data_file_ids) == 0: diff --git a/crc/services/file_service.py b/crc/services/file_service.py index ce4b7261..cda16a88 100644 --- a/crc/services/file_service.py +++ b/crc/services/file_service.py @@ -195,7 +195,7 @@ class FileService(object): @staticmethod def get_files(workflow_spec_id=None, workflow_id=None, - name=None, is_reference=False, irb_doc_code=None): + name=None, is_reference=False, irb_doc_code=None, include_archives=True): query = session.query(FileModel).filter_by(is_reference=is_reference) if workflow_spec_id: query = query.filter_by(workflow_spec_id=workflow_spec_id) @@ -208,6 +208,10 @@ class FileService(object): if name: query = query.filter_by(name=name) + + if not include_archives: + query = query.filter(FileModel.archived == False) + query = query.order_by(FileModel.id) results = query.all() @@ -238,11 +242,11 @@ class FileService(object): return latest_data_files @staticmethod - def get_workflow_data_files(workflow_id=None): + def get_workflow_data_files(workflow_id=None, include_archives=True): """Returns all the FileDataModels related to a running workflow - So these are the latest data files that were uploaded or generated that go along with this workflow. Not related to the spec in any way""" - file_models = FileService.get_files(workflow_id=workflow_id) + file_models = FileService.get_files(workflow_id=workflow_id, include_archives=include_archives) latest_data_files = [] for file_model in file_models: latest_data_files.append(FileService.get_file_data(file_model.id)) @@ -316,6 +320,10 @@ class FileService(object): session.query(FileModel).filter_by(id=file_id).delete() session.commit() except IntegrityError as ie: - app.logger.error("Failed to delete file: %i, due to %s" % (file_id, str(ie))) - raise ApiError('file_integrity_error', "You are attempting to delete a file that is " - "required by other records in the system.") \ No newline at end of file + # We can't delete the file or file data, because it is referenced elsewhere, + # but we can at least mark it as deleted on the table. + session.rollback() + file_model = session.query(FileModel).filter_by(id=file_id).first() + file_model.archived = True + session.commit() + app.logger.error("Failed to delete file, so archiving it instead. %i, due to %s" % (file_id, str(ie))) diff --git a/tests/test_files_api.py b/tests/test_files_api.py index ecce309c..4c8c3eb6 100644 --- a/tests/test_files_api.py +++ b/tests/test_files_api.py @@ -9,6 +9,8 @@ from crc.models.workflow import WorkflowSpecModel from crc.services.file_service import FileService from crc.services.workflow_processor import WorkflowProcessor from example_data import ExampleDataLoader +from crc.services.approval_service import ApprovalService +from crc.models.approval import ApprovalModel, ApprovalStatus class TestFilesApi(BaseTest): @@ -218,6 +220,41 @@ class TestFilesApi(BaseTest): rv = self.app.get('/v1.0/file/%i' % file.id, headers=self.logged_in_headers()) self.assertEqual(404, rv.status_code) + def test_delete_file_after_approval(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") + FileService.add_workflow_file(workflow_id=workflow.id, + name="anotother_anything.png", content_type="text", + binary_data=b'1234', irb_doc_code="Study_App_Doc") + + ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r") + + file = session.query(FileModel).\ + filter(FileModel.workflow_id == workflow.id).\ + filter(FileModel.name == "anything.png").first() + self.assertFalse(file.archived) + rv = self.app.get('/v1.0/file/%i' % file.id, headers=self.logged_in_headers()) + self.assert_success(rv) + + rv = self.app.delete('/v1.0/file/%i' % file.id, headers=self.logged_in_headers()) + self.assert_success(rv) + + session.refresh(file) + self.assertTrue(file.archived) + + ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r") + + approvals = session.query(ApprovalModel)\ + .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)) + + def test_change_primary_bpmn(self): self.load_example_data() spec = session.query(WorkflowSpecModel).first() diff --git a/tests/test_workflow_spec_validation_api.py b/tests/test_workflow_spec_validation_api.py index e2f652d9..cb9b6b77 100644 --- a/tests/test_workflow_spec_validation_api.py +++ b/tests/test_workflow_spec_validation_api.py @@ -71,7 +71,7 @@ class TestWorkflowSpecValidation(BaseTest): self.load_example_data() errors = self.validate_workflow("invalid_expression") self.assertEqual(2, len(errors)) - self.assertEqual("workflow_execution_exception", errors[0]['code']) + self.assertEqual("workflow_validation_exception", errors[0]['code']) self.assertEqual("ExclusiveGateway_003amsm", errors[0]['task_id']) self.assertEqual("Has Bananas Gateway", errors[0]['task_name']) self.assertEqual("invalid_expression.bpmn", errors[0]['file_name']) @@ -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_execution_exception", errors[0]['code']) + self.assertEqual("workflow_validation_exception", 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 f581bd9f2bc8758a4df7c9455c1be8eae22c5a5f Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Thu, 4 Jun 2020 00:35:59 -0600 Subject: [PATCH 43/76] Mails for approval process --- Pipfile | 1 + Pipfile.lock | 39 +++++--- crc/__init__.py | 5 + crc/services/mails.py | 99 +++++++++++++++++++ .../mails/ramp_up_approval_request.html | 1 + .../mails/ramp_up_approval_request.txt | 1 + ...ramp_up_approval_request_first_review.html | 1 + .../ramp_up_approval_request_first_review.txt | 1 + .../templates/mails/ramp_up_approved.html | 1 + .../templates/mails/ramp_up_approved.txt | 1 + .../templates/mails/ramp_up_denied.html | 0 crc/static/templates/mails/ramp_up_denied.txt | 0 .../templates/mails/ramp_up_submission.html | 5 + .../templates/mails/ramp_up_submission.txt | 5 + tests/test_mails.py | 48 +++++++++ 15 files changed, 192 insertions(+), 16 deletions(-) create mode 100644 crc/services/mails.py create mode 100644 crc/static/templates/mails/ramp_up_approval_request.html create mode 100644 crc/static/templates/mails/ramp_up_approval_request.txt create mode 100644 crc/static/templates/mails/ramp_up_approval_request_first_review.html create mode 100644 crc/static/templates/mails/ramp_up_approval_request_first_review.txt create mode 100644 crc/static/templates/mails/ramp_up_approved.html create mode 100644 crc/static/templates/mails/ramp_up_approved.txt create mode 100644 crc/static/templates/mails/ramp_up_denied.html create mode 100644 crc/static/templates/mails/ramp_up_denied.txt create mode 100644 crc/static/templates/mails/ramp_up_submission.html create mode 100644 crc/static/templates/mails/ramp_up_submission.txt create mode 100644 tests/test_mails.py diff --git a/Pipfile b/Pipfile index 6a5e31dd..0079962c 100644 --- a/Pipfile +++ b/Pipfile @@ -39,6 +39,7 @@ ldap3 = "*" gunicorn = "*" werkzeug = "*" sentry-sdk = {extras = ["flask"],version = "==0.14.4"} +flask-mail = "*" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index aca63a57..43163832 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "54d9d51360f54762138a3acc7696badd1d711e7b1dde9e2d82aa706e40c17102" + "sha256": "6c89585086260ebcb41918b8ef3b1d9e189e1b492208d3ff000a138bc2f2fcee" }, "pipfile-spec": 6, "requires": { @@ -104,10 +104,10 @@ }, "celery": { "hashes": [ - "sha256:5147662e23dc6bc39c17a2cbc9a148debe08ecfb128b0eded14a0d9c81fc5742", - "sha256:df2937b7536a2a9b18024776a3a46fd281721813636c03a5177fa02fe66078f6" + "sha256:9ae2e73b93cc7d6b48b56aaf49a68c91752d0ffd7dfdcc47f842ca79a6f13eae", + "sha256:c2037b6a8463da43b19969a0fc13f9023ceca6352b4dd51be01c66fbbb13647e" ], - "version": "==4.4.3" + "version": "==4.4.4" }, "certifi": { "hashes": [ @@ -276,6 +276,13 @@ "index": "pypi", "version": "==3.0.8" }, + "flask-mail": { + "hashes": [ + "sha256:22e5eb9a940bf407bcf30410ecc3708f3c56cc44b29c34e1726fe85006935f41" + ], + "index": "pypi", + "version": "==0.9.1" + }, "flask-marshmallow": { "hashes": [ "sha256:6e6aec171b8e092e0eafaf035ff5b8637bf3a58ab46f568c4c1bab02f2a3c196", @@ -387,10 +394,10 @@ }, "kombu": { "hashes": [ - "sha256:ab0afaa5388dd2979cbc439d3623b86a4f7a58d41f621096bef7767c37bc2505", - "sha256:aece08f48706743aaa1b9d607fee300559481eafcc5ee56451aa0ef867a3be07" + "sha256:437b9cdea193cc2ed0b8044c85fd0f126bb3615ca2f4d4a35b39de7cacfa3c1a", + "sha256:dc282bb277197d723bccda1a9ba30a27a28c9672d0ab93e9e51bb05a37bd29c3" ], - "version": "==4.6.9" + "version": "==4.6.10" }, "ldap3": { "hashes": [ @@ -479,11 +486,11 @@ }, "marshmallow": { "hashes": [ - "sha256:c2673233aa21dde264b84349dc2fd1dce5f30ed724a0a00e75426734de5b84ab", - "sha256:f88fe96434b1f0f476d54224d59333eba8ca1a203a2695683c1855675c4049a7" + "sha256:35ee2fb188f0bd9fc1cf9ac35e45fd394bd1c153cee430745a465ea435514bd5", + "sha256:9aa20f9b71c992b4782dad07c51d92884fd0f7c5cb9d3c737bea17ec1bad765f" ], "index": "pypi", - "version": "==3.6.0" + "version": "==3.6.1" }, "marshmallow-enum": { "hashes": [ @@ -968,11 +975,11 @@ }, "pytest": { "hashes": [ - "sha256:95c710d0a72d91c13fae35dce195633c929c3792f54125919847fdcdf7caa0d3", - "sha256:eb2b5e935f6a019317e455b6da83dd8650ac9ffd2ee73a7b657a30873d67a698" + "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1", + "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8" ], "index": "pypi", - "version": "==5.4.2" + "version": "==5.4.3" }, "six": { "hashes": [ @@ -983,10 +990,10 @@ }, "wcwidth": { "hashes": [ - "sha256:3de2e41158cb650b91f9654cbf9a3e053cee0719c9df4ddc11e4b568669e9829", - "sha256:b651b6b081476420e4e9ae61239ac4c1b49d0c5ace42b2e81dc2ff49ed50c566" + "sha256:980fbf4f3c196c0f329cdcd1e84c554d6a211f18e252e525a0cf4223154a41d6", + "sha256:edbc2b718b4db6cdf393eefe3a420183947d6aa312505ce6754516f458ff8830" ], - "version": "==0.2.2" + "version": "==0.2.3" }, "zipp": { "hashes": [ diff --git a/crc/__init__.py b/crc/__init__.py index e705321b..480ae4b1 100644 --- a/crc/__init__.py +++ b/crc/__init__.py @@ -3,6 +3,7 @@ import os import sentry_sdk import connexion +from jinja2 import Environment, FileSystemLoader from flask_cors import CORS from flask_marshmallow import Marshmallow from flask_migrate import Migrate @@ -48,6 +49,10 @@ 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('DB_HOST = ', ) print('CORS_ALLOW_ORIGINS = ', app.config['CORS_ALLOW_ORIGINS']) diff --git a/crc/services/mails.py b/crc/services/mails.py new file mode 100644 index 00000000..13358768 --- /dev/null +++ b/crc/services/mails.py @@ -0,0 +1,99 @@ +import os + +from crc import app, env +from jinja2 import Environment, FileSystemLoader +from flask import render_template, render_template_string +from flask_mail import Mail, Message + + +# TODO: Extract common mailing code into its own function + +def send_ramp_up_submission_email(sender, recipients, approver_1, approver_2=None): + with app.app_context(): + try: + msg = Message('Research Ramp-up Plan Submitted', + sender=sender, + recipients=recipients) + + 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) + + mail = Mail(app) + mail.send(msg) + except Exception as e: + app.logger.error(str(e)) + +def send_ramp_up_approval_request_email(sender, recipients, primary_investigator): + with app.app_context(): + try: + msg = Message('Research Ramp-up Plan Approval Request', + sender=sender, + recipients=recipients) + + 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) + + mail = Mail(app) + mail.send(msg) + except Exception as e: + app.logger.error(str(e)) + +def send_ramp_up_approval_request_first_review_email(sender, recipients, primary_investigator, approver): + with app.app_context(): + try: + msg = Message('Research Ramp-up Plan Approval Request', + sender=sender, + recipients=recipients) + + template = env.get_template('ramp_up_approval_request_first_review.txt') + template_vars = {'primary_investigator': primary_investigator, 'approver': approver} + msg.body = template.render(template_vars) + template = env.get_template('ramp_up_approval_request_first_review.html') + msg.html = template.render(template_vars) + + mail = Mail(app) + mail.send(msg) + except Exception as e: + app.logger.error(str(e)) + +def send_ramp_up_approved_email(sender, recipients, approver_1, approver_2=None): + with app.app_context(): + try: + msg = Message('Research Ramp-up Plan Approved', + sender=sender, + recipients=recipients) + + 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) + + mail = Mail(app) + mail.send(msg) + except Exception as e: + app.logger.error(str(e)) + +def send_ramp_up_denied_email(sender, recipients, approver): + with app.app_context(): + try: + msg = Message('Research Ramp-up Plan Denied', + sender=sender, + recipients=recipients) + + 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) + + mail = Mail(app) + mail.send(msg) + except Exception as e: + app.logger.error(str(e)) diff --git a/crc/static/templates/mails/ramp_up_approval_request.html b/crc/static/templates/mails/ramp_up_approval_request.html new file mode 100644 index 00000000..e78bc6f9 --- /dev/null +++ b/crc/static/templates/mails/ramp_up_approval_request.html @@ -0,0 +1 @@ +

    A Research Ramp-up approval request from {{ primary_investigator }} is now available for your review in your [Research Ramp-up Toolkit](https://rrt.uvadcos.io/app/approvals).

    \ No newline at end of file diff --git a/crc/static/templates/mails/ramp_up_approval_request.txt b/crc/static/templates/mails/ramp_up_approval_request.txt new file mode 100644 index 00000000..1b7c5a09 --- /dev/null +++ b/crc/static/templates/mails/ramp_up_approval_request.txt @@ -0,0 +1 @@ +A Research Ramp-up approval request from {{ primary_investigator }} is now available for your review in your [Research Ramp-up Toolkit](https://rrt.uvadcos.io/app/approvals). \ No newline at end of file diff --git a/crc/static/templates/mails/ramp_up_approval_request_first_review.html b/crc/static/templates/mails/ramp_up_approval_request_first_review.html new file mode 100644 index 00000000..9d2ac37a --- /dev/null +++ b/crc/static/templates/mails/ramp_up_approval_request_first_review.html @@ -0,0 +1 @@ +

    A Research Ramp-up approval request from {{ primary_investigator }} has been approve by {{ approver }} and is now available for your review in your [Research Ramp-up Toolkit](https://rrt.uvadcos.io/app/approvals).

    \ No newline at end of file diff --git a/crc/static/templates/mails/ramp_up_approval_request_first_review.txt b/crc/static/templates/mails/ramp_up_approval_request_first_review.txt new file mode 100644 index 00000000..cbe19f87 --- /dev/null +++ b/crc/static/templates/mails/ramp_up_approval_request_first_review.txt @@ -0,0 +1 @@ +A Research Ramp-up approval request from {{ primary_investigator }} has been approve by {{ approver }} and is now available for your review in your [Research Ramp-up Toolkit](https://rrt.uvadcos.io/app/approvals). \ No newline at end of file diff --git a/crc/static/templates/mails/ramp_up_approved.html b/crc/static/templates/mails/ramp_up_approved.html new file mode 100644 index 00000000..57fc1dc4 --- /dev/null +++ b/crc/static/templates/mails/ramp_up_approved.html @@ -0,0 +1 @@ +

    Your Research Ramp-up Plan has been approved by {{ approver_1 }} {% if approver_2 %}and {{ approver_2 }} {% endif %}

    \ No newline at end of file diff --git a/crc/static/templates/mails/ramp_up_approved.txt b/crc/static/templates/mails/ramp_up_approved.txt new file mode 100644 index 00000000..2eec582b --- /dev/null +++ b/crc/static/templates/mails/ramp_up_approved.txt @@ -0,0 +1 @@ +Your Research Ramp-up Plan has been approved by {{ approver_1 }} {% if approver_2 %}and {{ approver_2 }} {% endif %} \ No newline at end of file diff --git a/crc/static/templates/mails/ramp_up_denied.html b/crc/static/templates/mails/ramp_up_denied.html new file mode 100644 index 00000000..e69de29b diff --git a/crc/static/templates/mails/ramp_up_denied.txt b/crc/static/templates/mails/ramp_up_denied.txt new file mode 100644 index 00000000..e69de29b diff --git a/crc/static/templates/mails/ramp_up_submission.html b/crc/static/templates/mails/ramp_up_submission.html new file mode 100644 index 00000000..2c57c916 --- /dev/null +++ b/crc/static/templates/mails/ramp_up_submission.html @@ -0,0 +1,5 @@ +

    Your Research Ramp-up Plan (RRP) has been submitted for review by {{ approver_1 }} {% if approver_2 %}and {{ approver_2 }} {% endif %}. After completion of the review step you will receive email notification of its approval or if additional information and/or modifications are required, along with instructions on how to proceed. Return to the Research Ramp-up Plan application to proceed as instructed.

    + +

    In the meantime, please make sure all required training has been completed and needed supplies secured. You will be asked to confirm that both of these requirements have been met before reopening the research space approved in your RRP.

    + +

    Additionally, if there are any unknown Area Monitors for the spaces listed in your RRP, please contact your approvers to determine either who they are or how you can find out. Missing Area Monitors will need to be entered before proceeding as well.

    diff --git a/crc/static/templates/mails/ramp_up_submission.txt b/crc/static/templates/mails/ramp_up_submission.txt new file mode 100644 index 00000000..14c34500 --- /dev/null +++ b/crc/static/templates/mails/ramp_up_submission.txt @@ -0,0 +1,5 @@ +Your Research Ramp-up Plan (RRP) has been submitted for review by {{ approver_1 }} {% if approver_2 %}and {{ approver_2 }} {% endif %}. After completion of the review step you will receive email notification of its approval or if additional information and/or modifications are required, along with instructions on how to proceed. Return to the Research Ramp-up Plan application to proceed as instructed. + +In the meantime, please make sure all required training has been completed and needed supplies secured. You will be asked to confirm that both of these requirements have been met before reopening the research space approved in your RRP. + +Additionally, if there are any unknown Area Monitors for the spaces listed in your RRP, please contact your approvers to determine either who they are or how you can find out. Missing Area Monitors will need to be entered before proceeding as well. diff --git a/tests/test_mails.py b/tests/test_mails.py new file mode 100644 index 00000000..5e71ecd7 --- /dev/null +++ b/tests/test_mails.py @@ -0,0 +1,48 @@ + +from tests.base_test import BaseTest + +from crc.services.mails import ( + send_ramp_up_submission_email, + send_ramp_up_approval_request_email, + send_ramp_up_approval_request_first_review_email, + send_ramp_up_approved_email, + send_ramp_up_denied_email +) + + +class TestMails(BaseTest): + + def setUp(self): + self.sender = 'sender@sartography.com' + self.recipients = ['recipient@sartography.com'] + self.primary_investigator = 'Dr. Bartlett' + self.approver_1 = 'Max Approver' + 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) + + send_ramp_up_submission_email(self.sender, self.recipients, self.approver_1) + self.assertTrue(True) + + 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) + + 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.approver_1 + ) + self.assertTrue(True) + + def test_send_ramp_up_approved_email(self): + send_ramp_up_approved_email(self.sender, self.recipients, self.approver_1) + self.assertTrue(True) + + send_ramp_up_approved_email(self.sender, self.recipients, self.approver_1, self.approver_2) + self.assertTrue(True) + + def test_send_ramp_up_denied_email(self): + send_ramp_up_denied_email(self.sender, self.recipients, self.approver_1) + self.assertTrue(True) From 1324533865b4996d5e7812e785355eeb87162413 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Thu, 4 Jun 2020 09:49:42 -0400 Subject: [PATCH 44/76] Some additional cleanup - when a file is "archived" it is no longer returned for any endpoints about files, but it is directly accessible via id, in the event some request is made for it at a later date. --- crc/api.yml | 2 +- crc/api/approval.py | 2 +- crc/services/approval_service.py | 2 +- crc/services/file_service.py | 17 ++++++++------- tests/test_file_service.py | 33 ++++++++++++++++++++++++++++- tests/test_files_api.py | 36 +++++++++++++++++++++++++++++++- tests/test_tasks_api.py | 1 - 7 files changed, 80 insertions(+), 13 deletions(-) diff --git a/crc/api.yml b/crc/api.yml index a3fd835b..07e61251 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -468,7 +468,7 @@ paths: $ref: "#/components/schemas/File" delete: operationId: crc.api.file.delete_file - summary: Removes an existing file. In the event the file can not be deleted, it is archived. + summary: Removes an existing file. In the event the file can not be deleted, it is marked as "archived" in the database and is no longer returned unless specifically requested by id. tags: - Files responses: diff --git a/crc/api/approval.py b/crc/api/approval.py index 1408fec5..4f413aa4 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -31,7 +31,7 @@ def get_approvals_for_study(study_id=None): # ----- Being decent into madness ---- # def get_csv(): - """A huge bit of a one-off for RRT, but 3 weeks of midnight work can convince a + """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 = [] diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index a71f4ba1..b6605f7b 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -104,7 +104,7 @@ class ApprovalService(object): # Construct as hash of the latest files to see if things have changed since # the last approval. workflow = db.session.query(WorkflowModel).filter(WorkflowModel.id == workflow_id).first() - workflow_data_files = FileService.get_workflow_data_files(workflow_id, include_archives=False) + workflow_data_files = FileService.get_workflow_data_files(workflow_id) current_data_file_ids = list(data_file.id for data_file in workflow_data_files) if len(current_data_file_ids) == 0: diff --git a/crc/services/file_service.py b/crc/services/file_service.py index cda16a88..c0046a26 100644 --- a/crc/services/file_service.py +++ b/crc/services/file_service.py @@ -96,6 +96,7 @@ class FileService(object): def get_workflow_files(workflow_id): """Returns all the file models associated with a running workflow.""" return session.query(FileModel).filter(FileModel.workflow_id == workflow_id).\ + filter(FileModel.archived == False).\ order_by(FileModel.id).all() @staticmethod @@ -139,6 +140,7 @@ class FileService(object): else: file_model.type = FileType[file_extension] file_model.content_type = content_type + file_model.archived = False # Unarchive the file if it is archived. if latest_data_model is None: version = 1 @@ -188,14 +190,15 @@ class FileService(object): def get_files_for_study(study_id, irb_doc_code=None): query = session.query(FileModel).\ join(WorkflowModel).\ - filter(WorkflowModel.study_id == study_id) + filter(WorkflowModel.study_id == study_id).\ + filter(FileModel.archived == False) if irb_doc_code: query = query.filter(FileModel.irb_doc_code == irb_doc_code) return query.all() @staticmethod def get_files(workflow_spec_id=None, workflow_id=None, - name=None, is_reference=False, irb_doc_code=None, include_archives=True): + name=None, is_reference=False, irb_doc_code=None): query = session.query(FileModel).filter_by(is_reference=is_reference) if workflow_spec_id: query = query.filter_by(workflow_spec_id=workflow_spec_id) @@ -209,8 +212,7 @@ class FileService(object): if name: query = query.filter_by(name=name) - if not include_archives: - query = query.filter(FileModel.archived == False) + query = query.filter(FileModel.archived == False) query = query.order_by(FileModel.id) @@ -242,11 +244,11 @@ class FileService(object): return latest_data_files @staticmethod - def get_workflow_data_files(workflow_id=None, include_archives=True): + def get_workflow_data_files(workflow_id=None): """Returns all the FileDataModels related to a running workflow - So these are the latest data files that were uploaded or generated that go along with this workflow. Not related to the spec in any way""" - file_models = FileService.get_files(workflow_id=workflow_id, include_archives=include_archives) + file_models = FileService.get_files(workflow_id=workflow_id) latest_data_files = [] for file_model in file_models: latest_data_files.append(FileService.get_file_data(file_model.id)) @@ -274,7 +276,8 @@ class FileService(object): @staticmethod def get_workflow_file_data(workflow, file_name): - """Given a SPIFF Workflow Model, tracks down a file with the given name in the database and returns its data""" + """This method should be deleted, find where it is used, and remove this method. + Given a SPIFF Workflow Model, tracks down a file with the given name in the database and returns its data""" workflow_spec_model = FileService.find_spec_model_in_db(workflow) if workflow_spec_model is None: diff --git a/tests/test_file_service.py b/tests/test_file_service.py index 705fef95..02a70ce8 100644 --- a/tests/test_file_service.py +++ b/tests/test_file_service.py @@ -1,8 +1,9 @@ from tests.base_test import BaseTest + +from crc import db from crc.services.file_service import FileService from crc.services.workflow_processor import WorkflowProcessor - class TestFileService(BaseTest): """Largely tested via the test_file_api, and time is tight, but adding new tests here.""" @@ -46,12 +47,42 @@ class TestFileService(BaseTest): name="anything.png", content_type="text", binary_data=b'5678') + def test_replace_archive_file_unarchives_the_file_and_updates(self): + self.load_example_data() + self.create_reference_document() + workflow = self.create_workflow('file_upload_form') + processor = WorkflowProcessor(workflow) + task = processor.next_task() + irb_code = "UVACompl_PRCAppr" # The first file referenced in pb required docs. + FileService.add_workflow_file(workflow_id=workflow.id, + irb_doc_code=irb_code, + name="anything.png", content_type="text", + binary_data=b'1234') + + # Archive the file + file_models = FileService.get_workflow_files(workflow_id=workflow.id) + self.assertEquals(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)) + + # Add the file again with different data + FileService.add_workflow_file(workflow_id=workflow.id, + irb_doc_code=irb_code, + name="anything.png", content_type="text", + binary_data=b'5678') + file_models = FileService.get_workflow_files(workflow_id=workflow.id) self.assertEquals(1, len(file_models)) file_data = FileService.get_workflow_data_files(workflow_id=workflow.id) self.assertEquals(1, len(file_data)) self.assertEquals(2, file_data[0].version) + self.assertEquals(b'5678', file_data[0].data) def test_add_file_from_form_allows_multiple_files_with_different_names(self): self.load_example_data() diff --git a/tests/test_files_api.py b/tests/test_files_api.py index 4c8c3eb6..2d14a8b5 100644 --- a/tests/test_files_api.py +++ b/tests/test_files_api.py @@ -3,7 +3,7 @@ import json from tests.base_test import BaseTest -from crc import session +from crc import session, db from crc.models.file import FileModel, FileType, FileSchema, FileModelSchema from crc.models.workflow import WorkflowSpecModel from crc.services.file_service import FileService @@ -48,6 +48,7 @@ class TestFilesApi(BaseTest): json_data = json.loads(rv.get_data(as_text=True)) self.assertEqual(2, len(json_data)) + def test_create_file(self): self.load_example_data() spec = session.query(WorkflowSpecModel).first() @@ -91,6 +92,39 @@ class TestFilesApi(BaseTest): self.assert_success(rv) + def test_archive_file_no_longer_shows_up(self): + self.load_example_data() + self.create_reference_document() + workflow = self.create_workflow('file_upload_form') + processor = WorkflowProcessor(workflow) + task = processor.next_task() + data = {'file': (io.BytesIO(b"abcdef"), 'random_fact.svg')} + correct_name = task.task_spec.form.fields[0].id + + data = {'file': (io.BytesIO(b"abcdef"), 'random_fact.svg')} + rv = self.app.post('/v1.0/file?study_id=%i&workflow_id=%s&task_id=%i&form_field_key=%s' % + (workflow.study_id, workflow.id, task.id, correct_name), data=data, follow_redirects=True, + content_type='multipart/form-data', headers=self.logged_in_headers()) + + 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)))) + + file_model = db.session.query(FileModel).filter(FileModel.workflow_id == workflow.id).all() + self.assertEquals(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)))) + + + + + + def test_set_reference_file(self): file_name = "irb_document_types.xls" data = {'file': (io.BytesIO(b"abcdef"), "does_not_matter.xls")} diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index 37849867..52c5f39d 100644 --- a/tests/test_tasks_api.py +++ b/tests/test_tasks_api.py @@ -208,7 +208,6 @@ class TestTasksApi(BaseTest): self.assert_success(rv) - def test_get_documentation_populated_in_end(self): self.load_example_data() workflow = self.create_workflow('random_fact') From bbcbfef1baf6800e3919d9de94e3a67019ad6fab Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Thu, 4 Jun 2020 10:09:36 -0400 Subject: [PATCH 45/76] Fixing the migrations so I don't break the universe. --- crc/models/file.py | 2 +- crc/services/file_service.py | 6 +++++- migrations/versions/17597692d0b0_.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 migrations/versions/17597692d0b0_.py diff --git a/crc/models/file.py b/crc/models/file.py index a0c94985..15a48709 100644 --- a/crc/models/file.py +++ b/crc/models/file.py @@ -85,7 +85,7 @@ class FileModel(db.Model): # A request was made to delete the file, but we can't because there are # active approvals or running workflows that depend on it. So we archive # it instead, hide it in the interface. - archived = db.Column(db.Boolean, default=False) + archived = db.Column(db.Boolean, default=False, nullable=False) class File(object): @classmethod diff --git a/crc/services/file_service.py b/crc/services/file_service.py index c0046a26..f7d40fd7 100644 --- a/crc/services/file_service.py +++ b/crc/services/file_service.py @@ -128,7 +128,11 @@ class FileService(object): md5_checksum = UUID(hashlib.md5(binary_data).hexdigest()) if (latest_data_model is not None) and (md5_checksum == latest_data_model.md5_hash): - # This file does not need to be updated, it's the same file. + # This file does not need to be updated, it's the same file. If it is arhived, + # then de-arvhive it. + file_model.archived = False + session.add(file_model) + session.commit() return file_model # Verify the extension diff --git a/migrations/versions/17597692d0b0_.py b/migrations/versions/17597692d0b0_.py new file mode 100644 index 00000000..0b15c956 --- /dev/null +++ b/migrations/versions/17597692d0b0_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 17597692d0b0 +Revises: 13424d5a6de8 +Create Date: 2020-06-03 17:33:56.454339 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '17597692d0b0' +down_revision = '13424d5a6de8' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('file', sa.Column('archived', sa.Boolean(), nullable=True, default=False)) + op.execute("UPDATE file SET archived = false") + op.alter_column('file', 'archived', nullable=False) + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('file', 'archived') + # ### end Alembic commands ### From 68aeaf1273635ad8dec22a84c68969cb0b4bcb1c Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Thu, 4 Jun 2020 10:33:17 -0400 Subject: [PATCH 46/76] BE VERY CAREFUL where you create a new LdapService() - construction is expensive. Adding a few more details to the "csv" endpoint for RRT. --- crc/api/approval.py | 13 +++++++++++-- crc/models/approval.py | 7 +++---- crc/services/study_service.py | 4 ++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/crc/api/approval.py b/crc/api/approval.py index 4f413aa4..ffdd4fe0 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -49,6 +49,7 @@ def get_csv(): 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) @@ -59,14 +60,22 @@ def get_csv(): details.append(ldapService.user_info(uid)) for person in details: - output.append({ + 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 + + 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 } diff --git a/crc/models/approval.py b/crc/models/approval.py index d2597e5e..b72ee19a 100644 --- a/crc/models/approval.py +++ b/crc/models/approval.py @@ -48,6 +48,7 @@ class ApprovalModel(db.Model): class Approval(object): + ldap_service = LdapService() def __init__(self, **kwargs): self.__dict__.update(kwargs) @@ -71,11 +72,9 @@ class Approval(object): if model.study: instance.title = model.study.title - - ldap_service = LdapService() try: - instance.approver = ldap_service.user_info(model.approver_uid) - instance.primary_investigator = ldap_service.user_info(model.study.primary_investigator_id) + instance.approver = Approval.ldap_service.user_info(model.approver_uid) + instance.primary_investigator = Approval.ldap_service.user_info(model.study.primary_investigator_id) except ApiError as ae: app.logger.error("Ldap lookup failed for approval record %i" % model.id) diff --git a/crc/services/study_service.py b/crc/services/study_service.py index 0bc80bcf..d71c5796 100644 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -26,6 +26,7 @@ from crc.models.approval import Approval class StudyService(object): """Provides common tools for working with a Study""" + ldap_service = LdapService() @staticmethod def get_studies_for_user(user): @@ -206,8 +207,7 @@ class StudyService(object): @staticmethod def get_ldap_dict_if_available(user_id): try: - ldap_service = LdapService() - return LdapSchema().dump(ldap_service.user_info(user_id)) + return LdapSchema().dump(StudyService.ldap_service.user_info(user_id)) except ApiError as ae: app.logger.info(str(ae)) return {"error": str(ae)} From 50d2acac9c66daa64c761e22de2da3c484a2c061 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Thu, 4 Jun 2020 11:57:00 -0400 Subject: [PATCH 47/76] Made a very stupid mistake with LDAP connections, pushing up quickly to production. --- crc/api/approval.py | 3 ++- crc/models/approval.py | 7 +++---- crc/services/approval_service.py | 9 ++++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/crc/api/approval.py b/crc/api/approval.py index ffdd4fe0..c8cd6194 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -24,7 +24,8 @@ def get_approvals(everything=False): def get_approvals_for_study(study_id=None): db_approvals = ApprovalService.get_approvals_for_study(study_id) - approvals = [Approval.from_model(approval_model) for approval_model in db_approvals] + ldap_service = LdapService() + approvals = [Approval.from_model(approval_model, ldap_service) for approval_model in db_approvals] results = ApprovalSchema(many=True).dump(approvals) return results diff --git a/crc/models/approval.py b/crc/models/approval.py index b72ee19a..b6dab996 100644 --- a/crc/models/approval.py +++ b/crc/models/approval.py @@ -48,13 +48,12 @@ class ApprovalModel(db.Model): class Approval(object): - ldap_service = LdapService() def __init__(self, **kwargs): self.__dict__.update(kwargs) @classmethod - def from_model(cls, model: ApprovalModel): + def from_model(cls, model: ApprovalModel, ldap_service: LdapSchema): # TODO: Reduce the code by iterating over model's dict keys instance = cls() instance.id = model.id @@ -73,8 +72,8 @@ class Approval(object): if model.study: instance.title = model.study.title try: - instance.approver = Approval.ldap_service.user_info(model.approver_uid) - instance.primary_investigator = Approval.ldap_service.user_info(model.study.primary_investigator_id) + instance.approver = ldap_service.user_info(model.approver_uid) + instance.primary_investigator = ldap_service.user_info(model.study.primary_investigator_id) except ApiError as ae: app.logger.error("Ldap lookup failed for approval record %i" % model.id) diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index b6605f7b..af2f86b0 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -9,6 +9,7 @@ from crc.models.approval import ApprovalModel, ApprovalStatus, ApprovalFile, App from crc.models.study import StudyModel from crc.models.workflow import WorkflowModel from crc.services.file_service import FileService +from crc.services.ldap_service import LdapService class ApprovalService(object): @@ -27,11 +28,12 @@ class ApprovalService(object): query=query.filter(ApprovalModel.status != ApprovalStatus.CANCELED.value) approvals = query.all() + ldap_service = LdapService() for approval_model in approvals: if approval_model.approver_uid == approver_uid: - main_approval = Approval.from_model(approval_model) + main_approval = Approval.from_model(approval_model, ldap_service) else: - related_approvals.append(Approval.from_model(approval_model)) + related_approvals.append(Approval.from_model(approval_model, ldap_service)) if not main_approval and len(related_approvals) > 0: main_approval = related_approvals[0] related_approvals = related_approvals[1:] @@ -68,11 +70,12 @@ class ApprovalService(object): def get_approvals_for_study(study_id, include_cancelled=True): """Returns an array of Approval objects for the study, it does not compute the related approvals.""" + ldap_service = LdapService() query = session.query(ApprovalModel).filter_by(study_id=study_id) if not include_cancelled: query = query.filter(ApprovalModel.status != ApprovalStatus.CANCELED.value) db_approvals = query.all() - return [Approval.from_model(approval_model) for approval_model in db_approvals] + return [Approval.from_model(approval_model, ldap_service) for approval_model in db_approvals] @staticmethod From 8c36d9f367bb6e90052f9dd68fab296db8d98720 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Thu, 4 Jun 2020 11:43:10 -0600 Subject: [PATCH 48/76] Email calls outline --- crc/services/approval_service.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index bba53a61..bb4fe3cc 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -80,6 +80,25 @@ class ApprovalService(object): 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, + status=ApprovalStatus.PENDING.value, version=db_approval.version).first() + if second_approval: + # send rrp approval request for second approver + pass + else: + # send rrp approved email + pass + elif status == ApprovalStatus.DECLINED.value: + 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() + if first_approval: + # Second approver denies + # send rrp denied by second approver email to first approver + # send rrp denied email + pass # TODO: Log update action by approver_uid - maybe ? return db_approval @@ -129,6 +148,17 @@ class ApprovalService(object): message="", date_created=datetime.now(), version=version) approval_files = ApprovalService._create_approval_files(workflow_data_files, model) + + # Check approvals count + approvals_count = ApprovalModel().query.filter_by(study_id=study_id, workflow_id=workflow_id, + status=ApprovalStatus.PENDING.value, + version=version).count() + # Send first email + if approvals_count == 0: + # send rrp submission + # send rrp approval request for first approver + pass + db.session.add(model) db.session.add_all(approval_files) db.session.commit() From fed6e86f9288c76df0c07214f8d6ea75ef2b6e65 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Thu, 4 Jun 2020 14:59:36 -0400 Subject: [PATCH 49/76] Trying to fix LDAP issues on production. Changing LDAP to static only methods, caching the connection and calling bind before all connection requests. Also assuring we don't load the documents.xls file over and over again. --- crc/api/approval.py | 8 ++--- crc/api/user.py | 7 ++--- crc/models/approval.py | 8 ++--- crc/services/approval_service.py | 8 ++--- crc/services/file_service.py | 8 ++--- crc/services/ldap_service.py | 53 ++++++++++++++++++-------------- crc/services/lookup_service.py | 2 +- crc/services/study_service.py | 3 +- tests/test_ldap_service.py | 6 ++-- 9 files changed, 50 insertions(+), 53 deletions(-) diff --git a/crc/api/approval.py b/crc/api/approval.py index c8cd6194..9c42c82e 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -24,8 +24,7 @@ def get_approvals(everything=False): def get_approvals_for_study(study_id=None): db_approvals = ApprovalService.get_approvals_for_study(study_id) - ldap_service = LdapService() - approvals = [Approval.from_model(approval_model, ldap_service) for approval_model in db_approvals] + approvals = [Approval.from_model(approval_model) for approval_model in db_approvals] results = ApprovalSchema(many=True).dump(approvals) return results @@ -37,7 +36,6 @@ def get_csv(): approvals = ApprovalService.get_all_approvals(include_cancelled=False) output = [] errors = [] - ldapService = LdapService() for approval in approvals: try: if approval.status != ApprovalStatus.APPROVED.value: @@ -53,12 +51,12 @@ def get_csv(): 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) + 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)) + details.append(LdapService.user_info(uid)) for person in details: record = { diff --git a/crc/api/user.py b/crc/api/user.py index 172b7496..36e9926e 100644 --- a/crc/api/user.py +++ b/crc/api/user.py @@ -59,10 +59,7 @@ def sso_login(): app.logger.info("SSO_LOGIN: Full URL: " + request.url) app.logger.info("SSO_LOGIN: User Id: " + uid) app.logger.info("SSO_LOGIN: Will try to redirect to : " + str(redirect)) - - ldap_service = LdapService() - info = ldap_service.user_info(uid) - + info = LdapService.user_info(uid) return _handle_login(info, redirect) @app.route('/sso') @@ -151,7 +148,7 @@ def backdoor( """ if not 'PRODUCTION' in app.config or not app.config['PRODUCTION']: - ldap_info = LdapService().user_info(uid) + ldap_info = LdapService.user_info(uid) return _handle_login(ldap_info, redirect) else: raise ApiError('404', 'unknown') diff --git a/crc/models/approval.py b/crc/models/approval.py index b6dab996..ee19f4b7 100644 --- a/crc/models/approval.py +++ b/crc/models/approval.py @@ -53,7 +53,7 @@ class Approval(object): self.__dict__.update(kwargs) @classmethod - def from_model(cls, model: ApprovalModel, ldap_service: LdapSchema): + def from_model(cls, model: ApprovalModel): # TODO: Reduce the code by iterating over model's dict keys instance = cls() instance.id = model.id @@ -72,12 +72,12 @@ class Approval(object): if model.study: instance.title = model.study.title try: - instance.approver = ldap_service.user_info(model.approver_uid) - instance.primary_investigator = ldap_service.user_info(model.study.primary_investigator_id) + 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) - doc_dictionary = FileService.get_reference_data(FileService.DOCUMENT_LIST, 'code', ['id']) + doc_dictionary = FileService.get_doc_dictionary() instance.associated_files = [] for approval_file in model.approval_files: try: diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index af2f86b0..98c99d0b 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -28,12 +28,11 @@ class ApprovalService(object): query=query.filter(ApprovalModel.status != ApprovalStatus.CANCELED.value) approvals = query.all() - ldap_service = LdapService() for approval_model in approvals: if approval_model.approver_uid == approver_uid: - main_approval = Approval.from_model(approval_model, ldap_service) + main_approval = Approval.from_model(approval_model) else: - related_approvals.append(Approval.from_model(approval_model, ldap_service)) + related_approvals.append(Approval.from_model(approval_model)) if not main_approval and len(related_approvals) > 0: main_approval = related_approvals[0] related_approvals = related_approvals[1:] @@ -70,12 +69,11 @@ class ApprovalService(object): def get_approvals_for_study(study_id, include_cancelled=True): """Returns an array of Approval objects for the study, it does not compute the related approvals.""" - ldap_service = LdapService() query = session.query(ApprovalModel).filter_by(study_id=study_id) if not include_cancelled: query = query.filter(ApprovalModel.status != ApprovalStatus.CANCELED.value) db_approvals = query.all() - return [Approval.from_model(approval_model, ldap_service) for approval_model in db_approvals] + return [Approval.from_model(approval_model) for approval_model in db_approvals] @staticmethod diff --git a/crc/services/file_service.py b/crc/services/file_service.py index f7d40fd7..ff234a79 100644 --- a/crc/services/file_service.py +++ b/crc/services/file_service.py @@ -45,10 +45,8 @@ class FileService(object): @staticmethod def is_allowed_document(code): - data_model = FileService.get_reference_file_data(FileService.DOCUMENT_LIST) - xls = ExcelFile(data_model.data) - df = xls.parse(xls.sheet_names[0]) - return code in df['code'].values + doc_dict = FileService.get_doc_dictionary() + return code in doc_dict @staticmethod def add_workflow_file(workflow_id, irb_doc_code, name, content_type, binary_data): @@ -333,4 +331,4 @@ class FileService(object): file_model = session.query(FileModel).filter_by(id=file_id).first() file_model.archived = True session.commit() - app.logger.error("Failed to delete file, so archiving it instead. %i, due to %s" % (file_id, str(ie))) + app.logger.info("Failed to delete file, so archiving it instead. %i, due to %s" % (file_id, str(ie))) diff --git a/crc/services/ldap_service.py b/crc/services/ldap_service.py index 7d95a71b..6767f271 100644 --- a/crc/services/ldap_service.py +++ b/crc/services/ldap_service.py @@ -19,37 +19,42 @@ class LdapService(object): cn_single_search = '(&(objectclass=person)(cn=%s*))' cn_double_search = '(&(objectclass=person)(&(cn=%s*)(cn=*%s*)))' - def __init__(self): - if app.config['TESTING']: - server = Server('my_fake_server') - self.conn = Connection(server, client_strategy=MOCK_SYNC) - file_path = os.path.abspath(os.path.join(app.root_path, '..', 'tests', 'data', 'ldap_response.json')) - self.conn.strategy.entries_from_json(file_path) - self.conn.bind() - else: - server = Server(app.config['LDAP_URL'], connect_timeout=app.config['LDAP_TIMEOUT_SEC']) - self.conn = Connection(server, - auto_bind=True, - receive_timeout=app.config['LDAP_TIMEOUT_SEC'], - ) + conn = None - def __del__(self): - if self.conn: - self.conn.unbind() + @staticmethod + def __get_conn(): + if not LdapService.conn: + if app.config['TESTING']: + server = Server('my_fake_server') + conn = Connection(server, client_strategy=MOCK_SYNC) + file_path = os.path.abspath(os.path.join(app.root_path, '..', 'tests', 'data', 'ldap_response.json')) + conn.strategy.entries_from_json(file_path) + else: + server = Server(app.config['LDAP_URL'], connect_timeout=app.config['LDAP_TIMEOUT_SEC']) + conn = Connection(server, + receive_timeout=app.config['LDAP_TIMEOUT_SEC'], + ) + LdapService.conn = conn + return LdapService.conn - def user_info(self, uva_uid): + + @staticmethod + def user_info(uva_uid): user_info = db.session.query(LdapModel).filter(LdapModel.uid == uva_uid).first() if not user_info: search_string = LdapService.uid_search_string % uva_uid - self.conn.search(LdapService.search_base, search_string, attributes=LdapService.attributes) - if len(self.conn.entries) < 1: + conn = LdapService.__get_conn() + conn.bind() + conn.search(LdapService.search_base, search_string, attributes=LdapService.attributes) + if len(conn.entries) < 1: raise ApiError("missing_ldap_record", "Unable to locate a user with id %s in LDAP" % uva_uid) - entry = self.conn.entries[0] + entry = conn.entries[0] user_info = LdapModel.from_entry(entry) db.session.add(user_info) return user_info - def search_users(self, query, limit): + @staticmethod + def search_users(query, limit): if len(query.strip()) < 3: return [] elif query.endswith(' '): @@ -66,12 +71,14 @@ class LdapService(object): results = [] print(search_string) try: - self.conn.search(LdapService.search_base, search_string, attributes=LdapService.attributes) + conn = LdapService.__get_conn() + conn.bind() + conn.search(LdapService.search_base, search_string, attributes=LdapService.attributes) # Entries are returned as a generator, accessing entries # can make subsequent calls to the ldap service, so limit # those here. count = 0 - for entry in self.conn.entries: + for entry in conn.entries: if count > limit: break results.append(LdapSchema().dump(LdapModel.from_entry(entry))) diff --git a/crc/services/lookup_service.py b/crc/services/lookup_service.py index 71424b6b..b3e0bddc 100644 --- a/crc/services/lookup_service.py +++ b/crc/services/lookup_service.py @@ -189,7 +189,7 @@ class LookupService(object): @staticmethod def _run_ldap_query(query, limit): - users = LdapService().search_users(query, limit) + users = LdapService.search_users(query, limit) """Converts the user models into something akin to the LookupModel in models/file.py, so this can be returned in the same way diff --git a/crc/services/study_service.py b/crc/services/study_service.py index d71c5796..4024b5f0 100644 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -26,7 +26,6 @@ from crc.models.approval import Approval class StudyService(object): """Provides common tools for working with a Study""" - ldap_service = LdapService() @staticmethod def get_studies_for_user(user): @@ -207,7 +206,7 @@ class StudyService(object): @staticmethod def get_ldap_dict_if_available(user_id): try: - return LdapSchema().dump(StudyService.ldap_service.user_info(user_id)) + return LdapSchema().dump(LdapService().user_info(user_id)) except ApiError as ae: app.logger.info(str(ae)) return {"error": str(ae)} diff --git a/tests/test_ldap_service.py b/tests/test_ldap_service.py index 9e2e8931..88c748ed 100644 --- a/tests/test_ldap_service.py +++ b/tests/test_ldap_service.py @@ -7,13 +7,13 @@ from crc.services.ldap_service import LdapService class TestLdapService(BaseTest): def setUp(self): - self.ldap_service = LdapService() + pass def tearDown(self): pass def test_get_single_user(self): - user_info = self.ldap_service.user_info("lb3dp") + user_info = LdapService.user_info("lb3dp") self.assertIsNotNone(user_info) self.assertEqual("lb3dp", user_info.uid) self.assertEqual("Laura Barnes", user_info.display_name) @@ -27,7 +27,7 @@ class TestLdapService(BaseTest): def test_find_missing_user(self): try: - user_info = self.ldap_service.user_info("nosuch") + user_info = LdapService.user_info("nosuch") self.assertFalse(True, "An API error should be raised.") except ApiError as ae: self.assertEquals("missing_ldap_record", ae.code) \ No newline at end of file From 9cfe00dfd0b6db7953a874c03580ddc5f8b32bb7 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Thu, 4 Jun 2020 15:38:45 -0400 Subject: [PATCH 50/76] Don't bind all the time. --- crc/services/ldap_service.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crc/services/ldap_service.py b/crc/services/ldap_service.py index 6767f271..3bf24105 100644 --- a/crc/services/ldap_service.py +++ b/crc/services/ldap_service.py @@ -29,9 +29,10 @@ class LdapService(object): conn = Connection(server, client_strategy=MOCK_SYNC) file_path = os.path.abspath(os.path.join(app.root_path, '..', 'tests', 'data', 'ldap_response.json')) conn.strategy.entries_from_json(file_path) + conn.bind() else: server = Server(app.config['LDAP_URL'], connect_timeout=app.config['LDAP_TIMEOUT_SEC']) - conn = Connection(server, + conn = Connection(server, auto_bind=True, receive_timeout=app.config['LDAP_TIMEOUT_SEC'], ) LdapService.conn = conn @@ -44,7 +45,6 @@ class LdapService(object): if not user_info: search_string = LdapService.uid_search_string % uva_uid conn = LdapService.__get_conn() - conn.bind() conn.search(LdapService.search_base, search_string, attributes=LdapService.attributes) if len(conn.entries) < 1: raise ApiError("missing_ldap_record", "Unable to locate a user with id %s in LDAP" % uva_uid) @@ -72,7 +72,6 @@ class LdapService(object): print(search_string) try: conn = LdapService.__get_conn() - conn.bind() conn.search(LdapService.search_base, search_string, attributes=LdapService.attributes) # Entries are returned as a generator, accessing entries # can make subsequent calls to the ldap service, so limit From b6abb0cbe267c08522abe9ff059eb59257b11316 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Thu, 4 Jun 2020 18:03:59 -0400 Subject: [PATCH 51/76] using a restartable strategy to get around login errors --- crc/services/ldap_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crc/services/ldap_service.py b/crc/services/ldap_service.py index 3bf24105..61097921 100644 --- a/crc/services/ldap_service.py +++ b/crc/services/ldap_service.py @@ -4,7 +4,7 @@ from attr import asdict from ldap3.core.exceptions import LDAPExceptionError from crc import app, db -from ldap3 import Connection, Server, MOCK_SYNC +from ldap3 import Connection, Server, MOCK_SYNC, RESTARTABLE from crc.api.common import ApiError from crc.models.ldap import LdapModel, LdapSchema @@ -34,7 +34,7 @@ class LdapService(object): server = Server(app.config['LDAP_URL'], connect_timeout=app.config['LDAP_TIMEOUT_SEC']) conn = Connection(server, auto_bind=True, receive_timeout=app.config['LDAP_TIMEOUT_SEC'], - ) + client_strategy=RESTARTABLE) LdapService.conn = conn return LdapService.conn From 4727d87adb80d8f73de04c779bcf2e353c15b226 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Thu, 4 Jun 2020 20:37:28 -0600 Subject: [PATCH 52/76] Hooking up emails into process - start --- config/default.py | 3 +- crc/__init__.py | 9 ++ crc/services/approval_service.py | 38 ++++- crc/services/ldap_service.py | 2 +- crc/services/mails.py | 141 +++++++++--------- ...ramp_up_approval_request_first_review.html | 2 +- .../ramp_up_approval_request_first_review.txt | 2 +- tests/base_test.py | 3 +- tests/test_approvals_service.py | 12 ++ tests/test_mails.py | 4 +- 10 files changed, 126 insertions(+), 90 deletions(-) diff --git a/config/default.py b/config/default.py index 289c506f..bac744fd 100644 --- a/config/default.py +++ b/config/default.py @@ -44,4 +44,5 @@ PB_STUDY_DETAILS_URL = environ.get('PB_STUDY_DETAILS_URL', default=PB_BASE_URL + LDAP_URL = environ.get('LDAP_URL', default="ldap.virginia.edu").strip('/') # No trailing slash or http:// LDAP_TIMEOUT_SEC = int(environ.get('LDAP_TIMEOUT_SEC', default=3)) - +# Fallback emails +FALLBACK_EMAILS = ['askresearch@virginia.edu', 'sartographysupport@googlegroups.com'] diff --git a/crc/__init__.py b/crc/__init__.py index 480ae4b1..66b91b63 100644 --- a/crc/__init__.py +++ b/crc/__init__.py @@ -6,6 +6,7 @@ import connexion from jinja2 import Environment, FileSystemLoader from flask_cors import CORS from flask_marshmallow import Marshmallow +from flask_mail import Mail from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy from sentry_sdk.integrations.flask import FlaskIntegration @@ -52,6 +53,14 @@ 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 +app.config['MAIL_SERVER']='smtp.mailtrap.io' +app.config['MAIL_PORT'] = 2525 +app.config['MAIL_USERNAME'] = '5f012d0108d374' +app.config['MAIL_PASSWORD'] = '08442c04e98d50' +app.config['MAIL_USE_TLS'] = True +app.config['MAIL_USE_SSL'] = False +mail = Mail(app) print('=== USING THESE CONFIG SETTINGS: ===') print('DB_HOST = ', ) diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index bb4fe3cc..2c131aaa 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -2,13 +2,21 @@ from datetime import datetime from sqlalchemy import desc -from crc import db, session +from crc import app, db, session from crc.api.common import ApiError from crc.models.approval import ApprovalModel, ApprovalStatus, ApprovalFile, Approval from crc.models.study import StudyModel from crc.models.workflow import WorkflowModel from crc.services.file_service import FileService +from crc.services.ldap_service import LdapService +from crc.services.mails import ( + send_ramp_up_submission_email, + # send_ramp_up_approval_request_email, + send_ramp_up_approval_request_first_review_email, + # send_ramp_up_approved_email, + # send_ramp_up_denied_email +) class ApprovalService(object): @@ -97,8 +105,8 @@ class ApprovalService(object): if first_approval: # Second approver denies # send rrp denied by second approver email to first approver + pass # send rrp denied email - pass # TODO: Log update action by approver_uid - maybe ? return db_approval @@ -151,18 +159,32 @@ class ApprovalService(object): # Check approvals count approvals_count = ApprovalModel().query.filter_by(study_id=study_id, workflow_id=workflow_id, - status=ApprovalStatus.PENDING.value, version=version).count() - # Send first email - if approvals_count == 0: - # send rrp submission - # send rrp approval request for first approver - pass db.session.add(model) db.session.add_all(approval_files) db.session.commit() + # Send first email + if approvals_count == 0: + ldap_service = LdapService() + pi_user_info = ldap_service.user_info(model.study.primary_investigator_id) + approver_info = ldap_service.user_info(approver_uid) + # send rrp submission + send_ramp_up_submission_email( + 'askresearch@virginia.edu', + [pi_user_info.email_address], + f'{approver_info.display_name} - ({approver_info.uid})' + ) + # 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'] + send_ramp_up_approval_request_first_review_email( + 'askresearch@virginia.edu', + approver_email, + f'{pi_user_info.display_name} - ({pi_user_info.uid})' + ) + @staticmethod def _create_approval_files(workflow_data_files, approval): """Currently based exclusively on the status of files associated with a workflow.""" diff --git a/crc/services/ldap_service.py b/crc/services/ldap_service.py index 7d95a71b..1677823c 100644 --- a/crc/services/ldap_service.py +++ b/crc/services/ldap_service.py @@ -64,7 +64,7 @@ class LdapService(object): # Search by user_id or last name search_string = LdapService.user_or_last_name_search % (query, query) results = [] - print(search_string) + app.logger.info(search_string) try: self.conn.search(LdapService.search_base, search_string, attributes=LdapService.attributes) # Entries are returned as a generator, accessing entries diff --git a/crc/services/mails.py b/crc/services/mails.py index 13358768..487ff2a9 100644 --- a/crc/services/mails.py +++ b/crc/services/mails.py @@ -1,99 +1,92 @@ import os -from crc import app, env -from jinja2 import Environment, FileSystemLoader from flask import render_template, render_template_string -from flask_mail import Mail, Message +from flask_mail import Message # TODO: Extract common mailing code into its own function def send_ramp_up_submission_email(sender, recipients, approver_1, approver_2=None): - with app.app_context(): - try: - msg = Message('Research Ramp-up Plan Submitted', - sender=sender, - recipients=recipients) + try: + msg = Message('Research Ramp-up Plan Submitted', + sender=sender, + recipients=recipients) - 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) + 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) - mail = Mail(app) - mail.send(msg) - except Exception as e: - app.logger.error(str(e)) + mail.send(msg) + except Exception as e: + return str(e) def send_ramp_up_approval_request_email(sender, recipients, primary_investigator): - with app.app_context(): - try: - msg = Message('Research Ramp-up Plan Approval Request', - sender=sender, - recipients=recipients) + try: + msg = Message('Research Ramp-up Plan Approval Request', + sender=sender, + recipients=recipients) - 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, 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) - mail = Mail(app) - mail.send(msg) - except Exception as e: - app.logger.error(str(e)) + mail.send(msg) + except Exception as e: + return str(e) -def send_ramp_up_approval_request_first_review_email(sender, recipients, primary_investigator, approver): - with app.app_context(): - try: - msg = Message('Research Ramp-up Plan Approval Request', - sender=sender, - recipients=recipients) +def send_ramp_up_approval_request_first_review_email(sender, recipients, primary_investigator): + try: + msg = Message('Research Ramp-up Plan Approval Request', + sender=sender, + recipients=recipients) - template = env.get_template('ramp_up_approval_request_first_review.txt') - template_vars = {'primary_investigator': primary_investigator, 'approver': approver} - 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, 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) - mail = Mail(app) - mail.send(msg) - except Exception as e: - app.logger.error(str(e)) + mail.send(msg) + except Exception as e: + return str(e) def send_ramp_up_approved_email(sender, recipients, approver_1, approver_2=None): - with app.app_context(): - try: - msg = Message('Research Ramp-up Plan Approved', - sender=sender, - recipients=recipients) + try: + msg = Message('Research Ramp-up Plan Approved', + sender=sender, + recipients=recipients) - 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) + 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) - mail = Mail(app) - mail.send(msg) - except Exception as e: - app.logger.error(str(e)) + mail.send(msg) + except Exception as e: + return str(e) def send_ramp_up_denied_email(sender, recipients, approver): - with app.app_context(): - try: - msg = Message('Research Ramp-up Plan Denied', - sender=sender, - recipients=recipients) + try: + msg = Message('Research Ramp-up Plan Denied', + sender=sender, + recipients=recipients) - 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) + 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) - mail = Mail(app) - mail.send(msg) - except Exception as e: - app.logger.error(str(e)) + mail.send(msg) + except Exception as e: + return str(e) diff --git a/crc/static/templates/mails/ramp_up_approval_request_first_review.html b/crc/static/templates/mails/ramp_up_approval_request_first_review.html index 9d2ac37a..6c015fc3 100644 --- a/crc/static/templates/mails/ramp_up_approval_request_first_review.html +++ b/crc/static/templates/mails/ramp_up_approval_request_first_review.html @@ -1 +1 @@ -

    A Research Ramp-up approval request from {{ primary_investigator }} has been approve by {{ approver }} and is now available for your review in your [Research Ramp-up Toolkit](https://rrt.uvadcos.io/app/approvals).

    \ No newline at end of file +

    A Research Ramp-up approval request from {{ primary_investigator }} and is now available for your review in your [Research Ramp-up Toolkit](https://rrt.uvadcos.io/app/approvals).

    \ No newline at end of file diff --git a/crc/static/templates/mails/ramp_up_approval_request_first_review.txt b/crc/static/templates/mails/ramp_up_approval_request_first_review.txt index cbe19f87..1b7c5a09 100644 --- a/crc/static/templates/mails/ramp_up_approval_request_first_review.txt +++ b/crc/static/templates/mails/ramp_up_approval_request_first_review.txt @@ -1 +1 @@ -A Research Ramp-up approval request from {{ primary_investigator }} has been approve by {{ approver }} and is now available for your review in your [Research Ramp-up Toolkit](https://rrt.uvadcos.io/app/approvals). \ No newline at end of file +A Research Ramp-up approval request from {{ primary_investigator }} is now available for your review in your [Research Ramp-up Toolkit](https://rrt.uvadcos.io/app/approvals). \ No newline at end of file diff --git a/tests/base_test.py b/tests/base_test.py index f0418343..93c4b712 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -119,7 +119,6 @@ class BaseTest(unittest.TestCase): """use_crc_data will cause this to load the mammoth collection of documents we built up developing crc, use_rrt_data will do the same for hte rrt project, otherwise it depends on a small setup for running tests.""" - from example_data import ExampleDataLoader ExampleDataLoader.clean_db() if use_crc_data: @@ -228,7 +227,7 @@ class BaseTest(unittest.TestCase): if study is None: user = self.create_user(uid=uid) study = StudyModel(title=title, protocol_builder_status=ProtocolBuilderStatus.ACTIVE, - user_uid=user.uid) + user_uid=user.uid, primary_investigator_id='lb3dp') db.session.add(study) db.session.commit() return study diff --git a/tests/test_approvals_service.py b/tests/test_approvals_service.py index 1ec6db75..84e590ff 100644 --- a/tests/test_approvals_service.py +++ b/tests/test_approvals_service.py @@ -15,6 +15,7 @@ class TestApprovalsService(BaseTest): 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") self.assertEquals(1, db.session.query(ApprovalModel).count()) model = db.session.query(ApprovalModel).first() @@ -56,4 +57,15 @@ class TestApprovalsService(BaseTest): self.assertEquals(1, models[0].version) self.assertEquals(2, models[1].version) + def test_new_approval_sends_proper_emails(self): + self.assertEqual(1, 1) + def test_new_approval_failed_ldap_lookup(self): + # failed lookup should send email to sartographysupport@googlegroups.com + Cheryl + self.assertEqual(1, 1) + + def test_approve_approval_sends_proper_emails(self): + self.assertEqual(1, 1) + + def test_deny_approval_sends_proper_emails(self): + self.assertEqual(1, 1) diff --git a/tests/test_mails.py b/tests/test_mails.py index 5e71ecd7..28a48e53 100644 --- a/tests/test_mails.py +++ b/tests/test_mails.py @@ -23,7 +23,7 @@ class TestMails(BaseTest): send_ramp_up_submission_email(self.sender, self.recipients, self.approver_1) self.assertTrue(True) - send_ramp_up_submission_email(self.sender, self.recipients, self.approver_1) + send_ramp_up_submission_email(self.sender, self.recipients, self.approver_1, self.approver_2) self.assertTrue(True) def test_send_ramp_up_approval_request_email(self): @@ -32,7 +32,7 @@ class TestMails(BaseTest): 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.approver_1 + self.sender, self.recipients, self.primary_investigator ) self.assertTrue(True) From 4fc1b51cbce49c4e74353dcba47117f8c61e9618 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Fri, 5 Jun 2020 12:08:31 -0600 Subject: [PATCH 53/76] Approve/denied emails --- crc/services/approval_service.py | 47 ++++++++++++++----- crc/services/mails.py | 17 +++++++ .../templates/mails/ramp_up_denied.html | 1 + crc/static/templates/mails/ramp_up_denied.txt | 1 + .../mails/ramp_up_denied_first_approver.html | 1 + .../mails/ramp_up_denied_first_approver.txt | 1 + tests/test_mails.py | 9 +++- 7 files changed, 63 insertions(+), 14 deletions(-) create mode 100644 crc/static/templates/mails/ramp_up_denied_first_approver.html create mode 100644 crc/static/templates/mails/ramp_up_denied_first_approver.txt diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index 2c131aaa..3c710ab1 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -12,10 +12,11 @@ from crc.services.file_service import FileService from crc.services.ldap_service import LdapService from crc.services.mails import ( send_ramp_up_submission_email, - # send_ramp_up_approval_request_email, + send_ramp_up_approval_request_email, send_ramp_up_approval_request_first_review_email, - # send_ramp_up_approved_email, - # send_ramp_up_denied_email + send_ramp_up_approved_email, + send_ramp_up_denied_email, + send_ramp_up_denied_email_to_approver ) @@ -89,24 +90,44 @@ class ApprovalService(object): 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, - status=ApprovalStatus.PENDING.value, version=db_approval.version).first() - if second_approval: + # second_approval = ApprovalModel().query.filter_by( + # study_id=db_approval.study_id, workflow_id=db_approval.workflow_id, + # status=ApprovalStatus.PENDING.value, version=db_approval.version).first() + # if second_approval: # send rrp approval request for second approver - pass - else: - # send rrp approved email - pass + ldap_service = LdapService() + pi_user_info = ldap_service.user_info(model.study.primary_investigator_id) + approver_info = ldap_service.user_info(approver_uid) + # send rrp submission + send_ramp_up_approved_email( + 'askresearch@virginia.edu', + [pi_user_info.email_address], + f'{approver_info.display_name} - ({approver_info.uid})' + ) elif status == ApprovalStatus.DECLINED.value: + ldap_service = LdapService() + pi_user_info = ldap_service.user_info(model.study.primary_investigator_id) + approver_info = ldap_service.user_info(approver_uid) + # send rrp submission + send_ramp_up_denied_email( + 'askresearch@virginia.edu', + [pi_user_info.email_address], + f'{approver_info.display_name} - ({approver_info.uid})' + ) 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() if first_approval: # Second approver denies + first_approver_info = ldap_service.user_info(first_approval.approver_uid) + approver_email = [first_approver_info.email_address] if first_approver_info.email_address else app.config['FALLBACK_EMAILS'] # send rrp denied by second approver email to first approver - pass - # send rrp denied email + send_ramp_up_denied_email_to_approver( + 'askresearch@virginia.edu', + approver_email, + f'{pi_user_info.display_name} - ({pi_user_info.uid})', + f'{approver_info.display_name} - ({approver_info.uid})' + ) # TODO: Log update action by approver_uid - maybe ? return db_approval diff --git a/crc/services/mails.py b/crc/services/mails.py index 487ff2a9..d6de3ff6 100644 --- a/crc/services/mails.py +++ b/crc/services/mails.py @@ -90,3 +90,20 @@ def send_ramp_up_denied_email(sender, recipients, approver): mail.send(msg) except Exception as e: return str(e) + +def send_ramp_up_denied_email_to_approver(sender, recipients, primary_investigator, approver_2): + try: + msg = Message('Research Ramp-up Plan Denied', + sender=sender, + recipients=recipients) + + 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) + + mail.send(msg) + except Exception as e: + return str(e) diff --git a/crc/static/templates/mails/ramp_up_denied.html b/crc/static/templates/mails/ramp_up_denied.html index e69de29b..9c978a80 100644 --- a/crc/static/templates/mails/ramp_up_denied.html +++ b/crc/static/templates/mails/ramp_up_denied.html @@ -0,0 +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 diff --git a/crc/static/templates/mails/ramp_up_denied.txt b/crc/static/templates/mails/ramp_up_denied.txt index e69de29b..5fbaefda 100644 --- a/crc/static/templates/mails/ramp_up_denied.txt +++ b/crc/static/templates/mails/ramp_up_denied.txt @@ -0,0 +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 diff --git a/crc/static/templates/mails/ramp_up_denied_first_approver.html b/crc/static/templates/mails/ramp_up_denied_first_approver.html new file mode 100644 index 00000000..e58cae99 --- /dev/null +++ b/crc/static/templates/mails/ramp_up_denied_first_approver.html @@ -0,0 +1 @@ +

    The Research Ramp-up Plan submitted by {{ primary_investigator }} was denied by {{ approver_2 }} and returned for requested updates. You may see comments related to this denial in on your Research Ramp-up Toolkit Approval dashboard.

    \ No newline at end of file diff --git a/crc/static/templates/mails/ramp_up_denied_first_approver.txt b/crc/static/templates/mails/ramp_up_denied_first_approver.txt new file mode 100644 index 00000000..7172c856 --- /dev/null +++ b/crc/static/templates/mails/ramp_up_denied_first_approver.txt @@ -0,0 +1 @@ +The Research Ramp-up Plan submitted by {{ primary_investigator }} was denied by {{ approver_2 }} and returned for requested updates. You may see comments related to this denial in on your Research Ramp-up Toolkit Approval dashboard. \ No newline at end of file diff --git a/tests/test_mails.py b/tests/test_mails.py index 28a48e53..15a01583 100644 --- a/tests/test_mails.py +++ b/tests/test_mails.py @@ -6,7 +6,8 @@ from crc.services.mails import ( send_ramp_up_approval_request_email, send_ramp_up_approval_request_first_review_email, send_ramp_up_approved_email, - send_ramp_up_denied_email + send_ramp_up_denied_email, + send_ramp_up_denied_email_to_approver ) @@ -46,3 +47,9 @@ class TestMails(BaseTest): def test_send_ramp_up_denied_email(self): send_ramp_up_denied_email(self.sender, self.recipients, self.approver_1) self.assertTrue(True) + + 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) From a6758fd555a2849ff2ea57b9c53d907c57f6b94f Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Fri, 5 Jun 2020 12:08:46 -0600 Subject: [PATCH 54/76] Removing deprecation warnings --- tests/test_approvals_service.py | 20 +-- tests/test_authentication.py | 8 +- tests/test_complete_template_script.py | 2 +- tests/test_file_service.py | 14 +-- tests/test_ldap_service.py | 2 +- tests/test_lookup_service.py | 52 ++++---- tests/test_request_approval_script.py | 6 +- tests/test_study_api.py | 16 +-- tests/test_study_service.py | 20 +-- tests/test_tasks_api.py | 114 +++++++++--------- tests/test_tools_api.py | 2 +- tests/test_update_study_script.py | 4 +- .../test_workflow_processor_multi_instance.py | 24 ++-- tests/test_workflow_service.py | 6 +- 14 files changed, 145 insertions(+), 145 deletions(-) diff --git a/tests/test_approvals_service.py b/tests/test_approvals_service.py index 84e590ff..26a26ef4 100644 --- a/tests/test_approvals_service.py +++ b/tests/test_approvals_service.py @@ -17,12 +17,12 @@ class TestApprovalsService(BaseTest): ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r") - self.assertEquals(1, db.session.query(ApprovalModel).count()) + self.assertEqual(1, db.session.query(ApprovalModel).count()) model = db.session.query(ApprovalModel).first() - self.assertEquals(workflow.study_id, model.study_id) - self.assertEquals(workflow.id, model.workflow_id) - self.assertEquals("dhf8r", model.approver_uid) - self.assertEquals(1, model.version) + self.assertEqual(workflow.study_id, model.study_id) + self.assertEqual(workflow.id, model.workflow_id) + self.assertEqual("dhf8r", model.approver_uid) + self.assertEqual(1, model.version) def test_new_requests_dont_add_if_approval_exists_for_current_workflow(self): self.create_reference_document() @@ -33,9 +33,9 @@ class TestApprovalsService(BaseTest): 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="dhf8r") - self.assertEquals(1, db.session.query(ApprovalModel).count()) + self.assertEqual(1, db.session.query(ApprovalModel).count()) model = db.session.query(ApprovalModel).first() - self.assertEquals(1, model.version) + self.assertEqual(1, model.version) def test_new_approval_requests_after_file_modification_create_new_requests(self): self.load_example_data() @@ -52,10 +52,10 @@ class TestApprovalsService(BaseTest): binary_data=b'5678', irb_doc_code="UVACompl_PRCAppr") ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r") - self.assertEquals(2, db.session.query(ApprovalModel).count()) + self.assertEqual(2, db.session.query(ApprovalModel).count()) models = db.session.query(ApprovalModel).order_by(ApprovalModel.version).all() - self.assertEquals(1, models[0].version) - self.assertEquals(2, models[1].version) + self.assertEqual(1, models[0].version) + self.assertEqual(2, models[1].version) def test_new_approval_sends_proper_emails(self): self.assertEqual(1, 1) diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 11b77d07..713d0663 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -49,10 +49,10 @@ class TestAuthentication(BaseTest): self.assert_success(rv) user = db.session.query(UserModel).filter(UserModel.uid == new_uid).first() self.assertIsNotNone(user) - self.assertEquals(new_uid, user.uid) - self.assertEquals("Laura Barnes", user.display_name) - self.assertEquals("lb3dp@virginia.edu", user.email_address) - self.assertEquals("E0:Associate Professor of Systems and Information Engineering", user.title) + self.assertEqual(new_uid, user.uid) + self.assertEqual("Laura Barnes", user.display_name) + self.assertEqual("lb3dp@virginia.edu", user.email_address) + self.assertEqual("E0:Associate Professor of Systems and Information Engineering", user.title) def test_current_user_status(self): diff --git a/tests/test_complete_template_script.py b/tests/test_complete_template_script.py index dfac20c6..985c2a87 100644 --- a/tests/test_complete_template_script.py +++ b/tests/test_complete_template_script.py @@ -17,7 +17,7 @@ class TestCompleteTemplate(unittest.TestCase): data = {"name": "Dan"} data_copy = copy.deepcopy(data) script.rich_text_update(data_copy) - self.assertEquals(data, data_copy) + self.assertEqual(data, data_copy) def test_rich_text_update_new_line(self): script = CompleteTemplate() diff --git a/tests/test_file_service.py b/tests/test_file_service.py index 705fef95..6dae25ac 100644 --- a/tests/test_file_service.py +++ b/tests/test_file_service.py @@ -22,11 +22,11 @@ class TestFileService(BaseTest): binary_data=b'5678', irb_doc_code=irb_code) file_models = FileService.get_workflow_files(workflow_id=workflow.id) - self.assertEquals(1, len(file_models)) + self.assertEqual(1, len(file_models)) file_data = FileService.get_workflow_data_files(workflow_id=workflow.id) - self.assertEquals(1, len(file_data)) - self.assertEquals(2, file_data[0].version) + self.assertEqual(1, len(file_data)) + self.assertEqual(2, file_data[0].version) def test_add_file_from_form_increments_version_and_replaces_on_subsequent_add_with_same_name(self): @@ -47,11 +47,11 @@ class TestFileService(BaseTest): binary_data=b'5678') file_models = FileService.get_workflow_files(workflow_id=workflow.id) - self.assertEquals(1, len(file_models)) + self.assertEqual(1, len(file_models)) file_data = FileService.get_workflow_data_files(workflow_id=workflow.id) - self.assertEquals(1, len(file_data)) - self.assertEquals(2, file_data[0].version) + self.assertEqual(1, len(file_data)) + self.assertEqual(2, file_data[0].version) def test_add_file_from_form_allows_multiple_files_with_different_names(self): self.load_example_data() @@ -70,4 +70,4 @@ class TestFileService(BaseTest): name="a_different_thing.png", content_type="text", binary_data=b'5678') file_models = FileService.get_workflow_files(workflow_id=workflow.id) - self.assertEquals(2, len(file_models)) + self.assertEqual(2, len(file_models)) diff --git a/tests/test_ldap_service.py b/tests/test_ldap_service.py index 9e2e8931..45f7830d 100644 --- a/tests/test_ldap_service.py +++ b/tests/test_ldap_service.py @@ -30,4 +30,4 @@ class TestLdapService(BaseTest): user_info = self.ldap_service.user_info("nosuch") self.assertFalse(True, "An API error should be raised.") except ApiError as ae: - self.assertEquals("missing_ldap_record", ae.code) \ No newline at end of file + self.assertEqual("missing_ldap_record", ae.code) \ No newline at end of file diff --git a/tests/test_lookup_service.py b/tests/test_lookup_service.py index 4a2b1920..b61e20e2 100644 --- a/tests/test_lookup_service.py +++ b/tests/test_lookup_service.py @@ -31,7 +31,7 @@ class TestLookupService(BaseTest): self.assertEqual(1, len(lookup_records)) lookup_record = lookup_records[0] lookup_data = session.query(LookupDataModel).filter(LookupDataModel.lookup_file_model == lookup_record).all() - self.assertEquals(28, len(lookup_data)) + self.assertEqual(28, len(lookup_data)) def test_updates_to_file_cause_lookup_rebuild(self): spec = BaseTest.load_test_spec('enum_options_with_search') @@ -43,7 +43,7 @@ class TestLookupService(BaseTest): self.assertEqual(1, len(lookup_records)) lookup_record = lookup_records[0] lookup_data = session.query(LookupDataModel).filter(LookupDataModel.lookup_file_model == lookup_record).all() - self.assertEquals(28, len(lookup_data)) + self.assertEqual(28, len(lookup_data)) # Update the workflow specification file. file_path = os.path.join(app.root_path, '..', 'tests', 'data', @@ -59,7 +59,7 @@ class TestLookupService(BaseTest): lookup_records = session.query(LookupFileModel).all() lookup_record = lookup_records[0] lookup_data = session.query(LookupDataModel).filter(LookupDataModel.lookup_file_model == lookup_record).all() - self.assertEquals(4, len(lookup_data)) + self.assertEqual(4, len(lookup_data)) @@ -70,49 +70,49 @@ class TestLookupService(BaseTest): processor.do_engine_steps() results = LookupService.lookup(workflow, "AllTheNames", "", limit=10) - self.assertEquals(10, len(results), "Blank queries return everything, to the limit") + self.assertEqual(10, len(results), "Blank queries return everything, to the limit") results = LookupService.lookup(workflow, "AllTheNames", "medicines", limit=10) - self.assertEquals(1, len(results), "words in the middle of label are detected.") - self.assertEquals("The Medicines Company", results[0].label) + self.assertEqual(1, len(results), "words in the middle of label are detected.") + self.assertEqual("The Medicines Company", results[0].label) results = LookupService.lookup(workflow, "AllTheNames", "UVA", limit=10) - self.assertEquals(1, len(results), "Beginning of label is found.") - self.assertEquals("UVA - INTERNAL - GM USE ONLY", results[0].label) + self.assertEqual(1, len(results), "Beginning of label is found.") + self.assertEqual("UVA - INTERNAL - GM USE ONLY", results[0].label) results = LookupService.lookup(workflow, "AllTheNames", "uva", limit=10) - self.assertEquals(1, len(results), "case does not matter.") - self.assertEquals("UVA - INTERNAL - GM USE ONLY", results[0].label) + self.assertEqual(1, len(results), "case does not matter.") + self.assertEqual("UVA - INTERNAL - GM USE ONLY", results[0].label) results = LookupService.lookup(workflow, "AllTheNames", "medici", limit=10) - self.assertEquals(1, len(results), "partial words are picked up.") - self.assertEquals("The Medicines Company", results[0].label) + self.assertEqual(1, len(results), "partial words are picked up.") + self.assertEqual("The Medicines Company", results[0].label) results = LookupService.lookup(workflow, "AllTheNames", "Genetics Savings", limit=10) - self.assertEquals(1, len(results), "multiple terms are picked up..") - self.assertEquals("Genetics Savings & Clone, Inc.", results[0].label) + self.assertEqual(1, len(results), "multiple terms are picked up..") + self.assertEqual("Genetics Savings & Clone, Inc.", results[0].label) results = LookupService.lookup(workflow, "AllTheNames", "Genetics Sav", limit=10) - self.assertEquals(1, len(results), "prefix queries still work with partial terms") - self.assertEquals("Genetics Savings & Clone, Inc.", results[0].label) + self.assertEqual(1, len(results), "prefix queries still work with partial terms") + self.assertEqual("Genetics Savings & Clone, Inc.", results[0].label) results = LookupService.lookup(workflow, "AllTheNames", "Gen Sav", limit=10) - self.assertEquals(1, len(results), "prefix queries still work with ALL the partial terms") - self.assertEquals("Genetics Savings & Clone, Inc.", results[0].label) + self.assertEqual(1, len(results), "prefix queries still work with ALL the partial terms") + self.assertEqual("Genetics Savings & Clone, Inc.", results[0].label) results = LookupService.lookup(workflow, "AllTheNames", "Inc", limit=10) - self.assertEquals(7, len(results), "short terms get multiple correct results.") - self.assertEquals("Genetics Savings & Clone, Inc.", results[0].label) + self.assertEqual(7, len(results), "short terms get multiple correct results.") + self.assertEqual("Genetics Savings & Clone, Inc.", results[0].label) results = LookupService.lookup(workflow, "AllTheNames", "reaction design", limit=10) - self.assertEquals(5, len(results), "all results come back for two terms.") - self.assertEquals("Reaction Design", results[0].label, "Exact matches come first.") + self.assertEqual(5, len(results), "all results come back for two terms.") + self.assertEqual("Reaction Design", results[0].label, "Exact matches come first.") results = LookupService.lookup(workflow, "AllTheNames", "1 Something", limit=10) - self.assertEquals("1 Something", results[0].label, "Exact matches are prefered") + self.assertEqual("1 Something", results[0].label, "Exact matches are prefered") results = LookupService.lookup(workflow, "AllTheNames", "1 (!-Something", limit=10) - self.assertEquals("1 Something", results[0].label, "special characters don't flake out") + self.assertEqual("1 Something", results[0].label, "special characters don't flake out") @@ -124,6 +124,6 @@ class TestLookupService(BaseTest): # Fixme: Stop words are taken into account on the query side, and haven't found a fix yet. #results = WorkflowService.run_lookup_query(lookup_table.id, "in", limit=10) - #self.assertEquals(7, len(results), "stop words are not removed.") - #self.assertEquals("Genetics Savings & Clone, Inc.", results[0].label) + #self.assertEqual(7, len(results), "stop words are not removed.") + #self.assertEqual("Genetics Savings & Clone, Inc.", results[0].label) diff --git a/tests/test_request_approval_script.py b/tests/test_request_approval_script.py index 8cd56807..ebfe8436 100644 --- a/tests/test_request_approval_script.py +++ b/tests/test_request_approval_script.py @@ -24,7 +24,7 @@ class TestRequestApprovalScript(BaseTest): binary_data=b'1234') script = RequestApproval() script.do_task(task, workflow.study_id, workflow.id, "study.approval1", "study.approval2") - self.assertEquals(2, db.session.query(ApprovalModel).count()) + self.assertEqual(2, db.session.query(ApprovalModel).count()) def test_do_task_with_blank_second_approver(self): self.load_example_data() @@ -39,7 +39,7 @@ class TestRequestApprovalScript(BaseTest): binary_data=b'1234') script = RequestApproval() script.do_task(task, workflow.study_id, workflow.id, "study.approval1", "study.approval2") - self.assertEquals(1, db.session.query(ApprovalModel).count()) + self.assertEqual(1, db.session.query(ApprovalModel).count()) def test_do_task_with_incorrect_argument(self): @@ -64,5 +64,5 @@ class TestRequestApprovalScript(BaseTest): script = RequestApproval() script.do_task_validate_only(task, workflow.study_id, workflow.id, "study.approval1") - self.assertEquals(0, db.session.query(ApprovalModel).count()) + self.assertEqual(0, db.session.query(ApprovalModel).count()) diff --git a/tests/test_study_api.py b/tests/test_study_api.py index 61e42543..cdae21c5 100644 --- a/tests/test_study_api.py +++ b/tests/test_study_api.py @@ -87,10 +87,10 @@ class TestStudyApi(BaseTest): 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.assertEquals(1, len(study.files)) - self.assertEquals("UVA Compliance/PRC Approval", study.files[0]["category"]) - self.assertEquals("Cancer Center's PRC Approval Form", study.files[0]["description"]) - self.assertEquals("UVA Compliance/PRC Approval.png", study.files[0]["download_name"]) + self.assertEqual(1, len(study.files)) + self.assertEqual("UVA Compliance/PRC Approval", study.files[0]["category"]) + self.assertEqual("Cancer Center's PRC Approval Form", study.files[0]["description"]) + self.assertEqual("UVA Compliance/PRC Approval.png", study.files[0]["download_name"]) # TODO: WRITE A TEST FOR STUDY FILES @@ -180,10 +180,10 @@ class TestStudyApi(BaseTest): db_studies_after = session.query(StudyModel).all() num_db_studies_after = len(db_studies_after) self.assertGreater(num_db_studies_after, num_db_studies_before) - self.assertEquals(num_abandoned, 1) - self.assertEquals(num_open, 1) - self.assertEquals(num_active, 1) - self.assertEquals(num_incomplete, 1) + self.assertEqual(num_abandoned, 1) + self.assertEqual(num_open, 1) + self.assertEqual(num_active, 1) + self.assertEqual(num_incomplete, 1) 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/test_study_service.py b/tests/test_study_service.py index 52babbb8..03a0e033 100644 --- a/tests/test_study_service.py +++ b/tests/test_study_service.py @@ -153,7 +153,7 @@ class TestStudyService(BaseTest): self.assertEqual(1, docs["UVACompl_PRCAppr"]['count']) self.assertIsNotNone(docs["UVACompl_PRCAppr"]['files'][0]) self.assertIsNotNone(docs["UVACompl_PRCAppr"]['files'][0]['file_id']) - self.assertEquals(workflow.id, docs["UVACompl_PRCAppr"]['files'][0]['workflow_id']) + self.assertEqual(workflow.id, docs["UVACompl_PRCAppr"]['files'][0]['workflow_id']) def test_get_all_studies(self): user = self.create_user_with_study_and_workflow() @@ -174,8 +174,8 @@ class TestStudyService(BaseTest): binary_data=b'1234', irb_doc_code="UVACompl_PRCAppr" ) studies = StudyService().get_all_studies_with_files() - self.assertEquals(1, len(studies)) - self.assertEquals(3, len(studies[0].files)) + self.assertEqual(1, len(studies)) + self.assertEqual(3, len(studies[0].files)) @@ -191,17 +191,17 @@ class TestStudyService(BaseTest): workflow = self.create_workflow('docx') # The workflow really doesnt matter in this case. investigators = StudyService().get_investigators(workflow.study_id) - self.assertEquals(9, len(investigators)) + self.assertEqual(9, len(investigators)) # dhf8r is in the ldap mock data. - self.assertEquals("dhf8r", investigators['PI']['user_id']) - self.assertEquals("Dan Funk", investigators['PI']['display_name']) # Data from ldap - self.assertEquals("Primary Investigator", investigators['PI']['label']) # Data from xls file. - self.assertEquals("Always", investigators['PI']['display']) # Data from xls file. + 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. # asd3v is not in ldap, so an error should be returned. - self.assertEquals("asd3v", investigators['DC']['user_id']) - self.assertEquals("Unable to locate a user with id asd3v in LDAP", investigators['DC']['error']) # Data from ldap + self.assertEqual("asd3v", investigators['DC']['user_id']) + self.assertEqual("Unable to locate a user with id asd3v in LDAP", investigators['DC']['error']) # Data from ldap # No value is provided for Department Chair self.assertIsNone(investigators['DEPT_CH']['user_id']) diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index 37849867..25ba9c4e 100644 --- a/tests/test_tasks_api.py +++ b/tests/test_tasks_api.py @@ -48,7 +48,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) \ @@ -57,25 +57,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) @@ -155,14 +155,14 @@ class TestTasksApi(BaseTest): self.assertIsNotNone(workflow_api.navigation) nav = workflow_api.navigation - self.assertEquals(5, len(nav)) - self.assertEquals("Do You Have Bananas", nav[0]['title']) - self.assertEquals("Bananas?", nav[1]['title']) - self.assertEquals("FUTURE", nav[1]['state']) - self.assertEquals("yes", nav[2]['title']) - self.assertEquals("NOOP", nav[2]['state']) - self.assertEquals("no", nav[3]['title']) - self.assertEquals("NOOP", nav[3]['state']) + self.assertEqual(5, len(nav)) + self.assertEqual("Do You Have Bananas", nav[0]['title']) + self.assertEqual("Bananas?", nav[1]['title']) + self.assertEqual("FUTURE", nav[1]['state']) + self.assertEqual("yes", nav[2]['title']) + self.assertEqual("NOOP", nav[2]['state']) + self.assertEqual("no", nav[3]['title']) + self.assertEqual("NOOP", nav[3]['state']) def test_navigation_with_exclusive_gateway(self): self.load_example_data() @@ -172,14 +172,14 @@ class TestTasksApi(BaseTest): workflow_api = self.get_workflow_api(workflow) self.assertIsNotNone(workflow_api.navigation) nav = workflow_api.navigation - self.assertEquals(7, len(nav)) - self.assertEquals("Task 1", nav[0]['title']) - self.assertEquals("Which Branch?", nav[1]['title']) - self.assertEquals("a", nav[2]['title']) - self.assertEquals("Task 2a", nav[3]['title']) - self.assertEquals("b", nav[4]['title']) - self.assertEquals("Task 2b", nav[5]['title']) - self.assertEquals("Task 3", nav[6]['title']) + self.assertEqual(7, len(nav)) + self.assertEqual("Task 1", nav[0]['title']) + self.assertEqual("Which Branch?", nav[1]['title']) + self.assertEqual("a", nav[2]['title']) + self.assertEqual("Task 2a", nav[3]['title']) + self.assertEqual("b", nav[4]['title']) + self.assertEqual("Task 2b", nav[5]['title']) + self.assertEqual("Task 3", nav[6]['title']) def test_document_added_to_workflow_shows_up_in_file_list(self): self.load_example_data() @@ -286,8 +286,8 @@ class TestTasksApi(BaseTest): workflow_api = self.complete_form(workflow, task, {"name": "Dan"}) workflow = self.get_workflow_api(workflow) - self.assertEquals('Task_Manual_One', workflow.next_task.name) - self.assertEquals('ManualTask', workflow_api.next_task.type) + self.assertEqual('Task_Manual_One', workflow.next_task.name) + self.assertEqual('ManualTask', workflow_api.next_task.type) self.assertTrue('Markdown' in workflow_api.next_task.documentation) self.assertTrue('Dan' in workflow_api.next_task.documentation) @@ -297,7 +297,7 @@ class TestTasksApi(BaseTest): # get the first form in the two form workflow. task = self.get_workflow_api(workflow).next_task - self.assertEquals("JustAValue", task.properties['JustAKey']) + self.assertEqual("JustAValue", task.properties['JustAKey']) @patch('crc.services.protocol_builder.requests.get') @@ -317,13 +317,13 @@ class TestTasksApi(BaseTest): # get the first form in the two form workflow. workflow = self.get_workflow_api(workflow) navigation = self.get_workflow_api(workflow).navigation - self.assertEquals(4, len(navigation)) # Start task, form_task, multi_task, end task - self.assertEquals("UserTask", workflow.next_task.type) - self.assertEquals(MultiInstanceType.sequential.value, workflow.next_task.multi_instance_type) - self.assertEquals(9, workflow.next_task.multi_instance_count) + 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) # Assure that the names for each task are properly updated, so they aren't all the same. - self.assertEquals("Primary Investigator", workflow.next_task.properties['display_name']) + self.assertEqual("Primary Investigator", workflow.next_task.properties['display_name']) def test_lookup_endpoint_for_task_field_enumerations(self): @@ -365,18 +365,18 @@ class TestTasksApi(BaseTest): navigation = workflow_api.navigation task = workflow_api.next_task - self.assertEquals(2, len(navigation)) - self.assertEquals("UserTask", task.type) - self.assertEquals("Activity_A", task.name) - self.assertEquals("My Sub Process", task.process_name) + self.assertEqual(2, len(navigation)) + 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"}) task = workflow_api.next_task self.assertIsNotNone(task) - self.assertEquals("Activity_B", task.name) - self.assertEquals("Sub Workflow Example", task.process_name) + self.assertEqual("Activity_B", task.name) + self.assertEqual("Sub Workflow Example", task.process_name) workflow_api = self.complete_form(workflow, task, {"name": "Dan"}) - self.assertEquals(WorkflowStatus.complete, workflow_api.status) + self.assertEqual(WorkflowStatus.complete, workflow_api.status) def test_update_task_resets_token(self): self.load_example_data() @@ -386,7 +386,7 @@ class TestTasksApi(BaseTest): first_task = self.get_workflow_api(workflow).next_task self.complete_form(workflow, first_task, {"has_bananas": True}) workflow = self.get_workflow_api(workflow) - self.assertEquals('Task_Num_Bananas', workflow.next_task.name) + self.assertEqual('Task_Num_Bananas', workflow.next_task.name) # Trying to re-submit the initial task, and answer differently, should result in an error. self.complete_form(workflow, first_task, {"has_bananas": False}, error_code="invalid_state") @@ -407,18 +407,18 @@ class TestTasksApi(BaseTest): workflow = WorkflowApiSchema().load(json_data) # Assure the Next Task is the one we just reset the token to be on. - self.assertEquals("Task_Has_Bananas", workflow.next_task.name) + self.assertEqual("Task_Has_Bananas", workflow.next_task.name) # Go ahead and get that workflow one more time, it should still be right. workflow = self.get_workflow_api(workflow) # Assure the Next Task is the one we just reset the token to be on. - self.assertEquals("Task_Has_Bananas", workflow.next_task.name) + self.assertEqual("Task_Has_Bananas", workflow.next_task.name) # The next task should be a different value. self.complete_form(workflow, workflow.next_task, {"has_bananas": False}) workflow = self.get_workflow_api(workflow) - self.assertEquals('Task_Why_No_Bananas', workflow.next_task.name) + self.assertEqual('Task_Why_No_Bananas', workflow.next_task.name) @patch('crc.services.protocol_builder.requests.get') def test_parallel_multi_instance(self, mock_get): @@ -433,13 +433,13 @@ class TestTasksApi(BaseTest): workflow = self.create_workflow('multi_instance_parallel') workflow_api = self.get_workflow_api(workflow) - self.assertEquals(12, len(workflow_api.navigation)) + self.assertEqual(12, len(workflow_api.navigation)) ready_items = [nav for nav in workflow_api.navigation if nav['state'] == "READY"] - self.assertEquals(9, len(ready_items)) + self.assertEqual(9, len(ready_items)) - self.assertEquals("UserTask", workflow_api.next_task.type) - self.assertEquals("MutiInstanceTask",workflow_api.next_task.name) - self.assertEquals("more information", workflow_api.next_task.title) + self.assertEqual("UserTask", workflow_api.next_task.type) + self.assertEqual("MutiInstanceTask",workflow_api.next_task.name) + self.assertEqual("more information", workflow_api.next_task.title) for i in random.sample(range(9), 9): task = TaskSchema().load(ready_items[i]['task']) @@ -447,5 +447,5 @@ class TestTasksApi(BaseTest): #tasks = self.get_workflow_api(workflow).user_tasks workflow = self.get_workflow_api(workflow) - self.assertEquals(WorkflowStatus.complete, workflow.status) + self.assertEqual(WorkflowStatus.complete, workflow.status) diff --git a/tests/test_tools_api.py b/tests/test_tools_api.py index 48ac65a7..c6f543c1 100644 --- a/tests/test_tools_api.py +++ b/tests/test_tools_api.py @@ -28,7 +28,7 @@ class TestStudyApi(BaseTest): content_type='multipart/form-data') self.assert_success(rv) self.assertIsNotNone(rv.data) - self.assertEquals('application/octet-stream', rv.content_type) + self.assertEqual('application/octet-stream', rv.content_type) def test_list_scripts(self): rv = self.app.get('/v1.0/list_scripts') diff --git a/tests/test_update_study_script.py b/tests/test_update_study_script.py index ba550a19..df59ffc2 100644 --- a/tests/test_update_study_script.py +++ b/tests/test_update_study_script.py @@ -19,5 +19,5 @@ class TestUpdateStudyScript(BaseTest): script = UpdateStudy() script.do_task(task, workflow.study_id, workflow.id, "title:details.label", "pi:details.value") - self.assertEquals("My New Title", workflow.study.title) - self.assertEquals("dhf8r", workflow.study.primary_investigator_id) + self.assertEqual("My New Title", workflow.study.title) + self.assertEqual("dhf8r", workflow.study.primary_investigator_id) diff --git a/tests/test_workflow_processor_multi_instance.py b/tests/test_workflow_processor_multi_instance.py index 21fc3b43..aefb73f1 100644 --- a/tests/test_workflow_processor_multi_instance.py +++ b/tests/test_workflow_processor_multi_instance.py @@ -57,13 +57,13 @@ class TestWorkflowProcessorMultiInstance(BaseTest): task = next_user_tasks[0] self.assertEqual(WorkflowStatus.user_input_required, processor.get_status()) - self.assertEquals("dhf8r", task.data["investigator"]["user_id"]) + self.assertEqual("dhf8r", task.data["investigator"]["user_id"]) self.assertEqual("MutiInstanceTask", task.get_name()) api_task = WorkflowService.spiff_task_to_api_task(task) - self.assertEquals(MultiInstanceType.sequential, api_task.multi_instance_type) - self.assertEquals(3, api_task.multi_instance_count) - self.assertEquals(1, api_task.multi_instance_index) + self.assertEqual(MultiInstanceType.sequential, api_task.multi_instance_type) + self.assertEqual(3, api_task.multi_instance_count) + self.assertEqual(1, api_task.multi_instance_index) task.update_data({"investigator":{"email":"asd3v@virginia.edu"}}) processor.complete_task(task) processor.do_engine_steps() @@ -72,8 +72,8 @@ class TestWorkflowProcessorMultiInstance(BaseTest): api_task = WorkflowService.spiff_task_to_api_task(task) self.assertEqual("MutiInstanceTask", api_task.name) task.update_data({"investigator":{"email":"asdf32@virginia.edu"}}) - self.assertEquals(3, api_task.multi_instance_count) - self.assertEquals(2, api_task.multi_instance_index) + self.assertEqual(3, api_task.multi_instance_count) + self.assertEqual(2, api_task.multi_instance_index) processor.complete_task(task) processor.do_engine_steps() @@ -81,8 +81,8 @@ class TestWorkflowProcessorMultiInstance(BaseTest): api_task = WorkflowService.spiff_task_to_api_task(task) self.assertEqual("MutiInstanceTask", task.get_name()) task.update_data({"investigator":{"email":"dhf8r@virginia.edu"}}) - self.assertEquals(3, api_task.multi_instance_count) - self.assertEquals(3, api_task.multi_instance_index) + self.assertEqual(3, api_task.multi_instance_count) + self.assertEqual(3, api_task.multi_instance_index) processor.complete_task(task) processor.do_engine_steps() task = processor.bpmn_workflow.last_task @@ -91,7 +91,7 @@ class TestWorkflowProcessorMultiInstance(BaseTest): expected['PI']['email'] = "asd3v@virginia.edu" expected['SC_I']['email'] = "asdf32@virginia.edu" expected['DC']['email'] = "dhf8r@virginia.edu" - self.assertEquals(expected, + self.assertEqual(expected, task.data['StudyInfo']['investigators']) self.assertEqual(WorkflowStatus.complete, processor.get_status()) @@ -117,10 +117,10 @@ class TestWorkflowProcessorMultiInstance(BaseTest): task = next_user_tasks[2] self.assertEqual(WorkflowStatus.user_input_required, processor.get_status()) - self.assertEquals("asd3v", task.data["investigator"]["user_id"]) # The last of the tasks + self.assertEqual("asd3v", task.data["investigator"]["user_id"]) # The last of the tasks api_task = WorkflowService.spiff_task_to_api_task(task) - self.assertEquals(MultiInstanceType.parallel, api_task.multi_instance_type) + self.assertEqual(MultiInstanceType.parallel, api_task.multi_instance_type) task.update_data({"investigator":{"email":"dhf8r@virginia.edu"}}) processor.complete_task(task) processor.do_engine_steps() @@ -144,7 +144,7 @@ class TestWorkflowProcessorMultiInstance(BaseTest): expected['PI']['email'] = "asd3v@virginia.edu" expected['SC_I']['email'] = "asdf32@virginia.edu" expected['DC']['email'] = "dhf8r@virginia.edu" - self.assertEquals(expected, + self.assertEqual(expected, task.data['StudyInfo']['investigators']) self.assertEqual(WorkflowStatus.complete, processor.get_status()) diff --git a/tests/test_workflow_service.py b/tests/test_workflow_service.py index f509f642..9f3ceda1 100644 --- a/tests/test_workflow_service.py +++ b/tests/test_workflow_service.py @@ -66,9 +66,9 @@ class TestWorkflowService(BaseTest): task = processor.next_task() WorkflowService.process_options(task, task.task_spec.form.fields[0]) options = task.task_spec.form.fields[0].options - self.assertEquals(28, len(options)) - self.assertEquals('1000', options[0]['id']) - self.assertEquals("UVA - INTERNAL - GM USE ONLY", options[0]['name']) + self.assertEqual(28, len(options)) + self.assertEqual('1000', options[0]['id']) + self.assertEqual("UVA - INTERNAL - GM USE ONLY", options[0]['name']) def test_random_data_populate_form_on_auto_complete(self): self.load_example_data() From f0db5b70fcd7d6e5a3ea2b20ee5be01ff7703ea2 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Fri, 5 Jun 2020 14:33:00 -0400 Subject: [PATCH 55/76] Adding some additional logic to the approval endpoint so that we take related approvals into account when setting the status. In addtion to prevous status options, there is a new status of "AWAITING" which means there are pending approvals before this approval that still need to be approved or canceled. --- crc/api.yml | 6 ++++++ crc/api/approval.py | 4 +++- crc/models/approval.py | 3 +++ crc/scripts/request_approval.py | 3 ++- crc/services/approval_service.py | 26 +++++++++++++++++++++++++- 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crc/api.yml b/crc/api.yml index 07e61251..b4e45a95 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -814,6 +814,12 @@ paths: description: If set to true, returns all the approvals known to the system. schema: type: boolean + - name: as_user + in: query + required: false + description: If provided, returns the approval results as they would appear for that user. + schema: + type: string get: operationId: crc.api.approval.get_approvals summary: Provides a list of workflows approvals diff --git a/crc/api/approval.py b/crc/api/approval.py index 9c42c82e..9cd0399c 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -13,9 +13,11 @@ from crc.services.approval_service import ApprovalService from crc.services.ldap_service import LdapService -def get_approvals(everything=False): +def get_approvals(everything=False, as_user=None): if everything: approvals = ApprovalService.get_all_approvals(include_cancelled=True) + elif as_user: + approvals = ApprovalService.get_all_approvals(as_user, include_cancelled=False) else: approvals = ApprovalService.get_approvals_per_user(g.user.uid, include_cancelled=False) results = ApprovalSchema(many=True).dump(approvals) diff --git a/crc/models/approval.py b/crc/models/approval.py index ee19f4b7..c60bef1f 100644 --- a/crc/models/approval.py +++ b/crc/models/approval.py @@ -20,6 +20,9 @@ class ApprovalStatus(enum.Enum): DECLINED = "DECLINED" # rejected by the reviewer CANCELED = "CANCELED" # The document was replaced with a new version and this review is no longer needed. + # Used for overall status only, never set on a task. + AWAITING = "AWAITING" # awaiting another approval + class ApprovalFile(db.Model): file_data_id = db.Column(db.Integer, db.ForeignKey(FileDataModel.id), primary_key=True) diff --git a/crc/scripts/request_approval.py b/crc/scripts/request_approval.py index 1e5d2c6c..0a4c76ff 100644 --- a/crc/scripts/request_approval.py +++ b/crc/scripts/request_approval.py @@ -11,7 +11,8 @@ class RequestApproval(Script): return """ Creates an approval request on this workflow, by the given approver_uid(s)," Takes multiple arguments, which should point to data located in current task -or be quoted strings. +or be quoted strings. The order is important. Approvals will be processed +in this order. Example: RequestApproval approver1 "dhf8r" diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index 98c99d0b..9b04c3d4 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -27,19 +27,43 @@ class ApprovalService(object): if not include_cancelled: query=query.filter(ApprovalModel.status != ApprovalStatus.CANCELED.value) - approvals = query.all() + approvals = query.all() ## all non-cancelled approvals. + study_approval_status = "" + + # IF THIS IS RELATED TO THE CURRENT USER for approval_model in approvals: if approval_model.approver_uid == approver_uid: main_approval = Approval.from_model(approval_model) else: related_approvals.append(Approval.from_model(approval_model)) + + # IF WE ARE JUST RETURNING ALL OF THE APPROVALS PER STUDY if not main_approval and len(related_approvals) > 0: main_approval = related_approvals[0] related_approvals = related_approvals[1:] + + if(main_approval): # May be null if the study has no approvals. + main_approval.status = ApprovalService.__calculate_overall_approval_status(main_approval) + if len(related_approvals) > 0: main_approval.related_approvals = related_approvals return main_approval + @staticmethod + def __calculate_overall_approval_status(approval): + # In the case of pending approvals, check to see if there is a related approval + # that proceeds this approval - and if it is declined, or still pending, then change + # the state of the approval to be Delcined, or Waiting respectively. + if approval.status == ApprovalStatus.PENDING.value: + for ra in approval.related_approvals: + if ra.id < approval.id: + if ra.status == ApprovalStatus.DECLINED.value or ra.status == ApprovalStatus.CANCELED.value: + return ra.status # If any prior approval id declined or cancelled so is this approval. + elif ra.status == ApprovalStatus.PENDING.value: + return ApprovalStatus.AWAITING.value # if any prior approval is pending, then this is waiting. + else: + return approval.status + @staticmethod def get_approvals_per_user(approver_uid, include_cancelled=False): """Returns a list of approval objects (not db models) for the given From 1f32a99efef6f7bdf0c90465862c9ad40488dd02 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Fri, 5 Jun 2020 14:55:49 -0400 Subject: [PATCH 56/76] Some approval statuses coming back as null., fixed --- crc/services/approval_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index 9b04c3d4..2ffd2093 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -61,6 +61,7 @@ class ApprovalService(object): return ra.status # If any prior approval id declined or cancelled so is this approval. elif ra.status == ApprovalStatus.PENDING.value: return ApprovalStatus.AWAITING.value # if any prior approval is pending, then this is waiting. + return approval.status else: return approval.status From 57a7c7fa5401d940eabf9eb3aedcc5ee45dd161e Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Fri, 5 Jun 2020 13:39:52 -0600 Subject: [PATCH 57/76] Approve/deny fixes --- crc/api/approval.py | 4 ++++ crc/services/approval_service.py | 13 +++++++------ crc/services/mails.py | 4 ---- crc/static/templates/mails/ramp_up_denied.html | 2 +- tests/test_approvals_api.py | 4 ++-- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/crc/api/approval.py b/crc/api/approval.py index 9c42c82e..5c403eec 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -111,5 +111,9 @@ def update_approval(approval_id, body): session.add(approval_model) session.commit() + # Called only to send emails + approver = body['approver']['uid'] + ApprovalService.update_approval(approval_id, approver) + result = ApprovalSchema().dump(approval_model) return result diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index 04b0db65..86547cb3 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -85,13 +85,14 @@ class ApprovalService(object): @staticmethod - def update_approval(approval_id, approver_uid, status): + def update_approval(approval_id, approver_uid): """Update a specific approval""" 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() + # 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, @@ -99,7 +100,7 @@ class ApprovalService(object): # if second_approval: # send rrp approval request for second approver ldap_service = LdapService() - pi_user_info = ldap_service.user_info(model.study.primary_investigator_id) + pi_user_info = ldap_service.user_info(db_approval.study.primary_investigator_id) approver_info = ldap_service.user_info(approver_uid) # send rrp submission send_ramp_up_approved_email( @@ -109,7 +110,7 @@ class ApprovalService(object): ) elif status == ApprovalStatus.DECLINED.value: ldap_service = LdapService() - pi_user_info = ldap_service.user_info(model.study.primary_investigator_id) + pi_user_info = ldap_service.user_info(db_approval.study.primary_investigator_id) approver_info = ldap_service.user_info(approver_uid) # send rrp submission send_ramp_up_denied_email( diff --git a/crc/services/mails.py b/crc/services/mails.py index d6de3ff6..2a80457c 100644 --- a/crc/services/mails.py +++ b/crc/services/mails.py @@ -11,7 +11,6 @@ def send_ramp_up_submission_email(sender, recipients, approver_1, approver_2=Non msg = Message('Research Ramp-up Plan Submitted', sender=sender, recipients=recipients) - from crc import env, mail template = env.get_template('ramp_up_submission.txt') template_vars = {'approver_1': approver_1, 'approver_2': approver_2} @@ -28,7 +27,6 @@ def send_ramp_up_approval_request_email(sender, recipients, primary_investigator msg = Message('Research Ramp-up Plan Approval Request', sender=sender, recipients=recipients) - from crc import env, mail template = env.get_template('ramp_up_approval_request.txt') template_vars = {'primary_investigator': primary_investigator} @@ -45,7 +43,6 @@ def send_ramp_up_approval_request_first_review_email(sender, recipients, primary msg = Message('Research Ramp-up Plan Approval Request', sender=sender, recipients=recipients) - from crc import env, mail template = env.get_template('ramp_up_approval_request_first_review.txt') template_vars = {'primary_investigator': primary_investigator} @@ -62,7 +59,6 @@ def send_ramp_up_approved_email(sender, recipients, approver_1, approver_2=None) msg = Message('Research Ramp-up Plan Approved', sender=sender, recipients=recipients) - from crc import env, mail template = env.get_template('ramp_up_approved.txt') template_vars = {'approver_1': approver_1, 'approver_2': approver_2} diff --git a/crc/static/templates/mails/ramp_up_denied.html b/crc/static/templates/mails/ramp_up_denied.html index 9c978a80..7a40c1ea 100644 --- a/crc/static/templates/mails/ramp_up_denied.html +++ b/crc/static/templates/mails/ramp_up_denied.html @@ -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_approvals_api.py b/tests/test_approvals_api.py index 6d95be39..da1c2076 100644 --- a/tests/test_approvals_api.py +++ b/tests/test_approvals_api.py @@ -106,7 +106,7 @@ class TestApprovals(BaseTest): def test_accept_approval(self): approval = session.query(ApprovalModel).filter_by(approver_uid='dhf8r').first() data = {'id': approval.id, - "approver_uid": "dhf8r", + "approver": {"uid": "dhf8r"}, 'message': "Approved. I like the cut of your jib.", 'status': ApprovalStatus.APPROVED.value} @@ -127,7 +127,7 @@ class TestApprovals(BaseTest): def test_decline_approval(self): approval = session.query(ApprovalModel).filter_by(approver_uid='dhf8r').first() data = {'id': approval.id, - "approver_uid": "dhf8r", + "approver": {"uid": "dhf8r"}, 'message': "Approved. I find the cut of your jib lacking.", 'status': ApprovalStatus.DECLINED.value} From 663da57d8beb768580c07624ea9d7d590f4c429d Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Fri, 5 Jun 2020 13:54:37 -0600 Subject: [PATCH 58/76] Config can read smtp values from environment now --- config/default.py | 8 +++++++- crc/__init__.py | 6 ------ crc/services/mails.py | 1 + 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/config/default.py b/config/default.py index bac744fd..ae17bcfa 100644 --- a/config/default.py +++ b/config/default.py @@ -44,5 +44,11 @@ PB_STUDY_DETAILS_URL = environ.get('PB_STUDY_DETAILS_URL', default=PB_BASE_URL + LDAP_URL = environ.get('LDAP_URL', default="ldap.virginia.edu").strip('/') # No trailing slash or http:// LDAP_TIMEOUT_SEC = int(environ.get('LDAP_TIMEOUT_SEC', default=3)) -# Fallback emails +# Email configuration FALLBACK_EMAILS = ['askresearch@virginia.edu', 'sartographysupport@googlegroups.com'] +MAIL_SERVER = environ.get('MAIL_SERVER', default='smtp.mailtrap.io') +MAIL_PORT = environ.get('MAIL_PORT', default=2525) +MAIL_USE_SSL = environ.get('MAIL_USE_SSL', default=False) +MAIL_USE_TLS = environ.get('MAIL_USE_TLS', default=True) +MAIL_USERNAME = environ.get('MAIL_USERNAME', default='5f012d0108d374') +MAIL_PASSWORD = environ.get('MAIL_PASSWORD', default='08442c04e98d50') diff --git a/crc/__init__.py b/crc/__init__.py index 66b91b63..e77864b9 100644 --- a/crc/__init__.py +++ b/crc/__init__.py @@ -54,12 +54,6 @@ if app.config['ENABLE_SENTRY']: template_dir = os.getcwd() + '/crc/static/templates/mails' env = Environment(loader=FileSystemLoader(template_dir)) # Mail settings -app.config['MAIL_SERVER']='smtp.mailtrap.io' -app.config['MAIL_PORT'] = 2525 -app.config['MAIL_USERNAME'] = '5f012d0108d374' -app.config['MAIL_PASSWORD'] = '08442c04e98d50' -app.config['MAIL_USE_TLS'] = True -app.config['MAIL_USE_SSL'] = False mail = Mail(app) print('=== USING THESE CONFIG SETTINGS: ===') diff --git a/crc/services/mails.py b/crc/services/mails.py index 2a80457c..994914d4 100644 --- a/crc/services/mails.py +++ b/crc/services/mails.py @@ -59,6 +59,7 @@ def send_ramp_up_approved_email(sender, recipients, approver_1, approver_2=None) msg = Message('Research Ramp-up Plan Approved', sender=sender, recipients=recipients) + from crc import env, mail template = env.get_template('ramp_up_approved.txt') template_vars = {'approver_1': approver_1, 'approver_2': approver_2} From f0904e75a6a9db246937274ad37b266397a56406 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 5 Jun 2020 15:54:53 -0400 Subject: [PATCH 59/76] Sets main approval status after related approvals have been populated --- Pipfile.lock | 86 ++++++++++++++++---------------- crc/services/approval_service.py | 11 ++-- 2 files changed, 49 insertions(+), 48 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index aca63a57..d288b80d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -104,10 +104,10 @@ }, "celery": { "hashes": [ - "sha256:5147662e23dc6bc39c17a2cbc9a148debe08ecfb128b0eded14a0d9c81fc5742", - "sha256:df2937b7536a2a9b18024776a3a46fd281721813636c03a5177fa02fe66078f6" + "sha256:9ae2e73b93cc7d6b48b56aaf49a68c91752d0ffd7dfdcc47f842ca79a6f13eae", + "sha256:c2037b6a8463da43b19969a0fc13f9023ceca6352b4dd51be01c66fbbb13647e" ], - "version": "==4.4.3" + "version": "==4.4.4" }, "certifi": { "hashes": [ @@ -344,11 +344,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f", - "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e" + "sha256:0505dd08068cfec00f53a74a0ad927676d7757da81b7436a6eefe4c7cf75c545", + "sha256:15ec6c0fd909e893e3a08b3a7c76ecb149122fb14b7efe1199ddd4c7c57ea958" ], "markers": "python_version < '3.8'", - "version": "==1.6.0" + "version": "==1.6.1" }, "inflection": { "hashes": [ @@ -387,10 +387,10 @@ }, "kombu": { "hashes": [ - "sha256:ab0afaa5388dd2979cbc439d3623b86a4f7a58d41f621096bef7767c37bc2505", - "sha256:aece08f48706743aaa1b9d607fee300559481eafcc5ee56451aa0ef867a3be07" + "sha256:437b9cdea193cc2ed0b8044c85fd0f126bb3615ca2f4d4a35b39de7cacfa3c1a", + "sha256:dc282bb277197d723bccda1a9ba30a27a28c9672d0ab93e9e51bb05a37bd29c3" ], - "version": "==4.6.9" + "version": "==4.6.10" }, "ldap3": { "hashes": [ @@ -479,11 +479,11 @@ }, "marshmallow": { "hashes": [ - "sha256:c2673233aa21dde264b84349dc2fd1dce5f30ed724a0a00e75426734de5b84ab", - "sha256:f88fe96434b1f0f476d54224d59333eba8ca1a203a2695683c1855675c4049a7" + "sha256:35ee2fb188f0bd9fc1cf9ac35e45fd394bd1c153cee430745a465ea435514bd5", + "sha256:9aa20f9b71c992b4782dad07c51d92884fd0f7c5cb9d3c737bea17ec1bad765f" ], "index": "pypi", - "version": "==3.6.0" + "version": "==3.6.1" }, "marshmallow-enum": { "hashes": [ @@ -503,29 +503,29 @@ }, "numpy": { "hashes": [ - "sha256:00d7b54c025601e28f468953d065b9b121ddca7fff30bed7be082d3656dd798d", - "sha256:02ec9582808c4e48be4e93cd629c855e644882faf704bc2bd6bbf58c08a2a897", - "sha256:0e6f72f7bb08f2f350ed4408bb7acdc0daba637e73bce9f5ea2b207039f3af88", - "sha256:1be2e96314a66f5f1ce7764274327fd4fb9da58584eaff00b5a5221edefee7d6", - "sha256:2466fbcf23711ebc5daa61d28ced319a6159b260a18839993d871096d66b93f7", - "sha256:2b573fcf6f9863ce746e4ad00ac18a948978bb3781cffa4305134d31801f3e26", - "sha256:3f0dae97e1126f529ebb66f3c63514a0f72a177b90d56e4bce8a0b5def34627a", - "sha256:50fb72bcbc2cf11e066579cb53c4ca8ac0227abb512b6cbc1faa02d1595a2a5d", - "sha256:57aea170fb23b1fd54fa537359d90d383d9bf5937ee54ae8045a723caa5e0961", - "sha256:709c2999b6bd36cdaf85cf888d8512da7433529f14a3689d6e37ab5242e7add5", - "sha256:7d59f21e43bbfd9a10953a7e26b35b6849d888fc5a331fa84a2d9c37bd9fe2a2", - "sha256:904b513ab8fbcbdb062bed1ce2f794ab20208a1b01ce9bd90776c6c7e7257032", - "sha256:96dd36f5cdde152fd6977d1bbc0f0561bccffecfde63cd397c8e6033eb66baba", - "sha256:9933b81fecbe935e6a7dc89cbd2b99fea1bf362f2790daf9422a7bb1dc3c3085", - "sha256:bbcc85aaf4cd84ba057decaead058f43191cc0e30d6bc5d44fe336dc3d3f4509", - "sha256:dccd380d8e025c867ddcb2f84b439722cf1f23f3a319381eac45fd077dee7170", - "sha256:e22cd0f72fc931d6abc69dc7764484ee20c6a60b0d0fee9ce0426029b1c1bdae", - "sha256:ed722aefb0ebffd10b32e67f48e8ac4c5c4cf5d3a785024fdf0e9eb17529cd9d", - "sha256:efb7ac5572c9a57159cf92c508aad9f856f1cb8e8302d7fdb99061dbe52d712c", - "sha256:efdba339fffb0e80fcc19524e4fdbda2e2b5772ea46720c44eaac28096d60720", - "sha256:f22273dd6a403ed870207b853a856ff6327d5cbce7a835dfa0645b3fc00273ec" + "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" ], - "version": "==1.18.4" + "version": "==1.18.5" }, "openapi-spec-validator": { "hashes": [ @@ -917,11 +917,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f", - "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e" + "sha256:0505dd08068cfec00f53a74a0ad927676d7757da81b7436a6eefe4c7cf75c545", + "sha256:15ec6c0fd909e893e3a08b3a7c76ecb149122fb14b7efe1199ddd4c7c57ea958" ], "markers": "python_version < '3.8'", - "version": "==1.6.0" + "version": "==1.6.1" }, "more-itertools": { "hashes": [ @@ -968,11 +968,11 @@ }, "pytest": { "hashes": [ - "sha256:95c710d0a72d91c13fae35dce195633c929c3792f54125919847fdcdf7caa0d3", - "sha256:eb2b5e935f6a019317e455b6da83dd8650ac9ffd2ee73a7b657a30873d67a698" + "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1", + "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8" ], "index": "pypi", - "version": "==5.4.2" + "version": "==5.4.3" }, "six": { "hashes": [ @@ -983,10 +983,10 @@ }, "wcwidth": { "hashes": [ - "sha256:3de2e41158cb650b91f9654cbf9a3e053cee0719c9df4ddc11e4b568669e9829", - "sha256:b651b6b081476420e4e9ae61239ac4c1b49d0c5ace42b2e81dc2ff49ed50c566" + "sha256:980fbf4f3c196c0f329cdcd1e84c554d6a211f18e252e525a0cf4223154a41d6", + "sha256:edbc2b718b4db6cdf393eefe3a420183947d6aa312505ce6754516f458ff8830" ], - "version": "==0.2.2" + "version": "==0.2.3" }, "zipp": { "hashes": [ diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index 2ffd2093..d05d4e16 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -27,7 +27,7 @@ class ApprovalService(object): if not include_cancelled: query=query.filter(ApprovalModel.status != ApprovalStatus.CANCELED.value) - approvals = query.all() ## all non-cancelled approvals. + approvals = query.all() # All non-cancelled approvals. study_approval_status = "" # IF THIS IS RELATED TO THE CURRENT USER @@ -42,18 +42,19 @@ class ApprovalService(object): main_approval = related_approvals[0] related_approvals = related_approvals[1:] - if(main_approval): # May be null if the study has no approvals. - main_approval.status = ApprovalService.__calculate_overall_approval_status(main_approval) - if len(related_approvals) > 0: main_approval.related_approvals = related_approvals + + if main_approval is not None: # May be null if the study has no approvals. + main_approval.status = ApprovalService.__calculate_overall_approval_status(main_approval) + return main_approval @staticmethod def __calculate_overall_approval_status(approval): # In the case of pending approvals, check to see if there is a related approval # that proceeds this approval - and if it is declined, or still pending, then change - # the state of the approval to be Delcined, or Waiting respectively. + # the state of the approval to be Declined, or Waiting respectively. if approval.status == ApprovalStatus.PENDING.value: for ra in approval.related_approvals: if ra.id < approval.id: From 6861991d8f7828e4eb5cef58b417ff210e52c182 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Fri, 5 Jun 2020 17:49:55 -0400 Subject: [PATCH 60/76] Allow setting the type of approvals you want back, by status. Some very minor performance enhancements, that will add up on the Approvers page. --- config/default.py | 2 +- crc/__init__.py | 6 ++++++ crc/api.yml | 6 +++--- crc/api/approval.py | 14 ++++++------- crc/models/approval.py | 2 +- crc/services/approval_service.py | 35 ++++++++++++++++---------------- crc/services/ldap_service.py | 4 +++- tests/test_approvals_api.py | 11 +++++----- 8 files changed, 43 insertions(+), 37 deletions(-) diff --git a/config/default.py b/config/default.py index 289c506f..269e2cb3 100644 --- a/config/default.py +++ b/config/default.py @@ -42,6 +42,6 @@ PB_REQUIRED_DOCS_URL = environ.get('PB_REQUIRED_DOCS_URL', default=PB_BASE_URL + PB_STUDY_DETAILS_URL = environ.get('PB_STUDY_DETAILS_URL', default=PB_BASE_URL + "study?studyid=%i") LDAP_URL = environ.get('LDAP_URL', default="ldap.virginia.edu").strip('/') # No trailing slash or http:// -LDAP_TIMEOUT_SEC = int(environ.get('LDAP_TIMEOUT_SEC', default=3)) +LDAP_TIMEOUT_SEC = int(environ.get('LDAP_TIMEOUT_SEC', default=1)) diff --git a/crc/__init__.py b/crc/__init__.py index e705321b..309e3f19 100644 --- a/crc/__init__.py +++ b/crc/__init__.py @@ -73,3 +73,9 @@ def load_example_rrt_data(): from example_data import ExampleDataLoader ExampleDataLoader.clean_db() ExampleDataLoader().load_rrt() + +@app.cli.command() +def clear_db(): + """Load example data into the database.""" + from example_data import ExampleDataLoader + ExampleDataLoader.clean_db() diff --git a/crc/api.yml b/crc/api.yml index b4e45a95..638eb787 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -808,12 +808,12 @@ paths: $ref: "#/components/schemas/Script" /approval: parameters: - - name: everything + - name: status in: query required: false - description: If set to true, returns all the approvals known to the system. + description: If set to true, returns all the approvals with any status. schema: - type: boolean + type: string - name: as_user in: query required: false diff --git a/crc/api/approval.py b/crc/api/approval.py index 9cd0399c..b23315df 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -13,13 +13,13 @@ from crc.services.approval_service import ApprovalService from crc.services.ldap_service import LdapService -def get_approvals(everything=False, as_user=None): - if everything: - approvals = ApprovalService.get_all_approvals(include_cancelled=True) - elif as_user: - approvals = ApprovalService.get_all_approvals(as_user, include_cancelled=False) - else: - approvals = ApprovalService.get_approvals_per_user(g.user.uid, include_cancelled=False) +def get_approvals(status=None, as_user=None): + #status = ApprovalStatus.PENDING.value + user = g.user.uid + if as_user: + user = as_user + approvals = ApprovalService.get_approvals_per_user(user, status, + include_cancelled=False) results = ApprovalSchema(many=True).dump(approvals) return results diff --git a/crc/models/approval.py b/crc/models/approval.py index c60bef1f..0592fbd1 100644 --- a/crc/models/approval.py +++ b/crc/models/approval.py @@ -84,13 +84,13 @@ class Approval(object): instance.associated_files = [] for approval_file in model.approval_files: try: + # fixme: This is slow because we are doing a ton of queries to find the irb code. extra_info = doc_dictionary[approval_file.file_data.file_model.irb_doc_code] except: extra_info = None associated_file = {} associated_file['id'] = approval_file.file_data.file_model.id if extra_info: - irb_doc_code = approval_file.file_data.file_model.irb_doc_code associated_file['name'] = '_'.join((extra_info['category1'], approval_file.file_data.file_model.name)) associated_file['description'] = extra_info['description'] diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index d05d4e16..5022d1f0 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -9,54 +9,52 @@ from crc.models.approval import ApprovalModel, ApprovalStatus, ApprovalFile, App from crc.models.study import StudyModel from crc.models.workflow import WorkflowModel from crc.services.file_service import FileService -from crc.services.ldap_service import LdapService - class ApprovalService(object): """Provides common tools for working with an Approval""" @staticmethod - def __one_approval_from_study(study, approver_uid = None, include_cancelled=True): + def __one_approval_from_study(study, approver_uid = None, status=None, + include_cancelled=True): """Returns one approval, with all additional approvals as 'related_approvals', the main approval can be pinned to an approver with an optional argument. Will return null if no approvals exist on the study.""" main_approval = None related_approvals = [] - query = db.session.query(ApprovalModel).\ - filter(ApprovalModel.study_id == study.id) + query = db.session.query(ApprovalModel).filter(ApprovalModel.study_id == study.id) if not include_cancelled: query=query.filter(ApprovalModel.status != ApprovalStatus.CANCELED.value) - approvals = query.all() # All non-cancelled approvals. - study_approval_status = "" - # IF THIS IS RELATED TO THE CURRENT USER for approval_model in approvals: if approval_model.approver_uid == approver_uid: - main_approval = Approval.from_model(approval_model) + main_approval = approval_model else: - related_approvals.append(Approval.from_model(approval_model)) + related_approvals.append(approval_model) # IF WE ARE JUST RETURNING ALL OF THE APPROVALS PER STUDY if not main_approval and len(related_approvals) > 0: main_approval = related_approvals[0] related_approvals = related_approvals[1:] - if len(related_approvals) > 0: - main_approval.related_approvals = related_approvals - if main_approval is not None: # May be null if the study has no approvals. - main_approval.status = ApprovalService.__calculate_overall_approval_status(main_approval) + final_status = ApprovalService.__calculate_overall_approval_status(main_approval, related_approvals) + if status and final_status != status: return # Now that we are certain of the status, filter on it. + + main_approval = Approval.from_model(main_approval) + main_approval.status = final_status + for ra in related_approvals: + main_approval.related_approvals.append(Approval.from_model(ra)) return main_approval @staticmethod - def __calculate_overall_approval_status(approval): + def __calculate_overall_approval_status(approval, related): # In the case of pending approvals, check to see if there is a related approval # that proceeds this approval - and if it is declined, or still pending, then change # the state of the approval to be Declined, or Waiting respectively. if approval.status == ApprovalStatus.PENDING.value: - for ra in approval.related_approvals: + for ra in related: if ra.id < approval.id: if ra.status == ApprovalStatus.DECLINED.value or ra.status == ApprovalStatus.CANCELED.value: return ra.status # If any prior approval id declined or cancelled so is this approval. @@ -67,14 +65,15 @@ class ApprovalService(object): return approval.status @staticmethod - def get_approvals_per_user(approver_uid, include_cancelled=False): + def get_approvals_per_user(approver_uid, status=None, include_cancelled=False): """Returns a list of approval objects (not db models) for the given approver. """ studies = db.session.query(StudyModel).join(ApprovalModel).\ filter(ApprovalModel.approver_uid == approver_uid).all() approvals = [] for study in studies: - approval = ApprovalService.__one_approval_from_study(study, approver_uid, include_cancelled) + approval = ApprovalService.__one_approval_from_study(study, approver_uid, + status, include_cancelled) if approval: approvals.append(approval) return approvals diff --git a/crc/services/ldap_service.py b/crc/services/ldap_service.py index 61097921..fea99af0 100644 --- a/crc/services/ldap_service.py +++ b/crc/services/ldap_service.py @@ -18,7 +18,7 @@ class LdapService(object): user_or_last_name_search = "(&(objectclass=person)(|(uid=%s*)(sn=%s*)))" cn_single_search = '(&(objectclass=person)(cn=%s*))' cn_double_search = '(&(objectclass=person)(&(cn=%s*)(cn=*%s*)))' - + temp_cache = {} conn = None @staticmethod @@ -43,6 +43,7 @@ class LdapService(object): def user_info(uva_uid): user_info = db.session.query(LdapModel).filter(LdapModel.uid == uva_uid).first() if not user_info: + app.logger.info("No cache for " + uva_uid) search_string = LdapService.uid_search_string % uva_uid conn = LdapService.__get_conn() conn.search(LdapService.search_base, search_string, attributes=LdapService.attributes) @@ -51,6 +52,7 @@ class LdapService(object): entry = conn.entries[0] user_info = LdapModel.from_entry(entry) db.session.add(user_info) + db.session.commit() return user_info @staticmethod diff --git a/tests/test_approvals_api.py b/tests/test_approvals_api.py index 6d95be39..b80eaef0 100644 --- a/tests/test_approvals_api.py +++ b/tests/test_approvals_api.py @@ -68,16 +68,15 @@ class TestApprovals(BaseTest): approval = response[0] self.assertEqual(approval['approver']['uid'], approver_uid) - def test_list_approvals_per_admin(self): - """All approvals will be returned""" - rv = self.app.get('/v1.0/approval?everything=true', headers=self.logged_in_headers()) + def test_list_approvals_as_user(self): + """All approvals as different user""" + rv = self.app.get('/v1.0/approval?as_user=lb3dp', headers=self.logged_in_headers()) self.assert_success(rv) response = json.loads(rv.get_data(as_text=True)) - # Returned approvals should match what's in the db, we should get one approval back - # per study (2 studies), and that approval should have one related approval. - approvals_count = ApprovalModel.query.count() + # Returned approvals should match what's in the db for user ld3dp, we should get one + # approval back per study (2 studies), and that approval should have one related approval. response_count = len(response) self.assertEqual(2, response_count) From 1d3f98b381730fdd3db4bca80dd1aba027dbd1d0 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 5 Jun 2020 22:19:37 -0400 Subject: [PATCH 61/76] Adds endpoint to quickly get counts of approvals in each status group for a user --- crc/api.yml | 46 ++++++++++++++++++++++++++++++++++++- crc/api/approval.py | 55 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/crc/api.yml b/crc/api.yml index 638eb787..3a0875cc 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -806,12 +806,34 @@ paths: type: array items: $ref: "#/components/schemas/Script" + /approval-counts: + parameters: + - name: as_user + in: query + required: false + description: If provided, returns the approval counts for that user. + schema: + type: string + get: + operationId: crc.api.approval.get_approval_counts + summary: Provides counts for approvals by status for the given user, or all users if no user is provided + tags: + - Approvals + responses: + '200': + description: An dictionary of Approval Statuses and the counts for each + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ApprovalCounts" /approval: parameters: - name: status in: query required: false - description: If set to true, returns all the approvals with any status. + description: If provided, returns just approvals for the given status. schema: type: string - name: as_user @@ -1286,4 +1308,26 @@ components: type: number format: integer example: 5 + ApprovalCounts: + properties: + PENDING: + type: number + format: integer + example: 5 + APPROVED: + type: number + format: integer + example: 5 + DECLINED: + type: number + format: integer + example: 5 + CANCELED: + type: number + format: integer + example: 5 + AWAITING: + type: number + format: integer + example: 5 diff --git a/crc/api/approval.py b/crc/api/approval.py index b23315df..bc20c624 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -13,6 +13,56 @@ from crc.services.approval_service import ApprovalService from crc.services.ldap_service import LdapService +# Returns counts of approvals in each status group assigned to the given user. +# The goal is to return results as quickly as possible. +def get_approval_counts(as_user=None): + uid = as_user or g.user.uid + + db_user_approvals = db.session.query(ApprovalModel)\ + .filter_by(approver_uid=uid)\ + .filter(ApprovalModel.status != ApprovalStatus.CANCELED.name)\ + .all() + + study_ids = [a.study_id for a in db_user_approvals] + print('study_ids', study_ids) + + db_other_approvals = db.session.query(ApprovalModel)\ + .filter(ApprovalModel.study_id.in_(study_ids))\ + .filter(ApprovalModel.approver_uid != uid)\ + .filter(ApprovalModel.status != ApprovalStatus.CANCELED.name)\ + .all() + + # Make a dict of the other approvals where the key is the study id and the value is the approval + # TODO: This won't work if there are more than 2 approvals with the same study_id + other_approvals = {} + for approval in db_other_approvals: + other_approvals[approval.study_id] = approval + + counts = {} + for status in ApprovalStatus: + counts[status.name] = 0 + + for approval in db_user_approvals: + # Check if another approval has the same study id + if approval.study_id in other_approvals: + other_approval = other_approvals[approval.study_id] + + # Other approval takes precedence over this one + if other_approval.id < approval.id: + if other_approval.status == ApprovalStatus.PENDING.name: + counts[ApprovalStatus.AWAITING.name] += 1 + elif other_approval.status == ApprovalStatus.DECLINED.name: + counts[ApprovalStatus.DECLINED.name] += 1 + elif other_approval.status == ApprovalStatus.CANCELED.name: + counts[ApprovalStatus.CANCELED.name] += 1 + elif other_approval.status == ApprovalStatus.APPROVED.name: + counts[approval.status] += 1 + else: + counts[approval.status] += 1 + + return counts + + def get_approvals(status=None, as_user=None): #status = ApprovalStatus.PENDING.value user = g.user.uid @@ -31,7 +81,7 @@ def get_approvals_for_study(study_id=None): return results -# ----- Being decent into madness ---- # +# ----- 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 man to do just about anything""" @@ -81,12 +131,14 @@ def get_csv(): 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 @@ -96,6 +148,7 @@ def find_task(uuid, task): return task # ----- come back to the world of the living ---- # + def update_approval(approval_id, body): if approval_id is None: raise ApiError('unknown_approval', 'Please provide a valid Approval ID.') From 049424d01b6681a022f7ce607ff733ba9881d0bb Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sun, 7 Jun 2020 22:38:21 -0400 Subject: [PATCH 62/76] Adds back an endpoint that returns all approvals across all statuses or users --- crc/api.yml | 22 ++++++++++++++++++++++ crc/api/approval.py | 6 ++++++ 2 files changed, 28 insertions(+) diff --git a/crc/api.yml b/crc/api.yml index 3a0875cc..20cd34dd 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -828,6 +828,28 @@ paths: type: array items: $ref: "#/components/schemas/ApprovalCounts" + /all_approvals: + parameters: + - name: status + in: query + required: false + description: If set to true, returns all the approvals with any status. Defaults to false, leaving out canceled approvals. + schema: + type: boolean + get: + operationId: crc.api.approval.get_all_approvals + summary: Provides a list of all workflows approvals + tags: + - Approvals + responses: + '200': + description: An array of approvals + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Approval" /approval: parameters: - name: status diff --git a/crc/api/approval.py b/crc/api/approval.py index bc20c624..0d5c24ca 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -63,6 +63,12 @@ def get_approval_counts(as_user=None): return counts +def get_all_approvals(status=None): + approvals = ApprovalService.get_all_approvals(include_cancelled=status is True) + results = ApprovalSchema(many=True).dump(approvals) + return results + + def get_approvals(status=None, as_user=None): #status = ApprovalStatus.PENDING.value user = g.user.uid From f91fbf76b9f9d8e4756733e4857b70ffbb89af70 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 8 Jun 2020 09:16:26 -0600 Subject: [PATCH 63/76] Capturing explicit errors from mails --- crc/services/approval_service.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crc/services/approval_service.py b/crc/services/approval_service.py index 8f9d45dd..833ae454 100644 --- a/crc/services/approval_service.py +++ b/crc/services/approval_service.py @@ -128,21 +128,25 @@ class ApprovalService(object): pi_user_info = ldap_service.user_info(model.study.primary_investigator_id) approver_info = ldap_service.user_info(approver_uid) # send rrp submission - send_ramp_up_approved_email( + mail_result = send_ramp_up_approved_email( 'askresearch@virginia.edu', [pi_user_info.email_address], f'{approver_info.display_name} - ({approver_info.uid})' ) + if mail_result: + app.logger.error(mail_result) elif status == ApprovalStatus.DECLINED.value: ldap_service = LdapService() pi_user_info = ldap_service.user_info(model.study.primary_investigator_id) approver_info = ldap_service.user_info(approver_uid) # send rrp submission - send_ramp_up_denied_email( + mail_result = send_ramp_up_denied_email( 'askresearch@virginia.edu', [pi_user_info.email_address], f'{approver_info.display_name} - ({approver_info.uid})' ) + if mail_result: + app.logger.error(mail_result) 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() @@ -151,12 +155,14 @@ class ApprovalService(object): first_approver_info = ldap_service.user_info(first_approval.approver_uid) approver_email = [first_approver_info.email_address] if first_approver_info.email_address else app.config['FALLBACK_EMAILS'] # send rrp denied by second approver email to first approver - send_ramp_up_denied_email_to_approver( + mail_result = send_ramp_up_denied_email_to_approver( 'askresearch@virginia.edu', approver_email, f'{pi_user_info.display_name} - ({pi_user_info.uid})', f'{approver_info.display_name} - ({approver_info.uid})' ) + if mail_result: + app.logger.error(mail_result) # TODO: Log update action by approver_uid - maybe ? return db_approval @@ -221,19 +227,23 @@ class ApprovalService(object): pi_user_info = ldap_service.user_info(model.study.primary_investigator_id) approver_info = ldap_service.user_info(approver_uid) # send rrp submission - send_ramp_up_submission_email( + mail_result = send_ramp_up_submission_email( 'askresearch@virginia.edu', [pi_user_info.email_address], f'{approver_info.display_name} - ({approver_info.uid})' ) + if mail_result: + app.logger.error(mail_result) # 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'] - send_ramp_up_approval_request_first_review_email( + mail_result = send_ramp_up_approval_request_first_review_email( 'askresearch@virginia.edu', approver_email, f'{pi_user_info.display_name} - ({pi_user_info.uid})' ) + if mail_result: + app.logger.error(mail_result) @staticmethod def _create_approval_files(workflow_data_files, approval): From 0351b3548a8149b42dedbb9acf703006d5139b5d Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 8 Jun 2020 11:17:17 -0600 Subject: [PATCH 64/76] Adding bcc to all emails sent --- crc/services/mails.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crc/services/mails.py b/crc/services/mails.py index d6de3ff6..68ef105d 100644 --- a/crc/services/mails.py +++ b/crc/services/mails.py @@ -10,7 +10,8 @@ def send_ramp_up_submission_email(sender, recipients, approver_1, approver_2=Non try: msg = Message('Research Ramp-up Plan Submitted', sender=sender, - recipients=recipients) + recipients=recipients, + bcc=['rrt_emails@googlegroups.com']) from crc import env, mail template = env.get_template('ramp_up_submission.txt') @@ -27,7 +28,8 @@ def send_ramp_up_approval_request_email(sender, recipients, primary_investigator try: msg = Message('Research Ramp-up Plan Approval Request', sender=sender, - recipients=recipients) + recipients=recipients, + bcc=['rrt_emails@googlegroups.com']) from crc import env, mail template = env.get_template('ramp_up_approval_request.txt') @@ -44,7 +46,8 @@ def send_ramp_up_approval_request_first_review_email(sender, recipients, primary try: msg = Message('Research Ramp-up Plan Approval Request', 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') @@ -61,7 +64,8 @@ def send_ramp_up_approved_email(sender, recipients, approver_1, approver_2=None) try: msg = Message('Research Ramp-up Plan Approved', sender=sender, - recipients=recipients) + recipients=recipients, + bcc=['rrt_emails@googlegroups.com']) from crc import env, mail template = env.get_template('ramp_up_approved.txt') @@ -78,7 +82,8 @@ def send_ramp_up_denied_email(sender, recipients, approver): try: msg = Message('Research Ramp-up Plan Denied', sender=sender, - recipients=recipients) + recipients=recipients, + bcc=['rrt_emails@googlegroups.com']) from crc import env, mail template = env.get_template('ramp_up_denied.txt') @@ -95,7 +100,8 @@ def send_ramp_up_denied_email_to_approver(sender, recipients, primary_investigat try: msg = Message('Research Ramp-up Plan Denied', sender=sender, - recipients=recipients) + recipients=recipients, + bcc=['rrt_emails@googlegroups.com']) from crc import env, mail template = env.get_template('ramp_up_denied_first_approver.txt') From ed10cc1fa8f88c5bdada5310e0ab3a439ddce710 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 8 Jun 2020 11:59:09 -0600 Subject: [PATCH 65/76] Enabling mail debugging --- config/default.py | 1 + 1 file changed, 1 insertion(+) diff --git a/config/default.py b/config/default.py index b42680ac..58762af0 100644 --- a/config/default.py +++ b/config/default.py @@ -46,3 +46,4 @@ LDAP_TIMEOUT_SEC = int(environ.get('LDAP_TIMEOUT_SEC', default=1)) # Fallback emails FALLBACK_EMAILS = ['askresearch@virginia.edu', 'sartographysupport@googlegroups.com'] +MAIL_DEBUG = True From 8cf420b781950b19c725a3271a608fbc489a31fa Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Mon, 8 Jun 2020 14:15:56 -0400 Subject: [PATCH 66/76] Default mail user name and password to blank. --- config/default.py | 8 +++++++- crc/api.yml | 20 +++++++++++++++++++ crc/api/tools.py | 7 +++++++ crc/models/ldap.py | 2 +- crc/services/mails.py | 16 +++++++++++++++ .../mails/ramp_up_approval_request.html | 3 ++- .../mails/ramp_up_approval_request.txt | 3 ++- ...ramp_up_approval_request_first_review.html | 3 ++- .../ramp_up_approval_request_first_review.txt | 3 ++- 9 files changed, 59 insertions(+), 6 deletions(-) diff --git a/config/default.py b/config/default.py index 58762af0..36b15f0e 100644 --- a/config/default.py +++ b/config/default.py @@ -46,4 +46,10 @@ LDAP_TIMEOUT_SEC = int(environ.get('LDAP_TIMEOUT_SEC', default=1)) # Fallback emails FALLBACK_EMAILS = ['askresearch@virginia.edu', 'sartographysupport@googlegroups.com'] -MAIL_DEBUG = True +MAIL_DEBUG = environ.get('MAIL_DEBUG', default=True) +MAIL_SERVER = environ.get('MAIL_SERVER', default='smtp.mailtrap.io') +MAIL_PORT = environ.get('MAIL_PORT', default=2525) +MAIL_USE_SSL = environ.get('MAIL_USE_SSL', default=False) +MAIL_USE_TLS = environ.get('MAIL_USE_TLS', default=True) +MAIL_USERNAME = environ.get('MAIL_USERNAME', default='') +MAIL_PASSWORD = environ.get('MAIL_PASSWORD', default='') diff --git a/crc/api.yml b/crc/api.yml index 20cd34dd..8ae9d9c6 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -762,6 +762,26 @@ paths: text/plain: schema: type: string + /send_email: + parameters: + - name: address + in: query + required: true + description: The address to send a test email to. + schema: + type: string + get: + operationId: crc.api.tools.send_email + summary: Sends an email so we can see if things work or not. + tags: + - Configurator Tools + responses: + '201': + description: Returns any error messages that might come back from sending the email. + content: + text/plain: + schema: + type: string /render_docx: put: operationId: crc.api.tools.render_docx diff --git a/crc/api/tools.py b/crc/api/tools.py index 4699be5f..d140e962 100644 --- a/crc/api/tools.py +++ b/crc/api/tools.py @@ -9,6 +9,8 @@ from crc.api.common import ApiError from crc.scripts.complete_template import CompleteTemplate from crc.scripts.script import Script import crc.scripts +from crc.services.mails import send_test_email + def render_markdown(data, template): """ @@ -59,3 +61,8 @@ 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]) \ No newline at end of file diff --git a/crc/models/ldap.py b/crc/models/ldap.py index 50c0654a..7e05eccd 100644 --- a/crc/models/ldap.py +++ b/crc/models/ldap.py @@ -1,6 +1,6 @@ from flask_marshmallow.sqla import SQLAlchemyAutoSchema from marshmallow import EXCLUDE -from sqlalchemy import func, inspect +from sqlalchemy import func from crc import db diff --git a/crc/services/mails.py b/crc/services/mails.py index 68ef105d..6303ce8f 100644 --- a/crc/services/mails.py +++ b/crc/services/mails.py @@ -5,6 +5,22 @@ from flask_mail import Message # 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) + from crc import env, mail + template = env.get_template('ramp_up_approval_request_first_review.txt') + template_vars = {'primary_investigator': "test"} + msg.body = template.render(template_vars) + template = env.get_template('ramp_up_approval_request_first_review.html') + msg.html = template.render(template_vars) + mail.send(msg) + except Exception as e: + return str(e) + + def send_ramp_up_submission_email(sender, recipients, approver_1, approver_2=None): try: diff --git a/crc/static/templates/mails/ramp_up_approval_request.html b/crc/static/templates/mails/ramp_up_approval_request.html index e78bc6f9..506fdf16 100644 --- a/crc/static/templates/mails/ramp_up_approval_request.html +++ b/crc/static/templates/mails/ramp_up_approval_request.html @@ -1 +1,2 @@ -

    A Research Ramp-up approval request from {{ primary_investigator }} is now available for your review in your [Research Ramp-up Toolkit](https://rrt.uvadcos.io/app/approvals).

    \ No newline at end of file +

    A Research Ramp-up approval request from {{ primary_investigator }} is now available for your review in your + Research Ramp-up Toolkit]

    \ No newline at end of file diff --git a/crc/static/templates/mails/ramp_up_approval_request.txt b/crc/static/templates/mails/ramp_up_approval_request.txt index 1b7c5a09..53d8e1ef 100644 --- a/crc/static/templates/mails/ramp_up_approval_request.txt +++ b/crc/static/templates/mails/ramp_up_approval_request.txt @@ -1 +1,2 @@ -A Research Ramp-up approval request from {{ primary_investigator }} is now available for your review in your [Research Ramp-up Toolkit](https://rrt.uvadcos.io/app/approvals). \ No newline at end of file +A Research Ramp-up approval request from {{ primary_investigator }} is now available for your review in your +Research Ramp-up Toolkit: https://rrt.uvadcos.io/app/approvals. \ No newline at end of file diff --git a/crc/static/templates/mails/ramp_up_approval_request_first_review.html b/crc/static/templates/mails/ramp_up_approval_request_first_review.html index 6c015fc3..1c7cb3c7 100644 --- a/crc/static/templates/mails/ramp_up_approval_request_first_review.html +++ b/crc/static/templates/mails/ramp_up_approval_request_first_review.html @@ -1 +1,2 @@ -

    A Research Ramp-up approval request from {{ primary_investigator }} and is now available for your review in your [Research Ramp-up Toolkit](https://rrt.uvadcos.io/app/approvals).

    \ No newline at end of file +

    A Research Ramp-up approval request from {{ primary_investigator }} and is now available for your review in your + Research Ramp-up Toolkit.

    \ No newline at end of file diff --git a/crc/static/templates/mails/ramp_up_approval_request_first_review.txt b/crc/static/templates/mails/ramp_up_approval_request_first_review.txt index 1b7c5a09..db6cc50e 100644 --- a/crc/static/templates/mails/ramp_up_approval_request_first_review.txt +++ b/crc/static/templates/mails/ramp_up_approval_request_first_review.txt @@ -1 +1,2 @@ -A Research Ramp-up approval request from {{ primary_investigator }} is now available for your review in your [Research Ramp-up Toolkit](https://rrt.uvadcos.io/app/approvals). \ No newline at end of file +A Research Ramp-up approval request from {{ primary_investigator }} is now available for your review in your +Research Ramp-up Toolkit at https://rrt.uvadcos.io/app/approvals. \ No newline at end of file From cccff9b8567355035fd17e0f0db6a3a3a90a2912 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Thu, 11 Jun 2020 11:29:58 -0400 Subject: [PATCH 67/76] Fixes broken unit tests. But still broken. --- Pipfile.lock | 36 +++++----- config/default.py | 2 +- crc/api.yml | 18 ++--- crc/api/user.py | 56 +++++++++------ crc/models/user.py | 8 +-- tests/base_test.py | 1 - tests/test_authentication.py | 130 ++++++++++++++++++++++++----------- 7 files changed, 156 insertions(+), 95 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index f8ab746b..fb38d03c 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -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": [ @@ -751,11 +751,11 @@ }, "sphinx": { "hashes": [ - "sha256:779a519adbd3a70fc7c468af08c5e74829868b0a5b34587b33340e010291856c", - "sha256:ea64df287958ee5aac46be7ac2b7277305b0381d213728c3a49d8bb9b8415807" + "sha256:1c445320a3310baa5ccb8d957267ef4a0fc930dc1234db5098b3d7af14fbb242", + "sha256:7d3d5087e39ab5a031b75588e9859f011de70e213cd0080ccbc28079fb0786d1" ], "index": "pypi", - "version": "==3.0.4" + "version": "==3.1.0" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -990,10 +990,10 @@ }, "wcwidth": { "hashes": [ - "sha256:980fbf4f3c196c0f329cdcd1e84c554d6a211f18e252e525a0cf4223154a41d6", - "sha256:edbc2b718b4db6cdf393eefe3a420183947d6aa312505ce6754516f458ff8830" + "sha256:79375666b9954d4a1a10739315816324c3e73110af9d0e102d906fdb0aec009f", + "sha256:8c6b5b6ee1360b842645f336d9e5d68c55817c26d3050f46b235ef2bc650e48f" ], - "version": "==0.2.3" + "version": "==0.2.4" }, "zipp": { "hashes": [ diff --git a/config/default.py b/config/default.py index 44e6cb3d..93e4a933 100644 --- a/config/default.py +++ b/config/default.py @@ -29,7 +29,7 @@ SQLALCHEMY_DATABASE_URI = environ.get( 'SQLALCHEMY_DATABASE_URI', default="postgresql://%s:%s@%s:%s/%s" % (DB_USER, DB_PASSWORD, DB_HOST, DB_PORT, DB_NAME) ) -TOKEN_AUTH_TTL_HOURS = int(environ.get('TOKEN_AUTH_TTL_HOURS', default=4)) +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.") 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/crc/api.yml b/crc/api.yml index a5f92212..64f6086a 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -115,7 +115,7 @@ paths: delete: operationId: crc.api.study.delete_study security: - - jwt_admin: ['secret'] + - auth_admin: ['secret'] summary: Removes the given study completely. tags: - Studies @@ -218,7 +218,7 @@ paths: put: operationId: crc.api.workflow.update_workflow_specification security: - - jwt_admin: ['secret'] + - auth_admin: ['secret'] summary: Modifies an existing workflow specification with the given parameters. tags: - Workflow Specifications @@ -237,7 +237,7 @@ paths: delete: operationId: crc.api.workflow.delete_workflow_specification security: - - jwt_admin: ['secret'] + - auth_admin: ['secret'] summary: Removes an existing workflow specification tags: - Workflow Specifications @@ -284,7 +284,7 @@ paths: post: operationId: crc.api.workflow.add_workflow_spec_category security: - - jwt_admin: ['secret'] + - auth_admin: ['secret'] summary: Creates a new workflow spec category with the given parameters. tags: - Workflow Specification Category @@ -323,7 +323,7 @@ paths: put: operationId: crc.api.workflow.update_workflow_spec_category security: - - jwt_admin: ['secret'] + - auth_admin: ['secret'] summary: Modifies an existing workflow spec category with the given parameters. tags: - Workflow Specification Category @@ -342,7 +342,7 @@ paths: delete: operationId: crc.api.workflow.delete_workflow_spec_category security: - - jwt_admin: ['secret'] + - auth_admin: ['secret'] summary: Removes an existing workflow spec category tags: - Workflow Specification Category @@ -543,7 +543,7 @@ paths: put: operationId: crc.api.file.set_reference_file security: - - jwt_admin: ['secret'] + - auth_admin: ['secret'] summary: Update the contents of a named reference file. tags: - Files @@ -603,7 +603,7 @@ paths: delete: operationId: crc.api.workflow.delete_workflow security: - - jwt_admin: ['secret'] + - auth_admin: ['secret'] summary: Removes an existing workflow tags: - Workflows and Tasks @@ -924,7 +924,7 @@ components: scheme: bearer bearerFormat: JWT x-bearerInfoFunc: crc.api.user.verify_token - jwt_admin: + auth_admin: type: http scheme: bearer bearerFormat: JWT diff --git a/crc/api/user.py b/crc/api/user.py index 3cf13c9f..0786fbc9 100644 --- a/crc/api/user.py +++ b/crc/api/user.py @@ -4,8 +4,7 @@ from flask import g, request from crc import app, db from crc.api.common import ApiError from crc.models.user import UserModel, UserModelSchema -from crc.services.ldap_service import LdapService, LdapModel, LdapUserInfo -from crc.services.approval_service import ApprovalService +from crc.services.ldap_service import LdapService, LdapModel """ .. module:: crc.api.user @@ -31,7 +30,8 @@ def verify_token(token=None): print('=== verify_token ===') print('_is_production()', _is_production()) - failure_error = ApiError("invalid_token", "Unable to decode the token you provided. Please re-authenticate", status_code=403) + failure_error = ApiError("invalid_token", "Unable to decode the token you provided. Please re-authenticate", + status_code=403) if not _is_production(): g.user = UserModel.query.first() @@ -62,7 +62,8 @@ def verify_token(token=None): return token_info else: - ApiError("no_user", "User not found. Please login via the frontend app before accessing this feature.", status_code=403) + ApiError("no_user", "User not found. Please login via the frontend app before accessing this feature.", + status_code=403) raise failure_error @@ -81,7 +82,6 @@ def verify_token_admin(token=None): print('=== verify_token_admin ===') print('_is_production()', _is_production()) - # If this is production, check that the user is in the list of admins if _is_production(): uid = _get_request_uid(request) @@ -101,8 +101,8 @@ def get_current_user(): def login( - uid=None, - redirect_url=None, + uid=None, + redirect_url=None, ): """ In non-production environment, provides an endpoint for end-to-end system testing that allows the system @@ -175,7 +175,7 @@ def sso(): return response -def _handle_login(user_info: LdapUserInfo, redirect_url=None): +def _handle_login(user_info: LdapModel, redirect_url=None): """ On successful login, adds user to database if the user is not already in the system, then returns the frontend auth callback URL, with auth token appended. @@ -187,22 +187,10 @@ def _handle_login(user_info: LdapUserInfo, redirect_url=None): Returns: Response. 302 - Redirects to the frontend auth callback URL, with auth token appended. """ + print('=== _handle_login ===') print('user_info', user_info) - user = db.session.query(UserModel).filter(UserModel.uid == user_info.uid).first() - - if user is None: - # Add new user - user = UserModel() - - user.uid = user_info.uid - user.display_name = user_info.display_name - user.email_address = user_info.email_address - user.affiliation = user_info.affiliation - user.title = user_info.title - - db.session.add(user) - db.session.commit() + user = _upsert_user(user_info) # Return the frontend auth callback URL, with auth token appended. auth_token = user.encode_auth_token().decode() @@ -217,11 +205,35 @@ def _handle_login(user_info: LdapUserInfo, redirect_url=None): return auth_token +def _upsert_user(user_info): + user = db.session.query(UserModel).filter(UserModel.uid == user_info.uid).first() + + if user is None: + # Add new user + user = UserModel() + else: + user = db.session.query(UserModel).filter(UserModel.uid == user_info.uid).with_for_update().first() + + user.uid = user_info.uid + user.display_name = user_info.display_name + user.email_address = user_info.email_address + user.affiliation = user_info.affiliation + user.title = user_info.title + + db.session.add(user) + db.session.commit() + return user + + def _get_request_uid(req): uid = None if _is_production(): + if 'user' in g and g.user is not None: + print('g.user.uid', g.user.uid) + return g.user.uid + print('req.headers', req.headers) uid = req.headers.get("Uid") if not uid: diff --git a/crc/models/user.py b/crc/models/user.py index d9ee8f72..67e85967 100644 --- a/crc/models/user.py +++ b/crc/models/user.py @@ -27,7 +27,7 @@ class UserModel(db.Model): Generates the Auth Token :return: string """ - hours = int(app.config['TOKEN_AUTH_TTL_HOURS']) + hours = float(app.config['TOKEN_AUTH_TTL_HOURS']) payload = { 'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=hours, minutes=0, seconds=0), 'iat': datetime.datetime.utcnow(), @@ -36,7 +36,7 @@ class UserModel(db.Model): return jwt.encode( payload, app.config.get('TOKEN_AUTH_SECRET_KEY'), - algorithm='HS256' + algorithm='HS256', ) @staticmethod @@ -50,9 +50,9 @@ class UserModel(db.Model): payload = jwt.decode(auth_token, app.config.get('TOKEN_AUTH_SECRET_KEY'), algorithms='HS256') return payload except jwt.ExpiredSignatureError: - raise ApiError('token_expired', 'The Authentication token you provided expired, and must be renewed.') + raise ApiError('token_expired', 'The Authentication token you provided expired and must be renewed.') except jwt.InvalidTokenError: - raise ApiError('token_invalid', 'The Authentication token you provided. You need a new token. ') + raise ApiError('token_invalid', 'The Authentication token you provided is invalid. You need a new token. ') class UserModelSchema(SQLAlchemyAutoSchema): diff --git a/tests/base_test.py b/tests/base_test.py index 07a1bd37..5c9e7dc1 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -95,7 +95,6 @@ class BaseTest(unittest.TestCase): def tearDown(self): ExampleDataLoader.clean_db() - session.flush() self.auths = {} def logged_in_headers(self, user=None, redirect_url='http://some/frontend/url'): diff --git a/tests/test_authentication.py b/tests/test_authentication.py index ff720b57..d18614df 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -1,9 +1,10 @@ import json -from datetime import timezone, datetime +from calendar import timegm +from datetime import timezone, datetime, timedelta from tests.base_test import BaseTest from crc import db, app -from crc.models.study import StudySchema +from crc.models.study import StudySchema, StudyModel from crc.models.user import UserModel from crc.models.protocol_builder import ProtocolBuilderStatus @@ -16,11 +17,29 @@ class TestAuthentication(BaseTest): super().tearDown() def test_auth_token(self): + # Save the orginal timeout setting + orig_ttl = float(app.config['TOKEN_AUTH_TTL_HOURS']) + self.load_example_data() + + # Set the timeout to something else + new_ttl = 4.0 + app.config['TOKEN_AUTH_TTL_HOURS'] = new_ttl user = UserModel(uid="dhf8r") - auth_token = user.encode_auth_token() - self.assertTrue(isinstance(auth_token, bytes)) - self.assertEqual("dhf8r", user.decode_auth_token(auth_token).get("sub")) + expected_exp_1 = timegm((datetime.utcnow() + timedelta(hours=new_ttl)).utctimetuple()) + auth_token_1 = user.encode_auth_token() + self.assertTrue(isinstance(auth_token_1, bytes)) + self.assertEqual("dhf8r", user.decode_auth_token(auth_token_1).get("sub")) + actual_exp_1 = user.decode_auth_token(auth_token_1).get("exp") + self.assertTrue(expected_exp_1 - 1000 <= actual_exp_1 <= expected_exp_1 + 1000) + + # Set the timeout back to where it was + app.config['TOKEN_AUTH_TTL_HOURS'] = orig_ttl + expected_exp_2 = timegm((datetime.utcnow() + timedelta(hours=new_ttl)).utctimetuple()) + auth_token_2 = user.encode_auth_token() + self.assertTrue(isinstance(auth_token_2, bytes)) + actual_exp_2 = user.decode_auth_token(auth_token_1).get("exp") + self.assertTrue(expected_exp_2 - 1000 <= actual_exp_2 <= expected_exp_2 + 1000) def test_non_production_auth_creates_user(self): new_uid = 'lb3dp' ## Assure this user id is in the fake responses from ldap. @@ -48,12 +67,14 @@ class TestAuthentication(BaseTest): self.assertTrue(str.startswith(rv_2.location, redirect_url)) def test_production_auth_creates_user(self): + # Switch production mode on app.config['PRODUCTION'] = True - new_uid = 'lb3dp' # This user is in the test ldap system. self.load_example_data() - user = db.session.query(UserModel).filter(UserModel.uid == new_uid).first() + + new_uid = 'lb3dp' # This user is in the test ldap system. + user = db.session.query(UserModel).filter_by(uid=new_uid).first() self.assertIsNone(user) redirect_url = 'http://worlds.best.website/admin' headers = dict(Uid=new_uid) @@ -61,7 +82,7 @@ class TestAuthentication(BaseTest): rv = self.app.get('v1.0/login', follow_redirects=False, headers=headers) self.assert_success(rv) - user = db.session.query(UserModel).filter(UserModel.uid == new_uid).first() + user = db.session.query(UserModel).filter_by(uid=new_uid).first() self.assertIsNotNone(user) self.assertEqual(new_uid, user.uid) self.assertEqual("Laura Barnes", user.display_name) @@ -70,6 +91,14 @@ class TestAuthentication(BaseTest): # Switch production mode back off app.config['PRODUCTION'] = False + db.session.flush() + db.session.flush() + db.session.flush() + db.session.flush() + db.session.flush() + db.session.flush() + db.session.flush() + db.session.flush() def test_current_user_status(self): self.load_example_data() @@ -84,49 +113,65 @@ class TestAuthentication(BaseTest): rv = self.app.get('/v1.0/user', headers=self.logged_in_headers(user, redirect_url='http://omg.edu/lolwut')) self.assert_success(rv) - def test_admin_only_endpoints(self): + def test_admin_can_access_admin_only_endpoints(self): + # Switch production mode on app.config['PRODUCTION'] = True + self.load_example_data() admin_uids = app.config['ADMIN_UIDS'] self.assertGreater(len(admin_uids), 0) + admin_uid = admin_uids[0] + self.assertEqual(admin_uid, 'dhf8r') # This user is in the test ldap system. + admin_headers = dict(Uid=admin_uid) - for uid in admin_uids: - admin_headers = dict(Uid=uid) + rv = self.app.get('v1.0/login', follow_redirects=False, headers=admin_headers) + self.assert_success(rv) - rv = self.app.get( - 'v1.0/login', - follow_redirects=False, - headers=admin_headers - ) - self.assert_success(rv) + admin_user = db.session.query(UserModel).filter(UserModel.uid == admin_uid).first() + self.assertIsNotNone(admin_user) + self.assertEqual(admin_uid, admin_user.uid) - admin_user = db.session.query(UserModel).filter_by(uid=uid).first() - self.assertIsNotNone(admin_user) + admin_study = self._make_fake_study(admin_uid) - admin_study = self._make_fake_study(uid) + admin_token_headers = dict(Authorization='Bearer ' + admin_user.encode_auth_token().decode()) - rv_add_study = self.app.post( - '/v1.0/study', - content_type="application/json", - headers=admin_headers, - data=json.dumps(StudySchema().dump(admin_study)) - ) - self.assert_success(rv_add_study, 'Admin user should be able to add a study') + rv_add_study = self.app.post( + '/v1.0/study', + content_type="application/json", + headers=admin_token_headers, + data=json.dumps(StudySchema().dump(admin_study)), + follow_redirects=False + ) + self.assert_success(rv_add_study, 'Admin user should be able to add a study') - new_study = json.loads(rv.get_data(as_text=True)) + new_admin_study = json.loads(rv_add_study.get_data(as_text=True)) + db_admin_study = db.session.query(StudyModel).filter_by(id=new_admin_study['id']).first() + self.assertIsNotNone(db_admin_study) - rv_del_study = self.app.delete( - '/v1.0/study/%i' % new_study.id, - follow_redirects=False, - headers=admin_headers - ) - self.assert_success(rv_del_study, 'Admin user should be able to delete a study') + rv_del_study = self.app.delete( + '/v1.0/study/%i' % db_admin_study.id, + follow_redirects=False, + headers=admin_token_headers + ) + self.assert_success(rv_del_study, 'Admin user should be able to delete a study') + # Switch production mode back off + app.config['PRODUCTION'] = False + + def test_nonadmin_cannot_access_admin_only_endpoints(self): + # Switch production mode on + app.config['PRODUCTION'] = True + + self.load_example_data() # Non-admin user should not be able to delete a study non_admin_uid = 'lb3dp' + admin_uids = app.config['ADMIN_UIDS'] + self.assertGreater(len(admin_uids), 0) + self.assertNotIn(non_admin_uid, admin_uids) + non_admin_headers = dict(Uid=non_admin_uid) rv = self.app.get( @@ -138,24 +183,29 @@ class TestAuthentication(BaseTest): non_admin_user = db.session.query(UserModel).filter_by(uid=non_admin_uid).first() self.assertIsNotNone(non_admin_user) + + non_admin_token_headers = dict(Authorization='Bearer ' + non_admin_user.encode_auth_token().decode()) + non_admin_study = self._make_fake_study(non_admin_uid) rv_add_study = self.app.post( '/v1.0/study', content_type="application/json", - headers=non_admin_headers, + headers=non_admin_token_headers, data=json.dumps(StudySchema().dump(non_admin_study)) ) self.assert_success(rv_add_study, 'Non-admin user should be able to add a study') - new_study = json.loads(rv.get_data(as_text=True)) + new_non_admin_study = json.loads(rv_add_study.get_data(as_text=True)) + db_non_admin_study = db.session.query(StudyModel).filter_by(id=new_non_admin_study['id']).first() + self.assertIsNotNone(db_non_admin_study) - rv_del_study = self.app.delete( - '/v1.0/study/%i' % new_study.id, + rv_non_admin_del_study = self.app.delete( + '/v1.0/study/%i' % db_non_admin_study.id, follow_redirects=False, - headers=non_admin_headers + headers=non_admin_token_headers ) - self.assert_failure(rv_del_study, 'Non-admin user should not be able to delete a study') + self.assert_failure(rv_non_admin_del_study, 401) # Switch production mode back off app.config['PRODUCTION'] = False From 4e006e2653a503c97e748adfc1ce52b28c2fd104 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Thu, 11 Jun 2020 11:49:07 -0400 Subject: [PATCH 68/76] Clear out the g.user between tests. --- tests/base_test.py | 2 ++ tests/test_authentication.py | 11 ++--------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/base_test.py b/tests/base_test.py index 5c9e7dc1..9b360543 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -2,6 +2,7 @@ # IMPORTANT - Environment must be loaded before app, models, etc.... import os +from flask import g from sqlalchemy import Sequence os.environ["TESTING"] = "true" @@ -95,6 +96,7 @@ class BaseTest(unittest.TestCase): def tearDown(self): ExampleDataLoader.clean_db() + g.user = None self.auths = {} def logged_in_headers(self, user=None, redirect_url='http://some/frontend/url'): diff --git a/tests/test_authentication.py b/tests/test_authentication.py index d18614df..777b39dd 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -78,7 +78,7 @@ class TestAuthentication(BaseTest): self.assertIsNone(user) redirect_url = 'http://worlds.best.website/admin' headers = dict(Uid=new_uid) - + db.session.flush() rv = self.app.get('v1.0/login', follow_redirects=False, headers=headers) self.assert_success(rv) @@ -91,14 +91,7 @@ class TestAuthentication(BaseTest): # Switch production mode back off app.config['PRODUCTION'] = False - db.session.flush() - db.session.flush() - db.session.flush() - db.session.flush() - db.session.flush() - db.session.flush() - db.session.flush() - db.session.flush() + def test_current_user_status(self): self.load_example_data() From 312eef4d4027677d5763c7f1f71f86bafb227293 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Thu, 11 Jun 2020 13:07:27 -0400 Subject: [PATCH 69/76] Raises 403 error if no user found --- crc/api/user.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crc/api/user.py b/crc/api/user.py index 0786fbc9..83f1f2ca 100644 --- a/crc/api/user.py +++ b/crc/api/user.py @@ -62,9 +62,8 @@ def verify_token(token=None): return token_info else: - ApiError("no_user", "User not found. Please login via the frontend app before accessing this feature.", + raise ApiError("no_user", "User not found. Please login via the frontend app before accessing this feature.", status_code=403) - raise failure_error def verify_token_admin(token=None): From 0cbbe756a3418124102189ffdf52fd44aa1cfd12 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Thu, 11 Jun 2020 13:42:32 -0400 Subject: [PATCH 70/76] Tests for token expiration ApiError --- tests/test_authentication.py | 42 +++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 777b39dd..7d706949 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -1,12 +1,15 @@ import json from calendar import timegm from datetime import timezone, datetime, timedelta -from tests.base_test import BaseTest +import jwt + +from tests.base_test import BaseTest from crc import db, app +from crc.api.common import ApiError +from crc.models.protocol_builder import ProtocolBuilderStatus from crc.models.study import StudySchema, StudyModel from crc.models.user import UserModel -from crc.models.protocol_builder import ProtocolBuilderStatus class TestAuthentication(BaseTest): @@ -25,21 +28,34 @@ class TestAuthentication(BaseTest): # Set the timeout to something else new_ttl = 4.0 app.config['TOKEN_AUTH_TTL_HOURS'] = new_ttl - user = UserModel(uid="dhf8r") + user_1 = UserModel(uid="dhf8r") expected_exp_1 = timegm((datetime.utcnow() + timedelta(hours=new_ttl)).utctimetuple()) - auth_token_1 = user.encode_auth_token() + auth_token_1 = user_1.encode_auth_token() self.assertTrue(isinstance(auth_token_1, bytes)) - self.assertEqual("dhf8r", user.decode_auth_token(auth_token_1).get("sub")) - actual_exp_1 = user.decode_auth_token(auth_token_1).get("exp") + self.assertEqual("dhf8r", user_1.decode_auth_token(auth_token_1).get("sub")) + actual_exp_1 = user_1.decode_auth_token(auth_token_1).get("exp") self.assertTrue(expected_exp_1 - 1000 <= actual_exp_1 <= expected_exp_1 + 1000) + # Set the timeout to something else + neg_ttl = -0.01 + app.config['TOKEN_AUTH_TTL_HOURS'] = neg_ttl + user_2 = UserModel(uid="dhf8r") + expected_exp_2 = timegm((datetime.utcnow() + timedelta(hours=neg_ttl)).utctimetuple()) + auth_token_2 = user_2.encode_auth_token() + self.assertTrue(isinstance(auth_token_2, bytes)) + with self.assertRaises(ApiError) as api_error: + with self.assertRaises(jwt.exceptions.ExpiredSignatureError): + user_2.decode_auth_token(auth_token_2) + self.assertEqual(api_error.exception.status_code, 400, 'Should raise an API Error if token is expired') + # Set the timeout back to where it was app.config['TOKEN_AUTH_TTL_HOURS'] = orig_ttl - expected_exp_2 = timegm((datetime.utcnow() + timedelta(hours=new_ttl)).utctimetuple()) - auth_token_2 = user.encode_auth_token() - self.assertTrue(isinstance(auth_token_2, bytes)) - actual_exp_2 = user.decode_auth_token(auth_token_1).get("exp") - self.assertTrue(expected_exp_2 - 1000 <= actual_exp_2 <= expected_exp_2 + 1000) + user_3 = UserModel(uid="dhf8r") + expected_exp_3 = timegm((datetime.utcnow() + timedelta(hours=new_ttl)).utctimetuple()) + auth_token_3 = user_3.encode_auth_token() + self.assertTrue(isinstance(auth_token_3, bytes)) + actual_exp_3 = user_3.decode_auth_token(auth_token_1).get("exp") + self.assertTrue(expected_exp_3 - 1000 <= actual_exp_3 <= expected_exp_3 + 1000) def test_non_production_auth_creates_user(self): new_uid = 'lb3dp' ## Assure this user id is in the fake responses from ldap. @@ -67,7 +83,6 @@ class TestAuthentication(BaseTest): self.assertTrue(str.startswith(rv_2.location, redirect_url)) def test_production_auth_creates_user(self): - # Switch production mode on app.config['PRODUCTION'] = True @@ -92,7 +107,6 @@ class TestAuthentication(BaseTest): # Switch production mode back off app.config['PRODUCTION'] = False - def test_current_user_status(self): self.load_example_data() rv = self.app.get('/v1.0/user') @@ -107,7 +121,6 @@ class TestAuthentication(BaseTest): self.assert_success(rv) def test_admin_can_access_admin_only_endpoints(self): - # Switch production mode on app.config['PRODUCTION'] = True @@ -203,7 +216,6 @@ class TestAuthentication(BaseTest): # Switch production mode back off app.config['PRODUCTION'] = False - def _make_fake_study(self, uid): return { "title": "blah", From 768f14b3ac8b85c3f3591f3c116d28fe909c395f Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Thu, 11 Jun 2020 13:42:44 -0400 Subject: [PATCH 71/76] Cleans up console logging --- crc/api/user.py | 17 ----------------- tests/base_test.py | 3 --- 2 files changed, 20 deletions(-) diff --git a/crc/api/user.py b/crc/api/user.py index 83f1f2ca..c4b85e55 100644 --- a/crc/api/user.py +++ b/crc/api/user.py @@ -27,8 +27,6 @@ def verify_token(token=None): ApiError. If not on production and token is not valid, returns an 'invalid_token' 403 error. If on production and user is not authenticated, returns a 'no_user' 403 error. """ - print('=== verify_token ===') - print('_is_production()', _is_production()) failure_error = ApiError("invalid_token", "Unable to decode the token you provided. Please re-authenticate", status_code=403) @@ -78,15 +76,10 @@ def verify_token_admin(token=None): token: str """ - print('=== verify_token_admin ===') - print('_is_production()', _is_production()) - # If this is production, check that the user is in the list of admins if _is_production(): uid = _get_request_uid(request) - print('verify_token_admin uid', uid) - if uid is not None and uid in app.config['ADMIN_UIDS']: return verify_token() @@ -139,15 +132,10 @@ def login( # X-Forwarded-Server: dev.crconnect.uvadcos.io # Connection: Keep-Alive - print('=== login ===') - print('_is_production()', _is_production()) - # If we're in production, override any uid with the uid from the SSO request headers if _is_production(): uid = _get_request_uid(request) - print('login > uid', uid) - if uid: app.logger.info("SSO_LOGIN: Full URL: " + request.url) app.logger.info("SSO_LOGIN: User Id: " + uid) @@ -186,9 +174,6 @@ def _handle_login(user_info: LdapModel, redirect_url=None): Returns: Response. 302 - Redirects to the frontend auth callback URL, with auth token appended. """ - - print('=== _handle_login ===') - print('user_info', user_info) user = _upsert_user(user_info) # Return the frontend auth callback URL, with auth token appended. @@ -230,10 +215,8 @@ def _get_request_uid(req): if _is_production(): if 'user' in g and g.user is not None: - print('g.user.uid', g.user.uid) return g.user.uid - print('req.headers', req.headers) uid = req.headers.get("Uid") if not uid: uid = req.headers.get("X-Remote-Uid") diff --git a/tests/base_test.py b/tests/base_test.py index 9b360543..e8a16f28 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -108,11 +108,8 @@ class BaseTest(unittest.TestCase): user_info = {'uid': user.uid} query_string = self.user_info_to_query_string(user_info, redirect_url) - print('query_string', query_string) rv = self.app.get("/v1.0/login%s" % query_string, follow_redirects=False) - print('rv.status_code', rv.status_code) - self.assertTrue(rv.status_code == 302) self.assertTrue(str.startswith(rv.location, redirect_url)) From f51e5e4e6b799c5e846fcd792105830fa38f68e5 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Thu, 11 Jun 2020 16:39:00 -0400 Subject: [PATCH 72/76] Adds tests for approval counts and all approvals endpoints --- crc/api/approval.py | 1 - tests/base_test.py | 26 ++++++- tests/test_approvals_api.py | 135 ++++++++++++++++++++++++++---------- tests/test_study_service.py | 6 +- 4 files changed, 127 insertions(+), 41 deletions(-) diff --git a/crc/api/approval.py b/crc/api/approval.py index c9866f88..d67a4d7a 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -24,7 +24,6 @@ def get_approval_counts(as_user=None): .all() study_ids = [a.study_id for a in db_user_approvals] - print('study_ids', study_ids) db_other_approvals = db.session.query(ApprovalModel)\ .filter(ApprovalModel.study_id.in_(study_ids))\ diff --git a/tests/base_test.py b/tests/base_test.py index e8a16f28..f5b89fcb 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -12,6 +12,7 @@ import unittest import urllib.parse import datetime +from crc.models.approval import ApprovalModel, ApprovalStatus from crc.models.protocol_builder import ProtocolBuilderStatus from crc.models.study import StudyModel from crc.services.file_service import FileService @@ -224,12 +225,12 @@ class BaseTest(unittest.TestCase): db.session.commit() return user - def create_study(self, uid="dhf8r", title="Beer conception in the bipedal software engineer"): - study = session.query(StudyModel).first() + def create_study(self, uid="dhf8r", title="Beer conception 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) study = StudyModel(title=title, protocol_builder_status=ProtocolBuilderStatus.ACTIVE, - user_uid=user.uid, primary_investigator_id='lb3dp') + user_uid=user.uid, primary_investigator_id=primary_investigator_id) db.session.add(study) db.session.commit() return study @@ -251,3 +252,22 @@ class BaseTest(unittest.TestCase): binary_data=file.read(), content_type=CONTENT_TYPES['xls']) file.close() + + def create_approval( + self, + study=None, + workflow=None, + approver_uid=None, + status=None, + version=None, + ): + study = study or self.create_study() + workflow = workflow or self.create_workflow() + approver_uid = approver_uid or self.test_uid + status = status or ApprovalStatus.PENDING.value + version = version or 1 + approval = ApprovalModel(study=study, workflow=workflow, approver_uid=approver_uid, status=status, version=version) + db.session.add(approval) + db.session.commit() + return approval + diff --git a/tests/test_approvals_api.py b/tests/test_approvals_api.py index 3460f6a9..63c47345 100644 --- a/tests/test_approvals_api.py +++ b/tests/test_approvals_api.py @@ -1,4 +1,7 @@ import json +import random +import string + from tests.base_test import BaseTest from crc import session, db @@ -11,43 +14,25 @@ class TestApprovals(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.unrelated_study = StudyModel(title="second study", - protocol_builder_status=ProtocolBuilderStatus.ACTIVE, - user_uid="dhf8r", primary_investigator_id="dhf8r") - self.unrelated_workflow = self.create_workflow('random_fact', study=self.unrelated_study) - # TODO: Move to base_test as a helper - self.approval = ApprovalModel( - study=self.study, - workflow=self.workflow, - approver_uid='lb3dp', - status=ApprovalStatus.PENDING.value, - version=1 + # Add a study with 2 approvers + study_workflow_approvals_1 = 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] ) - session.add(self.approval) + self.study = study_workflow_approvals_1['study'] + self.workflow = study_workflow_approvals_1['workflow'] + self.approval = study_workflow_approvals_1['approvals'][0] + self.approval_2 = study_workflow_approvals_1['approvals'][1] - self.approval_2 = ApprovalModel( - study=self.study, - workflow=self.workflow, - approver_uid='dhf8r', - status=ApprovalStatus.PENDING.value, - version=1 + # Add a study with 1 approver + study_workflow_approvals_2 = self._create_study_workflow_approvals( + user_uid="dhf8r", title="second study", primary_investigator_id="dhf8r", + approver_uids=["lb3dp"], statuses=[ApprovalStatus.PENDING.value] ) - session.add(self.approval_2) - - # A third study, unrelated to the first. - self.approval_3 = ApprovalModel( - study=self.unrelated_study, - workflow=self.unrelated_workflow, - approver_uid='lb3dp', - status=ApprovalStatus.PENDING.value, - version=1 - ) - session.add(self.approval_3) - - session.commit() + self.unrelated_study = study_workflow_approvals_2['study'] + self.unrelated_workflow = study_workflow_approvals_2['workflow'] + self.approval_3 = study_workflow_approvals_2['approvals'][0] def test_list_approvals_per_approver(self): """Only approvals associated with approver should be returned""" @@ -85,7 +70,7 @@ class TestApprovals(BaseTest): response = json.loads(rv.get_data(as_text=True)) response_count = len(response) self.assertEqual(1, response_count) - self.assertEqual(1, len(response[0]['related_approvals'])) # this approval has a related approval. + self.assertEqual(1, len(response[0]['related_approvals'])) # this approval has a related approval. def test_update_approval_fails_if_not_the_approver(self): approval = session.query(ApprovalModel).filter_by(approver_uid='lb3dp').first() @@ -150,4 +135,84 @@ class TestApprovals(BaseTest): app.status = ApprovalStatus.APPROVED.value db.session.commit() rv = self.app.get(f'/v1.0/approval/csv', headers=self.logged_in_headers()) - self.assert_success(rv) \ No newline at end of file + self.assert_success(rv) + + def test_all_approvals(self): + not_canceled = session.query(ApprovalModel).filter(ApprovalModel.status != 'CANCELED').all() + not_canceled_study_ids = [] + for a in not_canceled: + if a.study_id not in not_canceled_study_ids: + not_canceled_study_ids.append(a.study_id) + + rv_all = self.app.get(f'/v1.0/all_approvals?status=false', headers=self.logged_in_headers()) + self.assert_success(rv_all) + all_data = json.loads(rv_all.get_data(as_text=True)) + self.assertEqual(len(all_data), len(not_canceled_study_ids), 'Should return all non-canceled approvals, grouped by study') + + all_approvals = session.query(ApprovalModel).all() + all_approvals_study_ids = [] + for a in all_approvals: + if a.study_id not in all_approvals_study_ids: + all_approvals_study_ids.append(a.study_id) + + rv_all = self.app.get(f'/v1.0/all_approvals?status=true', headers=self.logged_in_headers()) + self.assert_success(rv_all) + all_data = json.loads(rv_all.get_data(as_text=True)) + self.assertEqual(len(all_data), len(all_approvals_study_ids), 'Should return all approvals, grouped by study') + + def test_approvals_counts(self): + statuses = [name for name, value in ApprovalStatus.__members__.items()] + + # Add a whole bunch of approvals with random statuses + for i in range(100): + approver_uids = random.choices(["lb3dp", "dhf8r"]) + self._create_study_workflow_approvals( + user_uid=random.choice(["lb3dp", "dhf8r"]), + title="".join(random.sample(string.ascii_lowercase, 16)), + primary_investigator_id=random.choice(["lb3dp", "dhf8r"]), + approver_uids=approver_uids, + statuses=random.choices(statuses, k=len(approver_uids)) + ) + + # Counts should still match + rv_counts = self.app.get(f'/v1.0/approval-counts', headers=self.logged_in_headers()) + self.assert_success(rv_counts) + counts = json.loads(rv_counts.get_data(as_text=True)) + + rv_approvals = self.app.get(f'/v1.0/approval', headers=self.logged_in_headers()) + self.assert_success(rv_approvals) + approvals = json.loads(rv_approvals.get_data(as_text=True)) + + 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') + + manual_counts = {} + for status in statuses: + manual_counts[status] = 0 + + for approval in approvals: + manual_counts[approval['status']] += 1 + + for status in statuses: + self.assertEqual(counts[status], manual_counts[status], 'Approval counts for status should match') + + + def _create_study_workflow_approvals(self, user_uid, title, primary_investigator_id, approver_uids, statuses): + study = self.create_study(uid=user_uid, title=title, primary_investigator_id=primary_investigator_id) + workflow = self.create_workflow('random_fact', 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, + } diff --git a/tests/test_study_service.py b/tests/test_study_service.py index 03a0e033..1c482bcb 100644 --- a/tests/test_study_service.py +++ b/tests/test_study_service.py @@ -157,10 +157,12 @@ class TestStudyService(BaseTest): def test_get_all_studies(self): user = self.create_user_with_study_and_workflow() + study = db.session.query(StudyModel).filter_by(user_uid=user.uid).first() + self.assertIsNotNone(study) # Add a document to the study with the correct code. - workflow1 = self.create_workflow('docx') - workflow2 = self.create_workflow('empty_workflow') + workflow1 = self.create_workflow('docx', study=study) + workflow2 = self.create_workflow('empty_workflow', study=study) # Add files to both workflows. FileService.add_workflow_file(workflow_id=workflow1.id, From 691258a4fb1b6f09f90647488df82836d208b8da Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Thu, 11 Jun 2020 17:15:44 -0400 Subject: [PATCH 73/76] Fixes bug where approvals counts were off by 1. Tests CSV export and all approvals endpoints with lots of records. --- crc/api/approval.py | 6 ++-- tests/test_approvals_api.py | 58 ++++++++++++++++++++++++------------- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/crc/api/approval.py b/crc/api/approval.py index d67a4d7a..b3ee0fed 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -38,8 +38,8 @@ def get_approval_counts(as_user=None): other_approvals[approval.study_id] = approval counts = {} - for status in ApprovalStatus: - counts[status.name] = 0 + for name, value in ApprovalStatus.__members__.items(): + counts[name] = 0 for approval in db_user_approvals: # Check if another approval has the same study id @@ -56,6 +56,8 @@ def get_approval_counts(as_user=None): counts[ApprovalStatus.CANCELED.name] += 1 elif other_approval.status == ApprovalStatus.APPROVED.name: counts[approval.status] += 1 + else: + counts[approval.status] += 1 else: counts[approval.status] += 1 diff --git a/tests/test_approvals_api.py b/tests/test_approvals_api.py index 63c47345..5cc60011 100644 --- a/tests/test_approvals_api.py +++ b/tests/test_approvals_api.py @@ -130,14 +130,19 @@ class TestApprovals(BaseTest): self.assertEqual(approval.status, ApprovalStatus.DECLINED.value) def test_csv_export(self): - approvals = db.session.query(ApprovalModel).all() - for app in approvals: - app.status = ApprovalStatus.APPROVED.value - db.session.commit() + self._add_lots_of_random_approvals() + + # approvals = db.session.query(ApprovalModel).all() + # for app in approvals: + # app.status = ApprovalStatus.APPROVED.value + # db.session.commit() + rv = self.app.get(f'/v1.0/approval/csv', headers=self.logged_in_headers()) self.assert_success(rv) def test_all_approvals(self): + self._add_lots_of_random_approvals() + not_canceled = session.query(ApprovalModel).filter(ApprovalModel.status != 'CANCELED').all() not_canceled_study_ids = [] for a in not_canceled: @@ -162,30 +167,19 @@ class TestApprovals(BaseTest): def test_approvals_counts(self): statuses = [name for name, value in ApprovalStatus.__members__.items()] + self._add_lots_of_random_approvals() - # Add a whole bunch of approvals with random statuses - for i in range(100): - approver_uids = random.choices(["lb3dp", "dhf8r"]) - self._create_study_workflow_approvals( - user_uid=random.choice(["lb3dp", "dhf8r"]), - title="".join(random.sample(string.ascii_lowercase, 16)), - primary_investigator_id=random.choice(["lb3dp", "dhf8r"]), - approver_uids=approver_uids, - statuses=random.choices(statuses, k=len(approver_uids)) - ) - - # Counts should still match + # Get the counts rv_counts = self.app.get(f'/v1.0/approval-counts', headers=self.logged_in_headers()) self.assert_success(rv_counts) counts = json.loads(rv_counts.get_data(as_text=True)) + # Get the actual approvals rv_approvals = self.app.get(f'/v1.0/approval', headers=self.logged_in_headers()) self.assert_success(rv_approvals) approvals = json.loads(rv_approvals.get_data(as_text=True)) - 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') - + # Tally up the number of approvals in each status category manual_counts = {} for status in statuses: manual_counts[status] = 0 @@ -193,9 +187,13 @@ class TestApprovals(BaseTest): for approval in approvals: manual_counts[approval['status']] += 1 + # Numbers in each category should match for status in statuses: - self.assertEqual(counts[status], manual_counts[status], 'Approval counts for status should match') + self.assertEqual(counts[status], manual_counts[status], 'Approval counts for status %s should match' % status) + # Total number of approvals should match + 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): study = self.create_study(uid=user_uid, title=title, primary_investigator_id=primary_investigator_id) @@ -216,3 +214,23 @@ class TestApprovals(BaseTest): 'workflow': workflow, 'approvals': approvals, } + + def _add_lots_of_random_approvals(self): + num_studies_before = db.session.query(StudyModel).count() + statuses = [name for name, value in ApprovalStatus.__members__.items()] + + # Add a whole bunch of approvals with random statuses + for i in range(100): + approver_uids = random.choices(["lb3dp", "dhf8r"]) + self._create_study_workflow_approvals( + user_uid=random.choice(["lb3dp", "dhf8r"]), + title="".join(random.choices(string.ascii_lowercase, k=64)), + primary_investigator_id=random.choice(["lb3dp", "dhf8r"]), + approver_uids=approver_uids, + statuses=random.choices(statuses, k=len(approver_uids)) + ) + + session.flush() + num_studies_after = db.session.query(StudyModel).count() + self.assertEqual(num_studies_after, num_studies_before + 100) + From 561e25431546c18cc75d6437716c71c379b0ce91 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 12 Jun 2020 13:46:10 -0400 Subject: [PATCH 74/76] Prevents non-admin users from editing each others' tasks. Fixes bug where test user uid was not being set from token. Moves complete form and get workflow API test utility methods into BaseTest. --- crc/api/user.py | 4 +- crc/api/workflow.py | 28 +++++++- crc/models/user.py | 2 +- crc/services/study_service.py | 4 +- crc/services/workflow_service.py | 6 +- tests/base_test.py | 109 +++++++++++++++++++++++++++---- tests/test_approvals_api.py | 50 ++++++++++---- tests/test_tasks_api.py | 73 +-------------------- 8 files changed, 167 insertions(+), 109 deletions(-) diff --git a/crc/api/user.py b/crc/api/user.py index c4b85e55..a298808d 100644 --- a/crc/api/user.py +++ b/crc/api/user.py @@ -31,7 +31,7 @@ def verify_token(token=None): failure_error = ApiError("invalid_token", "Unable to decode the token you provided. Please re-authenticate", status_code=403) - if not _is_production(): + if not _is_production() and (token is None or 'user' not in g): g.user = UserModel.query.first() token = g.user.encode_auth_token() @@ -132,6 +132,7 @@ def login( # X-Forwarded-Server: dev.crconnect.uvadcos.io # Connection: Keep-Alive + # If we're in production, override any uid with the uid from the SSO request headers if _is_production(): uid = _get_request_uid(request) @@ -175,6 +176,7 @@ def _handle_login(user_info: LdapModel, redirect_url=None): Response. 302 - Redirects to the frontend auth callback URL, with auth token appended. """ user = _upsert_user(user_info) + g.user = user # Return the frontend auth callback URL, with auth token appended. auth_token = user.encode_auth_token().decode() diff --git a/crc/api/workflow.py b/crc/api/workflow.py index 02b99641..655a85e7 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -1,6 +1,8 @@ import uuid -from crc import session +from flask import g + +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 @@ -156,6 +158,7 @@ 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) task = processor.bpmn_workflow.get_task(task_id) @@ -167,13 +170,21 @@ def set_current_task(workflow_id, task_id): if task.state == task.COMPLETED: task.reset_token(reset_data=False) # we could optionally clear the previous data. processor.save() - WorkflowService.log_task_action(processor, task, WorkflowService.TASK_ACTION_TOKEN_RESET) + WorkflowService.log_task_action(user_uid, processor, task, WorkflowService.TASK_ACTION_TOKEN_RESET) workflow_api_model = __get_workflow_api_model(processor, task) return WorkflowApiSchema().dump(workflow_api_model) def update_task(workflow_id, task_id, body): 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) + + 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) task = processor.bpmn_workflow.get_task(task_id) @@ -184,7 +195,7 @@ def update_task(workflow_id, task_id, body): processor.complete_task(task) processor.do_engine_steps() processor.save() - WorkflowService.log_task_action(processor, task, WorkflowService.TASK_ACTION_COMPLETE) + WorkflowService.log_task_action(user_uid, processor, task, WorkflowService.TASK_ACTION_COMPLETE) workflow_api_model = __get_workflow_api_model(processor) return WorkflowApiSchema().dump(workflow_api_model) @@ -239,3 +250,14 @@ def lookup(workflow_id, field_id, query, limit): workflow = session.query(WorkflowModel).filter(WorkflowModel.id == workflow_id).first() lookup_data = LookupService.lookup(workflow, field_id, query, limit) 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 + + else: + raise ApiError("logged_out", "You are no longer logged in.", status_code=401) diff --git a/crc/models/user.py b/crc/models/user.py index 67e85967..55bba35f 100644 --- a/crc/models/user.py +++ b/crc/models/user.py @@ -19,7 +19,7 @@ class UserModel(db.Model): last_name = db.Column(db.String, nullable=True) title = db.Column(db.String, nullable=True) - # Add Department and School + # TODO: Add Department and School def encode_auth_token(self): diff --git a/crc/services/study_service.py b/crc/services/study_service.py index 4024b5f0..92ec265d 100644 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -86,8 +86,8 @@ class StudyService(object): def delete_workflow(workflow): for file in session.query(FileModel).filter_by(workflow_id=workflow.id).all(): FileService.delete_file(file.id) - for deb in workflow.dependencies: - session.delete(deb) + for dep in workflow.dependencies: + session.delete(dep) session.query(TaskEventModel).filter_by(workflow_id=workflow.id).delete() session.query(WorkflowModel).filter_by(id=workflow.id).delete() diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 5efa8cab..d80f334a 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -58,7 +58,7 @@ class WorkflowService(object): @staticmethod def delete_test_data(): - for study in db.session.query(StudyModel).filter(StudyModel.user_uid=="test"): + for study in db.session.query(StudyModel).filter_by(user_uid="test"): StudyService.delete_study(study.id) db.session.commit() @@ -318,12 +318,12 @@ class WorkflowService(object): field.options.append({"id": d.value, "name": d.label}) @staticmethod - def log_task_action(processor, spiff_task, action): + def log_task_action(user_uid, processor, spiff_task, action): task = WorkflowService.spiff_task_to_api_task(spiff_task) workflow_model = processor.workflow_model task_event = TaskEventModel( study_id=workflow_model.study_id, - user_uid=g.user.uid, + user_uid=user_uid, workflow_id=workflow_model.id, workflow_spec_id=workflow_model.workflow_spec_id, spec_version=processor.get_version_string(), diff --git a/tests/base_test.py b/tests/base_test.py index f5b89fcb..f3efc189 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -2,26 +2,27 @@ # IMPORTANT - Environment must be loaded before app, models, etc.... import os -from flask import g -from sqlalchemy import Sequence - os.environ["TESTING"] = "true" import json import unittest import urllib.parse import datetime - -from crc.models.approval import ApprovalModel, ApprovalStatus -from crc.models.protocol_builder import ProtocolBuilderStatus -from crc.models.study import StudyModel -from crc.services.file_service import FileService -from crc.services.study_service import StudyService -from crc.models.file import FileModel, FileDataModel, CONTENT_TYPES -from crc.models.workflow import WorkflowSpecModel, WorkflowSpecModelSchema, WorkflowModel -from crc.models.user import UserModel +from flask import g +from sqlalchemy import Sequence from crc import app, db, session +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.study import StudyModel +from crc.models.user import UserModel +from crc.models.workflow import WorkflowSpecModel, WorkflowSpecModelSchema, WorkflowModel +from crc.services.file_service import FileService +from crc.services.study_service import StudyService +from crc.services.workflow_service import WorkflowService from example_data import ExampleDataLoader #UNCOMMENT THIS FOR DEBUGGING SQL ALCHEMY QUERIES @@ -40,6 +41,7 @@ class BaseTest(unittest.TestCase): auths = {} test_uid = "dhf8r" + flask_globals = g users = [ { @@ -97,7 +99,7 @@ class BaseTest(unittest.TestCase): def tearDown(self): ExampleDataLoader.clean_db() - g.user = None + self.flask_globals.user = None self.auths = {} def logged_in_headers(self, user=None, redirect_url='http://some/frontend/url'): @@ -110,12 +112,15 @@ class BaseTest(unittest.TestCase): query_string = self.user_info_to_query_string(user_info, redirect_url) rv = self.app.get("/v1.0/login%s" % query_string, follow_redirects=False) - self.assertTrue(rv.status_code == 302) self.assertTrue(str.startswith(rv.location, redirect_url)) user_model = session.query(UserModel).filter_by(uid=uid).first() self.assertIsNotNone(user_model.display_name) + self.assertEqual(user_model.uid, uid) + self.assertTrue('user' in self.flask_globals, 'User should be in Flask globals') + self.assertEqual(uid, self.flask_globals.user.uid, 'Logged in user should match given user uid') + return dict(Authorization='Bearer ' + user_model.encode_auth_token().decode()) def load_example_data(self, use_crc_data=False, use_rrt_data=False): @@ -162,6 +167,7 @@ class BaseTest(unittest.TestCase): @staticmethod def load_test_spec(dir_name, master_spec=False, category_id=None): """Loads a spec into the database based on a directory in /tests/data""" + if session.query(WorkflowSpecModel).filter_by(id=dir_name).count() > 0: return session.query(WorkflowSpecModel).filter_by(id=dir_name).first() filepath = os.path.join(app.root_path, '..', 'tests', 'data', dir_name, "*") @@ -271,3 +277,78 @@ class BaseTest(unittest.TestCase): db.session.commit() return approval + def get_workflow_api(self, workflow, soft_reset=False, hard_reset=False, user_uid="dhf8r"): + user = session.query(UserModel).filter_by(uid=user_uid).first() + self.assertIsNotNone(user) + + 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(user), + 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, user_uid="dhf8r"): + prev_completed_task_count = workflow_in.completed_tasks + if isinstance(task_in, dict): + task_id = task_in["id"] + else: + task_id = task_in.id + + 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 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(user_uid, 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 voodoo 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) + + + workflow = WorkflowApiSchema().load(json_data) + return workflow diff --git a/tests/test_approvals_api.py b/tests/test_approvals_api.py index 5cc60011..ca9fa30b 100644 --- a/tests/test_approvals_api.py +++ b/tests/test_approvals_api.py @@ -3,11 +3,10 @@ import random import string from tests.base_test import BaseTest - from crc import session, db -from crc.models.approval import ApprovalModel, ApprovalSchema, ApprovalStatus -from crc.models.protocol_builder import ProtocolBuilderStatus +from crc.models.approval import ApprovalModel, ApprovalStatus from crc.models.study import StudyModel +from crc.models.workflow import WorkflowModel class TestApprovals(BaseTest): @@ -130,12 +129,33 @@ class TestApprovals(BaseTest): self.assertEqual(approval.status, ApprovalStatus.DECLINED.value) def test_csv_export(self): - self._add_lots_of_random_approvals() + self.load_test_spec('two_forms') + self._add_lots_of_random_approvals(n=50, workflow_spec_name='two_forms') - # approvals = db.session.query(ApprovalModel).all() - # for app in approvals: - # app.status = ApprovalStatus.APPROVED.value - # db.session.commit() + # Get all workflows + workflows = db.session.query(WorkflowModel).filter_by(workflow_spec_id='two_forms').all() + + # For each workflow, complete all tasks + for workflow in workflows: + workflow_api = self.get_workflow_api(workflow, user_uid=workflow.study.user_uid) + self.assertEqual('two_forms', workflow_api.workflow_spec_id) + + # Log current user out. + self.flask_globals.user = None + self.assertIsNone(self.flask_globals.user) + + # Complete the form for Step one and post it. + self.complete_form(workflow, workflow_api.next_task, {"color": "blue"}, error_code=None, user_uid=workflow.study.user_uid) + + # Get the next Task + workflow_api = self.get_workflow_api(workflow, user_uid=workflow.study.user_uid) + self.assertEqual("StepTwo", workflow_api.next_task.name) + + # Get all user Tasks and check that the data have been saved + task = workflow_api.next_task + self.assertIsNotNone(task.data) + for val in task.data.values(): + self.assertIsNotNone(val) rv = self.app.get(f'/v1.0/approval/csv', headers=self.logged_in_headers()) self.assert_success(rv) @@ -195,9 +215,10 @@ 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): + 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('random_fact', study=study) + workflow = self.create_workflow(workflow_name=workflow_spec_name, study=study) approvals = [] for i in range(len(approver_uids)): @@ -215,22 +236,23 @@ class TestApprovals(BaseTest): 'approvals': approvals, } - def _add_lots_of_random_approvals(self): + 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()] # Add a whole bunch of approvals with random statuses - for i in range(100): + for i in range(n): approver_uids = random.choices(["lb3dp", "dhf8r"]) self._create_study_workflow_approvals( user_uid=random.choice(["lb3dp", "dhf8r"]), title="".join(random.choices(string.ascii_lowercase, k=64)), primary_investigator_id=random.choice(["lb3dp", "dhf8r"]), approver_uids=approver_uids, - statuses=random.choices(statuses, k=len(approver_uids)) + statuses=random.choices(statuses, k=len(approver_uids)), + workflow_spec_name=workflow_spec_name ) session.flush() num_studies_after = db.session.query(StudyModel).count() - self.assertEqual(num_studies_after, num_studies_before + 100) + self.assertEqual(num_studies_after, num_studies_before + n) diff --git a/tests/test_tasks_api.py b/tests/test_tasks_api.py index a670fb66..654b777e 100644 --- a/tests/test_tasks_api.py +++ b/tests/test_tasks_api.py @@ -4,85 +4,14 @@ import random from unittest.mock import patch from tests.base_test import BaseTest - from crc import session, app from crc.models.api_models import WorkflowApiSchema, MultiInstanceType, TaskSchema from crc.models.file import FileModelSchema -from crc.models.stats import TaskEventModel from crc.models.workflow import WorkflowStatus -from crc.services.workflow_service import WorkflowService 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) - - - workflow = WorkflowApiSchema().load(json_data) - return workflow - - def test_get_current_user_tasks(self): self.load_example_data() workflow = self.create_workflow('random_fact') @@ -185,6 +114,7 @@ class TestTasksApi(BaseTest): self.load_example_data() self.create_reference_document() workflow = self.create_workflow('docx') + # get the first form in the two form workflow. task = self.get_workflow_api(workflow).next_task data = { @@ -203,6 +133,7 @@ class TestTasksApi(BaseTest): json_data = json.loads(rv.get_data(as_text=True)) files = FileModelSchema(many=True).load(json_data, session=session) self.assertTrue(len(files) == 1) + # Assure we can still delete the study even when there is a file attached to a workflow. rv = self.app.delete('/v1.0/study/%i' % workflow.study_id, headers=self.logged_in_headers()) self.assert_success(rv) From e3126620b3d19527ce3d02e550301102f3d67b0b Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 12 Jun 2020 14:09:08 -0400 Subject: [PATCH 75/76] Eschews obfuscation --- crc/services/workflow_service.py | 2 +- tests/base_test.py | 7 +++---- tests/test_approvals_api.py | 6 ++++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index d80f334a..4f67d6af 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -58,7 +58,7 @@ class WorkflowService(object): @staticmethod def delete_test_data(): - for study in db.session.query(StudyModel).filter_by(user_uid="test"): + for study in db.session.query(StudyModel).filter_by(StudyModel.user_uid == "test"): StudyService.delete_study(study.id) db.session.commit() diff --git a/tests/base_test.py b/tests/base_test.py index f3efc189..93294193 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -41,7 +41,6 @@ class BaseTest(unittest.TestCase): auths = {} test_uid = "dhf8r" - flask_globals = g users = [ { @@ -99,7 +98,7 @@ class BaseTest(unittest.TestCase): def tearDown(self): ExampleDataLoader.clean_db() - self.flask_globals.user = None + g.user = None self.auths = {} def logged_in_headers(self, user=None, redirect_url='http://some/frontend/url'): @@ -118,8 +117,8 @@ class BaseTest(unittest.TestCase): user_model = session.query(UserModel).filter_by(uid=uid).first() self.assertIsNotNone(user_model.display_name) self.assertEqual(user_model.uid, uid) - self.assertTrue('user' in self.flask_globals, 'User should be in Flask globals') - self.assertEqual(uid, self.flask_globals.user.uid, 'Logged in user should match given user uid') + self.assertTrue('user' in g, 'User should be in Flask globals') + self.assertEqual(uid, g.user.uid, 'Logged in user should match given user uid') return dict(Authorization='Bearer ' + user_model.encode_auth_token().decode()) diff --git a/tests/test_approvals_api.py b/tests/test_approvals_api.py index ca9fa30b..ed0f7c5d 100644 --- a/tests/test_approvals_api.py +++ b/tests/test_approvals_api.py @@ -2,6 +2,8 @@ import json import random import string +from flask import g + from tests.base_test import BaseTest from crc import session, db from crc.models.approval import ApprovalModel, ApprovalStatus @@ -141,8 +143,8 @@ class TestApprovals(BaseTest): self.assertEqual('two_forms', workflow_api.workflow_spec_id) # Log current user out. - self.flask_globals.user = None - self.assertIsNone(self.flask_globals.user) + g.user = None + self.assertIsNone(g.user) # Complete the form for Step one and post it. self.complete_form(workflow, workflow_api.next_task, {"color": "blue"}, error_code=None, user_uid=workflow.study.user_uid) From 2a84d5196a403e004d8b104fe6018f819f009384 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 12 Jun 2020 14:13:27 -0400 Subject: [PATCH 76/76] filter, not filter_by --- crc/services/workflow_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index 4f67d6af..310bd7fd 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -58,7 +58,7 @@ class WorkflowService(object): @staticmethod def delete_test_data(): - for study in db.session.query(StudyModel).filter_by(StudyModel.user_uid == "test"): + for study in db.session.query(StudyModel).filter(StudyModel.user_uid == "test"): StudyService.delete_study(study.id) db.session.commit()