diff --git a/.sonarcloud.properties b/.sonarcloud.properties index 4394a1bb..f1205436 100644 --- a/.sonarcloud.properties +++ b/.sonarcloud.properties @@ -1,7 +1,7 @@ sonar.organization=sartography sonar.projectKey=sartography_cr-connect-workflow sonar.host.url=https://sonarcloud.io -sonar.exclusions=docs/**,config/**,instance/**,migrations/**,postgres/**,readme_images/**,schema/**,templates/** +sonar.exclusions=crc/templates/*.html,docs/**,config/**,instance/**,migrations/**,postgres/**,readme_images/**,schema/**,templates/** sonar.sources=crc sonar.test.inclusions=tests sonar.python.coverage.reportPaths=coverage.xml diff --git a/config/default.py b/config/default.py index f03af5b1..0aa4e254 100644 --- a/config/default.py +++ b/config/default.py @@ -15,7 +15,8 @@ JSON_SORT_KEYS = False # CRITICAL. Do not sort the data when returning values API_TOKEN = environ.get('API_TOKEN', default = 'af95596f327c9ecc007b60414fc84b61') NAME = "CR Connect Workflow" -FLASK_PORT = environ.get('PORT0') or environ.get('FLASK_PORT', default="5000") +DEFAULT_PORT = "5000" +FLASK_PORT = environ.get('PORT0') or environ.get('FLASK_PORT', default=DEFAULT_PORT) CORS_ALLOW_ORIGINS = re.split(r',\s*', environ.get('CORS_ALLOW_ORIGINS', default="localhost:4200, localhost:5002")) TESTING = environ.get('TESTING', default="false") == "true" PRODUCTION = (environ.get('PRODUCTION', default="false") == "true") diff --git a/config/testing.py b/config/testing.py index b8375d9a..047c45df 100644 --- a/config/testing.py +++ b/config/testing.py @@ -31,3 +31,5 @@ print('TESTING = ', TESTING) #Use the mock ldap. LDAP_URL = 'mock' +from config.default import DEFAULT_PORT +SERVER_NAME = f'localhost:{DEFAULT_PORT}' diff --git a/crc/api/study.py b/crc/api/study.py index e6e1ed20..b7e0d3b0 100644 --- a/crc/api/study.py +++ b/crc/api/study.py @@ -9,6 +9,7 @@ from crc.models.protocol_builder import ProtocolBuilderStatus from crc.models.study import Study, StudyEvent, StudyEventType, StudyModel, StudySchema, StudyForUpdateSchema, StudyStatus from crc.services.study_service import StudyService from crc.services.user_service import UserService +from crc.services.workflow_service import WorkflowService def add_study(body): @@ -63,6 +64,10 @@ def update_study(study_id, body): session.add(study_model) session.commit() + + if status == StudyStatus.abandoned or status == StudyStatus.hold: + WorkflowService.process_workflows_for_cancels(study_id) + # Need to reload the full study to return it to the frontend study = StudyService.get_study(study_id) return StudySchema().dump(study) diff --git a/crc/api/workflow.py b/crc/api/workflow.py index d9a0b498..f2cb39d9 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -24,6 +24,8 @@ def all_specifications(): def add_workflow_specification(body): + count = session.query(WorkflowSpecModel).filter_by(category_id=body['category_id']).count() + body['display_order'] = count new_spec: WorkflowSpecModel = WorkflowSpecModelSchema().load(body, session=session) session.add(new_spec) session.commit() @@ -115,7 +117,7 @@ def restart_workflow(workflow_id, clear_data=False): """Restart a workflow with the latest spec. Clear data allows user to restart the workflow without previous data.""" workflow_model: WorkflowModel = session.query(WorkflowModel).filter_by(id=workflow_id).first() - WorkflowProcessor.reset(workflow_model, clear_data=clear_data) + WorkflowProcessor(workflow_model).reset(workflow_model, clear_data=clear_data) return get_workflow(workflow_model.id) diff --git a/crc/scripts/email.py b/crc/scripts/email.py index 79f5dc5e..3160dc22 100644 --- a/crc/scripts/email.py +++ b/crc/scripts/email.py @@ -7,7 +7,9 @@ from crc import app from crc.api.common import ApiError from crc.scripts.script import Script from crc.services.ldap_service import LdapService -from crc.services.mails import send_mail +from crc.services.email_service import EmailService + +from flask import render_template, request class Email(Script): @@ -25,25 +27,27 @@ email ("My Subject", "dhf8r@virginia.edu", pi.email) """ def do_task_validate_only(self, task, *args, **kwargs): - self.get_subject(task, args) + self.get_subject(args) self.get_email_recipients(task, args) self.get_content(task) def do_task(self, task, study_id, workflow_id, *args, **kwargs): - if len(args) < 1: + if len(args) < 2: raise ApiError(code="missing_argument", message="Email script requires a subject and at least one email address as arguments") - subject = args[0] + subject = self.get_subject(args) recipients = self.get_email_recipients(task, args) - content, content_html = self.get_content(task) + if recipients: - send_mail( + content, content_html = self.get_content(task) + EmailService.add_email( subject=subject, sender=app.config['DEFAULT_SENDER'], recipients=recipients, content=content, - content_html=content_html + content_html=content_html, + study_id=study_id ) def check_valid_email(self, email): @@ -82,7 +86,8 @@ email ("My Subject", "dhf8r@virginia.edu", pi.email) return emails - def get_subject(self, task, args): + @staticmethod + def get_subject(args): # subject = '' if len(args[0]) < 1: raise ApiError(code="missing_argument", @@ -101,4 +106,10 @@ email ("My Subject", "dhf8r@virginia.edu", pi.email) template = Template(content) rendered = template.render(task.data) rendered_markdown = markdown.markdown(rendered).replace('\n', '
') - return rendered, rendered_markdown + wrapped = self.get_cr_connect_wrapper(rendered_markdown) + + return rendered, wrapped + + @staticmethod + def get_cr_connect_wrapper(email_body): + return render_template('mail_content_template.html', email_body=email_body, base_url=request.base_url) diff --git a/crc/services/workflow_processor.py b/crc/services/workflow_processor.py index 5149fa97..92e8d68b 100644 --- a/crc/services/workflow_processor.py +++ b/crc/services/workflow_processor.py @@ -196,10 +196,10 @@ class WorkflowProcessor(object): else: self.is_latest_spec = False - @classmethod - def reset(cls, workflow_model, clear_data=False): + def reset(self, workflow_model, clear_data=False): print('WorkflowProcessor: reset: ') + self.cancel_notify() workflow_model.bpmn_workflow_json = None if clear_data: # Clear form_data from task_events @@ -209,7 +209,7 @@ class WorkflowProcessor(object): task_event.form_data = {} session.add(task_event) session.commit() - return cls(workflow_model) + return self.__init__(workflow_model) def __get_bpmn_workflow(self, workflow_model: WorkflowModel, spec: WorkflowSpec, validate_only=False): if workflow_model.bpmn_workflow_json: diff --git a/crc/services/workflow_service.py b/crc/services/workflow_service.py index dce8118f..74d070c0 100644 --- a/crc/services/workflow_service.py +++ b/crc/services/workflow_service.py @@ -251,7 +251,9 @@ class WorkflowService(object): default = result # If no default exists, return None - if not default: return None + # Note: if default is False, we don't want to execute this code + if default is None: + return None if field.type == "enum" and not has_lookup: default_option = next((obj for obj in field.options if obj.id == default), None) @@ -278,7 +280,10 @@ class WorkflowService(object): elif field.type == "long": return int(default) elif field.type == 'boolean': - return bool(default) + default = str(default).lower() + if default == 'true' or default == 't': + return True + return False else: return default @@ -704,5 +709,10 @@ class WorkflowService(object): return data - - + @staticmethod + def process_workflows_for_cancels(study_id): + workflows = db.session.query(WorkflowModel).filter_by(study_id=study_id).all() + for workflow in workflows: + if workflow.status == WorkflowStatus.user_input_required or workflow.status == WorkflowStatus.waiting: + processor = WorkflowProcessor(workflow) + processor.reset(workflow) diff --git a/crc/static/uva_rotunda.svg b/crc/static/uva_rotunda.svg new file mode 100644 index 00000000..6ef09251 --- /dev/null +++ b/crc/static/uva_rotunda.svg @@ -0,0 +1,145 @@ + + + + + + + image/svg+xml + + Artboard 1 + + + + + + + Artboard 1 + + + + + + + + + + + + + + + diff --git a/crc/templates/mail_content_template.html b/crc/templates/mail_content_template.html new file mode 100644 index 00000000..76ea2b7a --- /dev/null +++ b/crc/templates/mail_content_template.html @@ -0,0 +1,10 @@ +{% extends "mail_main_template.html" %} +{% block content %} +
+ {{ email_body | safe }} +
+{% endblock %} +{% block footer %} + {{ super() }} + Ramp-Up Toolkit Configurator - University of Virginia +{% endblock %}} diff --git a/crc/templates/mail_main_template.html b/crc/templates/mail_main_template.html new file mode 100644 index 00000000..62e3ba97 --- /dev/null +++ b/crc/templates/mail_main_template.html @@ -0,0 +1,421 @@ + + + + + + + + CR-Connect Email + + Research Ramp-Up Toolkit + + + + + + + + + + + + diff --git a/tests/data/boolean_default_value/boolean_default_value.bpmn b/tests/data/boolean_default_value/boolean_default_value.bpmn new file mode 100644 index 00000000..4f820e65 --- /dev/null +++ b/tests/data/boolean_default_value/boolean_default_value.bpmn @@ -0,0 +1,89 @@ + + + + + Flow_1x41riu + + + + + + + + + + + + Flow_0zp5mss + Flow_0m31ypa + + + + <H1>Good Bye</H1> +<div><span>Pick One: {% if pick_one %}{{ pick_one}}{% endif %} </span></div> + + Flow_0m31ypa + Flow_0f3gndz + + + Flow_0f3gndz + + + + + <H1>Hello</H1> + Flow_1x41riu + Flow_1i32jb7 + + + + + Flow_1i32jb7 + Flow_0zp5mss + if not 'yes_no' in globals(): + yes_no = False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/study_cancellations/study_cancellations.bpmn b/tests/data/study_cancellations/study_cancellations.bpmn new file mode 100644 index 00000000..b164ce19 --- /dev/null +++ b/tests/data/study_cancellations/study_cancellations.bpmn @@ -0,0 +1,148 @@ + + + + + Flow_0xym55y + + + + Flow_16q1uec + update_study("title:'New Title'") +print('New Title') + + + + + + + + + Flow_1e9j7mj + Flow_07i0gvv + + + + Flow_16q1uec + + + + <H1>Hello</H1> + Flow_0xym55y + Flow_1e9j7mj + + + + Flow_0rus4fi + + + + <H1>Good Bye</H1> + Flow_0f79pbo + Flow_0rus4fi + + + + + + + + Flow_07i0gvv + Flow_0f79pbo + + + + Flow_13xidv2 + + + + + <H1>Cancel Message</H1> + Flow_13xidv2 + update_study("title:'Second Title'") +print('Second Title') + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/study/test_study_cancellations.py b/tests/study/test_study_cancellations.py new file mode 100644 index 00000000..4b0d61f9 --- /dev/null +++ b/tests/study/test_study_cancellations.py @@ -0,0 +1,107 @@ +from tests.base_test import BaseTest + +from crc import session +from crc.models.study import StudyModel, StudySchema +from crc.models.workflow import WorkflowModel, WorkflowSpecModel + +import json + + +class TestStudyCancellations(BaseTest): + + def update_study_status(self, study, study_schema): + put_response = self.app.put('/v1.0/study/%i' % study.id, + content_type="application/json", + headers=self.logged_in_headers(), + data=json.dumps(study_schema)) + self.assert_success(put_response) + + # The error happened when the dashboard reloaded, + # in particular, when we got the studies for the user + api_response = self.app.get('/v1.0/study', headers=self.logged_in_headers(), content_type="application/json") + self.assert_success(api_response) + + study_result = session.query(StudyModel).filter(StudyModel.id == study.id).first() + return study_result + + def put_study_on_hold(self, study_id): + study = session.query(StudyModel).filter_by(id=study_id).first() + + study_schema = StudySchema().dump(study) + study_schema['status'] = 'hold' + study_schema['comment'] = 'This is my hold comment' + + self.update_study_status(study, study_schema) + + study_result = session.query(StudyModel).filter(StudyModel.id == study_id).first() + return study_result + + def load_workflow(self): + self.load_example_data() + workflow = self.create_workflow('study_cancellations') + study_id = workflow.study_id + return workflow, study_id + + def get_first_task(self, workflow): + workflow_api = self.get_workflow_api(workflow) + first_task = workflow_api.next_task + self.assertEqual('Activity_Hello', first_task.name) + return workflow_api, first_task + + def get_second_task(self, workflow): + workflow_api = self.get_workflow_api(workflow) + second_task = workflow_api.next_task + self.assertEqual('Activity_HowMany', second_task.name) + return workflow_api, second_task + + def get_third_task(self, workflow): + workflow_api = self.get_workflow_api(workflow) + third_task = workflow_api.next_task + self.assertEqual('Activity_Modify', third_task.name) + return workflow_api, third_task + + def test_before_cancel(self): + + workflow, study_id = self.load_workflow() + self.get_first_task(workflow) + + study_result = self.put_study_on_hold(study_id) + self.assertEqual('Beer consumption in the bipedal software engineer', study_result.title) + + def test_first_cancel(self): + workflow, study_id = self.load_workflow() + workflow_api, first_task = self.get_first_task(workflow) + + self.complete_form(workflow_api, first_task, {}) + + study_result = self.put_study_on_hold(study_id) + self.assertEqual('New Title', study_result.title) + + def test_second_cancel(self): + + workflow, study_id = self.load_workflow() + workflow_api, first_task = self.get_first_task(workflow) + + self.complete_form(workflow_api, first_task, {}) + + workflow_api, next_task = self.get_second_task(workflow) + self.complete_form(workflow_api, next_task, {'how_many': 3}) + + study_result = self.put_study_on_hold(study_id) + self.assertEqual('Second Title', study_result.title) + + def test_after_cancel(self): + + workflow, study_id = self.load_workflow() + workflow_api, first_task = self.get_first_task(workflow) + + self.complete_form(workflow_api, first_task, {}) + + workflow_api, second_task = self.get_second_task(workflow) + self.complete_form(workflow_api, second_task, {'how_many': 3}) + + workflow_api, third_task = self.get_third_task(workflow) + self.complete_form(workflow_api, third_task, {}) + + study_result = self.put_study_on_hold(study_id) + self.assertEqual('Beer consumption in the bipedal software engineer', study_result.title) diff --git a/tests/test_lookup_service.py b/tests/test_lookup_service.py index e9da7fa8..1781e2ae 100644 --- a/tests/test_lookup_service.py +++ b/tests/test_lookup_service.py @@ -54,7 +54,8 @@ class TestLookupService(BaseTest): # restart the workflow, so it can pick up the changes. - processor = WorkflowProcessor.reset(workflow) + processor = WorkflowProcessor(workflow) + processor.reset(workflow) workflow = processor.workflow_model LookupService.lookup(workflow, "sponsor", "sam", limit=10) diff --git a/tests/workflow/test_workflow_boolean_default.py b/tests/workflow/test_workflow_boolean_default.py new file mode 100644 index 00000000..bd97d2ec --- /dev/null +++ b/tests/workflow/test_workflow_boolean_default.py @@ -0,0 +1,71 @@ +from tests.base_test import BaseTest + + +class TestBooleanDefault(BaseTest): + + def do_test(self, yes_no): + workflow = self.create_workflow('boolean_default_value') + workflow_api = self.get_workflow_api(workflow) + first_task = workflow_api.next_task + result = self.complete_form(workflow_api, first_task, {'yes_no': yes_no}) + return result + + def test_boolean_true_string(self): + + yes_no = 'True' + result = self.do_test(yes_no) + self.assertEqual(True, result.next_task.data['pick_one']) + + def test_boolean_true_string_lower(self): + + yes_no = 'true' + result = self.do_test(yes_no) + self.assertEqual(True, result.next_task.data['pick_one']) + + def test_boolean_t_string(self): + + yes_no = 'T' + result = self.do_test(yes_no) + self.assertEqual(True, result.next_task.data['pick_one']) + + def test_boolean_t_string_lower(self): + + yes_no = 't' + result = self.do_test(yes_no) + self.assertEqual(True, result.next_task.data['pick_one']) + + def test_boolean_true(self): + + yes_no = True + result = self.do_test(yes_no) + self.assertEqual(True, result.next_task.data['pick_one']) + + def test_boolean_false_string(self): + + yes_no = 'False' + result = self.do_test(yes_no) + self.assertEqual(False, result.next_task.data['pick_one']) + + def test_boolean_false_string_lower(self): + + yes_no = 'false' + result = self.do_test(yes_no) + self.assertEqual(False, result.next_task.data['pick_one']) + + def test_boolean_f_string(self): + + yes_no = 'F' + result = self.do_test(yes_no) + self.assertEqual(False, result.next_task.data['pick_one']) + + def test_boolean_f_string_lower(self): + + yes_no = 'f' + result = self.do_test(yes_no) + self.assertEqual(False, result.next_task.data['pick_one']) + + def test_boolean_false(self): + + yes_no = False + result = self.do_test(yes_no) + self.assertEqual(False, result.next_task.data['pick_one']) diff --git a/tests/workflow/test_workflow_processor.py b/tests/workflow/test_workflow_processor.py index 843a4f1f..21d9ba48 100644 --- a/tests/workflow/test_workflow_processor.py +++ b/tests/workflow/test_workflow_processor.py @@ -279,7 +279,7 @@ class TestWorkflowProcessor(BaseTest): self.assertFalse(processor2.is_latest_spec) # Still at version 1. # Do a hard reset, which should bring us back to the beginning, but retain the data. - WorkflowProcessor.reset(processor2.workflow_model) + processor2.reset(processor2.workflow_model) processor3 = WorkflowProcessor(processor.workflow_model) processor3.do_engine_steps() self.assertEqual("Step 1", processor3.next_task().task_spec.description) diff --git a/tests/workflow/test_workflow_restart.py b/tests/workflow/test_workflow_restart.py index dddbffeb..98688964 100644 --- a/tests/workflow/test_workflow_restart.py +++ b/tests/workflow/test_workflow_restart.py @@ -1,9 +1,11 @@ from tests.base_test import BaseTest +from crc import session +from crc.models.study import StudyModel -class TestMessageEvent(BaseTest): +class TestWorkflowRestart(BaseTest): - def test_message_event(self): + def test_workflow_restart(self): workflow = self.create_workflow('message_event') @@ -32,3 +34,49 @@ class TestMessageEvent(BaseTest): self.assertNotIn('formdata', workflow_api.next_task.data) print('Nice Test') + + def test_workflow_restart_on_cancel_notify(self): + workflow = self.create_workflow('message_event') + study_id = workflow.study_id + + # Start the workflow. + first_task = self.get_workflow_api(workflow).next_task + self.assertEqual('Activity_GetData', first_task.name) + workflow_api = self.get_workflow_api(workflow) + self.complete_form(workflow_api, first_task, {'formdata': 'asdf'}) + workflow_api = self.get_workflow_api(workflow) + self.assertEqual('Activity_HowMany', workflow_api.next_task.name) + + workflow_api = self.restart_workflow_api(workflow) + study_result = session.query(StudyModel).filter(StudyModel.id == study_id).first() + self.assertEqual('New Title', study_result.title) + + def test_workflow_restart_before_cancel_notify(self): + workflow = self.create_workflow('message_event') + study_id = workflow.study_id + + first_task = self.get_workflow_api(workflow).next_task + self.assertEqual('Activity_GetData', first_task.name) + + study_result = session.query(StudyModel).filter(StudyModel.id == study_id).first() + self.assertEqual('Beer consumption in the bipedal software engineer', study_result.title) + + def test_workflow_restart_after_cancel_notify(self): + workflow = self.create_workflow('message_event') + study_id = workflow.study_id + + # Start the workflow. + first_task = self.get_workflow_api(workflow).next_task + self.assertEqual('Activity_GetData', first_task.name) + workflow_api = self.get_workflow_api(workflow) + self.complete_form(workflow_api, first_task, {'formdata': 'asdf'}) + + workflow_api = self.get_workflow_api(workflow) + next_task = workflow_api.next_task + self.assertEqual('Activity_HowMany', next_task.name) + self.complete_form(workflow_api, next_task, {'how_many': 3}) + + workflow_api = self.restart_workflow_api(workflow) + study_result = session.query(StudyModel).filter(StudyModel.id == study_id).first() + self.assertEqual('Beer consumption in the bipedal software engineer', study_result.title) + diff --git a/tests/workflow/test_workflow_spec_api.py b/tests/workflow/test_workflow_spec_api.py index 5681270c..d54fbbf1 100644 --- a/tests/workflow/test_workflow_spec_api.py +++ b/tests/workflow/test_workflow_spec_api.py @@ -25,8 +25,10 @@ class TestWorkflowSpec(BaseTest): def test_add_new_workflow_specification(self): self.load_example_data() num_before = session.query(WorkflowSpecModel).count() + category_id = session.query(WorkflowSpecCategoryModel).first().id + category_count = session.query(WorkflowSpecModel).filter_by(category_id=category_id).count() spec = WorkflowSpecModel(id='make_cookies', name='make_cookies', display_name='Cooooookies', - description='Om nom nom delicious cookies') + description='Om nom nom delicious cookies', category_id=category_id) rv = self.app.post('/v1.0/workflow-specification', headers=self.logged_in_headers(), content_type="application/json", @@ -36,6 +38,9 @@ class TestWorkflowSpec(BaseTest): self.assertEqual(spec.display_name, db_spec.display_name) num_after = session.query(WorkflowSpecModel).count() self.assertEqual(num_after, num_before + 1) + self.assertEqual(category_count, db_spec.display_order) + category_count_after = session.query(WorkflowSpecModel).filter_by(category_id=category_id).count() + self.assertEqual(category_count_after, category_count + 1) def test_get_workflow_specification(self): self.load_example_data()