From 8a49edb7907edc517f258637fb38e1b5f41cced8 Mon Sep 17 00:00:00 2001 From: Ivan Cvitkovic Date: Wed, 7 Dec 2016 14:05:12 -0800 Subject: [PATCH 001/708] Use development version of persistance file for TravisCI --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index e85a0a686a..0419bf0a61 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,7 @@ env: global: - SQLALCHEMY_DATABASE_URI='postgresql://postgres:@localhost/portal_unit_tests' - LOG_FOLDER='/tmp/shared_service_log' - - PERSISTENCE_FILE='https://raw.githubusercontent.com/uwcirg/TrueNTH-USA-site-config/master/site_persistence_file.json' + - PERSISTENCE_FILE='https://raw.githubusercontent.com/uwcirg/TrueNTH-USA-site-config/develop/site_persistence_file.json' matrix: include: From 9d3afc2833109267ce02a8547f9163c7df1e9fce Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 7 Dec 2016 14:30:46 -0800 Subject: [PATCH 002/708] Update flask-user from 0.6.9 to 0.6.10 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6df629a0c6..769aa6c7b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ Flask-Session==0.3.0 Flask-SQLAlchemy==2.1 flask-swagger==0.2.13 Flask-Testing==0.6.1 -Flask-User==0.6.9 +Flask-User==0.6.10 Flask-WebTest==0.0.7 Flask-WTF==0.13.1 functools32==3.2.3.post2 From 8bd89527f42bdefbd49605e92fce0ad1d048a72c Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 7 Dec 2016 14:30:48 -0800 Subject: [PATCH 003/708] Update amqp from 2.1.1 to 2.1.2 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6df629a0c6..366d06c63e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ alabaster==0.7.9 alembic==0.8.9 -amqp==2.1.1 +amqp==2.1.2 anyjson==0.3.3 argcomplete==1.7.0 Authomatic==0.1.0.post1 From 8780798cb054d0fdd5bcadb2bed090500b455d1b Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 7 Dec 2016 18:02:56 -0800 Subject: [PATCH 004/708] Update amqp from 2.1.2 to 2.1.3 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9414929228..de7974294c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ alabaster==0.7.9 alembic==0.8.9 -amqp==2.1.2 +amqp==2.1.3 anyjson==0.3.3 argcomplete==1.7.0 Authomatic==0.1.0.post1 From a3f7d388743f472ed7e483b791d24a5030d00370 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 7 Dec 2016 18:02:58 -0800 Subject: [PATCH 005/708] Update kombu from 4.0.0 to 4.0.1 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9414929228..9dfd2499e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -44,7 +44,7 @@ itsdangerous==0.24 jedi==0.9.0 Jinja2==2.8 jsonschema==2.5.1 -kombu==4.0.0 +kombu==4.0.1 Mako==1.0.6 MarkupSafe==0.23 nose==1.3.7 From 13fb7bb087a8ed5abf2e7672953070affc6014f5 Mon Sep 17 00:00:00 2001 From: OptimusRhine Date: Thu, 8 Dec 2016 10:21:44 -0800 Subject: [PATCH 006/708] removing unused template files + profile_test ref --- portal/templates/access.html | 46 --- portal/templates/patient_profile.html | 108 ------- portal/templates/portal_old.html | 275 ---------------- portal/templates/profile_test.html | 450 -------------------------- portal/views/portal.py | 12 - 5 files changed, 891 deletions(-) delete mode 100644 portal/templates/access.html delete mode 100644 portal/templates/patient_profile.html delete mode 100644 portal/templates/portal_old.html delete mode 100644 portal/templates/profile_test.html diff --git a/portal/templates/access.html b/portal/templates/access.html deleted file mode 100644 index 276fb76c0c..0000000000 --- a/portal/templates/access.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends "layout.html" %} -{% block main %} - - -
- - -
- -
- -
- -
- -
- -
-
{{ _("Welcome to TrueNTH") }}
- {{ _("Tools for navigating the prostate cancer journey") }} -
- {{ _("Report Symptoms") }}     - {{ _("Complete Register") }} - -
- - -
- -
- - -
- -
- -{% endblock %} \ No newline at end of file diff --git a/portal/templates/patient_profile.html b/portal/templates/patient_profile.html deleted file mode 100644 index c0a6046000..0000000000 --- a/portal/templates/patient_profile.html +++ /dev/null @@ -1,108 +0,0 @@ -{% extends "layout.html" %} -{% block main %} -{% from "flask_user/_macros.html" import back_btn %} -{% from "profile_macros.html" import profile, profileSaveBtn %} - -{{ back_btn('patients', _('Patients List')) }} - -
- -
- - -
- -

- Patient #{{ patient.id}} - {{ patient.username }} -

- -
- -
-
- {{profile(patient, current_user)}} - {{profileSaveBtn()}} -
-
- -
-
- {{ back_btn('patients','Patients List') }} - -
- - -{% endblock %} - -{% block document_ready %} - -var patientId = {{ patient.id }}; -$(document).ready(function(){ - // When there's a change, check validation and if good, assembleContent - $('#profileForm').on('validated.bs.validator', function(e) { - var validator = $(this).data('bs.validator'); - if (!validator.hasErrors()) { - assembleContent.demo({{ patient.id }},true); - $("#errorMsg").hide() - } else { - $("#errorMsg").fadeIn("slow") - } - }); - - // Submit the form here - $("#updateProfile").on("click", function(event){ - event.preventDefault() - assembleContent.demo({{ patient.id }},true); - $("#confirmMsg").fadeIn(); - }); - -}); - -/** Remove patientQuestion functionality for now -var getQs = [ "biopsy", "pca_diag", "tx"]; -$.each(getQs, function(i,val){ - $.ajax ({ - type: "GET", - url: '/api/patient/{{ patient.id }}/clinical/'+val - }).done(function(data) { - var $radios = $('input:radio[name='+val+']'); - if($radios.is(':checked') === false) { - $radios.filter('[value='+data.value+']').prop('checked', true); - } - }).fail(function() { - console.log("Problem retrieving data from server.") - }); -}); - -$("#patientQuestions input").on("change", function(){ - var toCall = $(this).attr("name"); - var theVal = $(this).val(); - $.ajax ({ - type: "POST", - url: '/api/patient/{{ patient.id }}/clinical/'+toCall, - contentType: "application/json; charset=utf-8", - dataType: 'json', - data: JSON.stringify({value: theVal}) - }).done(function() { - // Allow user to save with button, even though these answers have already been submitted - $('#updateProfile').removeClass("disabled") - }).fail(function() { - alert("There was a problem saving your answers. Please try again."); - }); -}); -**/ - -/** Get and Put roles **/ -tnthAjax.getRoles({{ patient.id }}, true); -$("#rolesGroup input:checkbox").on("click",function(){ - - var roles = $("#rolesGroup input:checkbox:checked").map(function(){ - return { name: $(this).val() }; - }).get(); - var toSend = {"roles": roles}; - console.log("roles: ", toSend); - tnthAjax.putRoles({{patient.id}},toSend); - -}); - -{% endblock %} diff --git a/portal/templates/portal_old.html b/portal/templates/portal_old.html deleted file mode 100644 index 06b6f35cc7..0000000000 --- a/portal/templates/portal_old.html +++ /dev/null @@ -1,275 +0,0 @@ -{% extends "layout.html" %} -{% block main %} -{% from "flask_user/_macros.html" import left_nav %} -
-
-
-
- {% set intervention = interventions.decision_support_p3p %} - {% if intervention.user_has_access(user) %} -

Decision Support

-
Which option is best for me?
-

You're unique, and you have your own set of values and preferences. Learn which treatment options might best suit your specific needs and concerns, and watch examples of how patients discuss similar concerns with their urology doctors.

- {{ intervention.card_html }} - {% endif %} -
-
-
-
- {% set intervention = interventions.self_management %} - {% if intervention.user_has_access(user) %} -

Self Management

-
How do I cope with my symptoms?
-

Assess the severity of your symptoms, and the extent to which they bother you, in a variety of areas common to men who have been treated with prostate cancer. Learn strategies to address those symptoms, specifically for men like you.

-

{{ intervention.card_html }}

- {% endif %} -
-
-
-
- {% set intervention = interventions.care_plan %} - {% if intervention.user_has_access(user) %} -

Care Plan

-
How do I move forward after treatment?
-

You’ve been treated for prostate cancer, so what’s next? Develop a customized survivorship plan, to help remind you of your health needs as you move back to the care of your regular doctor.

- {{ intervention.card_html }} - {% endif %} -
-
- - -
-
- {% set intervention = interventions.community_of_wellness %} - {% if intervention.user_has_access(user) %} -

Community of Wellness

-
How can I be healthy?
-

Health covers a broad range of topics, including exercise, diet, friends and social environments. Learn how to improve your overall health as you progress through your treatment.

-

- {% endif %} -
-
-
-
- {% set intervention = interventions.sexual_recovery %} - {% if intervention.user_has_access(user) %} -

Sexual Recovery

-
What about sex?
-

Treatment can impact sexual function. Assess your particular needs and see specific strategies for both you and your partner to develop a new normal in your sex life.

-

- {% endif %} -
-
-
-
- {% set intervention = interventions.social_support %} - {% if intervention.user_has_access(user) %} -

Social Support Network

-
Who can I talk with?
-

Find out how to connect with others who've experienced what you're going through. Building a strong support network leads to better outcomes.

-

- {% endif %} -
-
-
-
- -
- -
-

TrueNTH

-

You have told us that you have not yet had a prostate cancer biopsy. You can browse through our information on prostate cancer:

-

You have told us that you do not yet know if you have prostate cancer, or you have been told you do not have prostate cancer. You may wish to look at the options below:

-

You have told us that you have been diagnosed but have not yet started treatment. Would you like to:

-

You have told us that you have already begun treatment. Would you like to:

- -
-
-
- {% set intervention = interventions.decision_support_p3p %} - {% if intervention.user_has_access(user) %} -

Decision Support

-
Which option is best for me?
-

You're unique, and you have your own set of values and preferences. Learn which treatment options might best suit your specific needs and concerns, and watch examples of how patients discuss similar concerns with their urology doctors.

- {{ intervention.card_html }} - {% endif %} -
-
-
-
- {% set intervention = interventions.self_management %} - {% if intervention.user_has_access(user) %} -

Self Management

-
How do I cope with my symptoms?
-

Assess the severity of your symptoms, and the extent to which they bother you, in a variety of areas common to men who have been treated with prostate cancer. Learn strategies to address those symptoms, specifically for men like you.

-

{{ intervention.card_html }}

-
- {% endif %} -
-
-
-

Information Page

-
Links and whatnot
-

Generic links would either be here or be listed on a sub-page.

- Info Page -
-
- -
- -
-
- -

Other Interventions

-

For demo use

- -
- {% set intervention = interventions.decision_support_p3p %} - {% if intervention.user_has_access(user) %} -

Decision Support

-
Which option is best for me?
-

You're unique, and you have your own set of values and preferences. Learn which treatment options might best suit your specific needs and concerns, and watch examples of how patients discuss similar concerns with their urology doctors.

- {{ intervention.card_html }} - {% endif %} -
- -
- {% set intervention = interventions.self_management %} - {% if intervention.user_has_access(user) %} -

Self Management

-
How do I cope with my symptoms?
-

Assess the severity of your symptoms, and the extent to which they bother you, in a variety of areas common to men who have been treated with prostate cancer. Learn strategies to address those symptoms, specifically for men like you.

-

{{ intervention.card_html }}

- {% endif %} -
- - {% set intervention = interventions.care_plan %} - {% if intervention.user_has_access(user) %} -

Care Plan

-
How do I move forward after treatment?
-

You’ve been treated for prostate cancer, so what’s next? Develop a customized survivorship plan, to help remind you of your health needs as you move back to the care of your regular doctor.

- {{ intervention.card_html }} - {% endif %} - - {% set intervention = interventions.community_of_wellness %} - {% if intervention.user_has_access(user) %} -

Community of Wellness

-
How can I be healthy?
-

Health covers a broad range of topics, including exercise, diet, friends and social environments. Learn how to improve your overall health as you progress through your treatment.

-

- {% endif %} - - {% set intervention = interventions.sexual_recovery %} - {% if intervention.user_has_access(user) %} -

Sexual Recovery

-
What about sex?
-

Treatment can impact sexual function. Assess your particular needs and see specific strategies for both you and your partner to develop a new normal in your sex life.

-

- {% endif %} - - {% set intervention = interventions.social_support %} - {% if intervention.user_has_access(user) %} -

Social Support Network

-
Who can I talk with?
-

Find out how to connect with others who've experienced what you're going through. Building a strong support network leads to better outcomes.

-

- {% endif %} - -
-
- -
- -
- - -{% endblock %} diff --git a/portal/templates/profile_test.html b/portal/templates/profile_test.html deleted file mode 100644 index 295a919a1d..0000000000 --- a/portal/templates/profile_test.html +++ /dev/null @@ -1,450 +0,0 @@ -{% extends "layout.html" %} -{% block main %} -{% from "flask_user/_macros.html" import back_btn %} - -{% if current_user.has_role('admin') and HTTP_REFERER in request.environ and request.environ.HTTP_REFERER.endswith("admin") %} -{{ back_btn('admin','User List') }} -{% endif %} - -
-
-

{{ _("Saving") }}...

-
- -

-{% if current_user.has_role('admin')%} -{{ _("Profile for") }} {{ user.username }} -{% else %} -{{ _("My TrueNTH Profile") }} -{% endif %} -

- -
- -
-
- - {% if user.image_url %} -
-
- -
-
- {% endif %} - - -
-
-
- - -
-
-
-
-
- - -
-
-
-
- -
- -
-
- -
-
- -
-
- -
-
- -
-
- -
- - - -
-
-
- - -
-
-
- - -
- - - - - -
- - -
- - -
- -{% if current_user.has_role('admin') and not user.has_role('service') %} -

{{ _("User Roles for") }} {{ user.username }}

-
-
-

{{ _("Editable by admins only.") }}

-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-{% endif %} - -
- -{% endblock %} -{% block document_ready %} -$(document).ready(function(){ - -var showClass = "show" -// Not using "on" class at the moment -var onClass = "on" -$(".form-with-floats .form-group > label").each(function(){ - console.log($(this)) - var label = $(this) - setTimeout(function() { - label.addClass("after-load") - }, 300) -}) -$(".float-input-label > input").bind("checkval",function(){ - var label = $(this).prev("label") - if(this.value !== ""){ - label.addClass(showClass); - } else { - label.removeClass(showClass); - } -}).on("keyup",function(){ - $(this).trigger("checkval") -}).on("focus",function(){ - $(this).prev("label").addClass(onClass) -}).on("blur",function(){ - $(this).prev("label").removeClass(onClass) -}).trigger("checkval"); - - /*** Field Validation ***/ - $('#profileForm').validator({ - custom: { - birthday: function() { - var m = parseInt($("#month").val()); - var d = parseInt($("#date").val()); - var y = parseInt($("#year").val()); - // If all three have been entered, run check - var goodDate = false; - var errorMsg = "Sorry, this isn't a valid date. Please try again."; - if (m && d && y) { - var today = new Date(); - // Check to see if this is a real date - var date = new Date(y,m-1,d); - if (date.getFullYear() == y && date.getMonth() + 1 == m && date.getDate() == d) { - goodDate = true; - // Only allow if birthdate is before today - if (date.setHours(0,0,0,0) >= today.setHours(0,0,0,0)) { - goodDate = false; - errorMsg = "Your birthdate must be in the past."; - } - } - if (y.toString().length < 3) { - goodDate = false; - errorMsg = "Please make sure you use a full 4-digit number for your birth year."; - } - // After tests display errors if necessary - if (goodDate) { - $("#errorbirthday").hide(); - // Set date if YYYY-MM-DD - $("#birthday").val(y+"-"+m+"-"+d); - } else { - $("#errorbirthday").html(errorMsg).show(); - $("#birthday").val(""); - } - } else { - // If NaN then the values haven't been entered yet, so we - // validate as true until other fields are entered - if (isNaN(y) || (isNaN(d) && isNaN(y))) { - return true; - } else if (isNaN(d)) { - errorMsg = "Please enter a date."; - } - $("#errorbirthday").html(errorMsg).show(); - $("#birthday").val(""); - } - if (goodDate) { - return true; - } else { - return false; - } - - }, - customemail: function($el) { - if ($el.val() == "") { - return false; - } - var emailReg = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; - return emailReg.test( $el.val() ); - } - }, - errors: { - birthday: "Sorry, this isn't a valid date. Please try again.", - customemail: "This isn't a valid e-mail address, please double-check." - }, - disable: false - }); - // If there's any change to form fields, the note that - $("#profileForm :input").change(function() { - $(this).closest('form').attr('data-changed', true) - }); - $('#profileForm').on('validated.bs.validator', function(e) { - var validator = $(this).data('bs.validator'); - // If the form has a change, then show save or error messages - if ($(this).data('changed')) { - if (!validator.hasErrors()) { - $("#saveMsg").fadeIn("slow") - $("#errorMsg").hide() - } else { - $("#saveMsg").hide() - $("#errorMsg").fadeIn("slow") - } - } - }) - /*** End -- Field Validation ***/ - - // Fill in 3 birthday fields based on existing value - requires date format YYYY-MM-DD - var currentBd = $("#birthday").val(); - if (currentBd) { - var bdArray = currentBd.split("-"); - $("#year").val(bdArray[0]); - $("#month").val(bdArray[1]); - $("#date").val(bdArray[2]); - } - - // Submit the form here - $("#updateProfile").on("click", function(event){ - event.preventDefault() - - $("#profileForm").addClass("loading"); - $("#loadingIndicator").show(); - - // Grab profile field values - var getBirthday = $("input[name=birthDate]").val() - var getFirstname = $("input[name=firstname]").val() - var getLastname = $("input[name=lastname]").val() - var getSex = $("input[name=sex]:checked").val() - var getEmail = $("input[name=email]").val() - var getPhone = $("input[name=phone]").val() - - // Put form data into FHIR array - var demoArray = {}; - demoArray["resourceType"] = "Patient" - demoArray["birthDate"] = getBirthday - demoArray["gender"] = getSex - demoArray["name"] = { - "given": getFirstname, - "family": getLastname - }; - demoArray["telecom"] = [ - { "system": "email", "value": getEmail }, - { "system": "phone", "value": getPhone } - ]; - /** Send the AJAX **/ - $.ajax ({ - type: "PUT", - url: '/api/demographics/{{ user.id }}', - contentType: "application/json; charset=utf-8", - dataType: 'json', - data: JSON.stringify(demoArray) - }).done(function() { - $("#saveMsg").hide() - setTimeout(function(){ - $("#profileIntro").html("Your changes have been saved. Return to the main portal page {% if current_user.has_roles('admin') %} or user administration{% endif %}."); - $("#profileForm").removeClass("loading"); - $("#loadingIndicator").fadeOut(); - }, 1200); - }).fail(function() { - alert("There was a problem updating your profile. Please try again."); - $("#loadingIndicator").fadeOut(); - $("#profileForm").removeClass("loading"); - }); - }); -}); - -/** Remove patientQuestion functionality for now -var getQs = [ "biopsy", "pca_diag", "tx"]; -$.each(getQs, function(i,val){ - $.ajax ({ - type: "GET", - url: '/api/patient/{{ user.id }}/clinical/'+val - }).done(function(data) { - var $radios = $('input:radio[name='+val+']'); - if($radios.is(':checked') === false) { - $radios.filter('[value='+data.value+']').prop('checked', true); - } - }).fail(function() { - console.log("Problem retrieving data from server.") - }); -}); -$("#patientQuestions input").on("change", function(){ - var toCall = $(this).attr("name"); - var theVal = $(this).val(); - $.ajax ({ - type: "POST", - url: '/api/patient/{{ user.id }}/clinical/'+toCall, - contentType: "application/json; charset=utf-8", - dataType: 'json', - data: JSON.stringify({value: theVal}) - }).done(function() { - // Allow user to save with button, even though these answers have already been submitted - $('#updateProfile').removeClass("disabled") - }).fail(function() { - alert("There was a problem saving your answers. Please try again."); - }); -}); -**/ -// GET and PUT user roles -$.ajax ({ - type: "GET", - url: '/api/user/{{ user.id }}/roles' -}).done(function(data) { - $.each(data.roles,function(i,val){ - $("#rolesGroup input:checkbox[value="+val.name+"]").prop('checked', true); - }) -}).fail(function() { - console.log("Problem retrieving data from server.") -}); - -$("#rolesGroup input:checkbox").on("click",function(){ - var roles = [] - roles.push({name: $(this).val()}) - var toSend = {"roles": roles} - // If role is checked, type is PUT, otherwise DELETE - var ajaxType = "DELETE" - if ($(this).prop('checked')) { - ajaxType = "PUT" - } - $.ajax ({ - type: ajaxType, - url: '/api/user/{{ user.id }}/roles', - contentType: "application/json; charset=utf-8", - dataType: 'json', - data: JSON.stringify(toSend) - }).done(function(data) { - }).fail(function() { - console.log("Problem updating role on server.") - }) -}) -{% endblock %} diff --git a/portal/views/portal.py b/portal/views/portal.py index c08a4f4e45..07f545a227 100644 --- a/portal/views/portal.py +++ b/portal/views/portal.py @@ -428,18 +428,6 @@ def profile(user_id): 'asset': response.text, 'agreement_url': consent_url, 'organization_name': org.name} return render_template('profile.html', user=user, consent_agreements=consent_agreements) -@portal.route('/profile-test', defaults={'user_id': None}) -@portal.route('/profile-test/') -@oauth.require_oauth() -def profile_test(user_id): - """profile test view function""" - user = current_user() - if user_id: - user.check_role("edit", other_id=user_id) - user = get_user(user_id) - return render_template('profile_test.html', user=user) - - @portal.route('/legal') def legal(): """ Legal/terms of use page""" From 32c3c28901df517943fb743bf30b813a9cbe4ac9 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Thu, 8 Dec 2016 15:11:48 -0800 Subject: [PATCH 007/708] Another round of wisercare icons. --- portal/static/img/wisercare-small.png | Bin 6237 -> 0 bytes portal/static/img/wisercare.png | Bin 7051 -> 2550 bytes portal/static/img/wisercare_sm.png | Bin 0 -> 2367 bytes portal/views/truenth.py | 2 +- 4 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 portal/static/img/wisercare-small.png create mode 100644 portal/static/img/wisercare_sm.png diff --git a/portal/static/img/wisercare-small.png b/portal/static/img/wisercare-small.png deleted file mode 100644 index fde4d503bf5eb2622cb69511bb7ff8052e5a8481..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6237 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 TnE1(AF94XE+L%-tz4+@t>$rUI literal 7051 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 diff --git a/portal/static/img/wisercare_sm.png b/portal/static/img/wisercare_sm.png new file mode 100644 index 0000000000000000000000000000000000000000..c28e31c6a7d2fd7f0eb29d51ebbeae1850ce9db8 GIT binary patch literal 2367 zcmc(g`#;l-8^%A|oKkyeQA)I?o*Et{b0}e0JxC>|R3tK$S~7Dgs+E{iltY%$M$XD1 zhnym3IgN%)!z`1-Mvj{ej~~DP!S{N-?(4em*X#c6`r#d6bKzg8>^@ll0HBr@7~8Fk z+Zq!Y$d()Q6&G*CUcwnCf}OV~A;1lf1x!7>-LY^>oZBs|E!NE=$fq598UR2~ZLICj zP#ClQv%}*HToQ>iwLH7Lyj(TbR66oUDBiIBG@8bSFsmza&n?Nr*ekow!Yn~*i*|Q*1V8Dy;AbC_Ei_n?`y=;+RDV@RM*r>NORm* z&bNOueuy_VlY6uAUni=ot6L_zpSS3z(Hi+Msw{ zY??p(v?V?uAV4>3=wTE0eDR2XBR8pcrk}z1!DAH+&->Jc%?lQ^GlxQ2CdU?pS1QI- zDgAYv#R1;xtDfnY&NL=}Z1m5s>z~=&r8PdEPiiNJwk1&+ABXsY-svB_Rq@}{0ijSR zS{GRrveq}&!`hP(2!wBa_?+NRLeG0Hmy5w*$Yipor>BmNPG@K5sz@LduQ@n4%&v-d z?%WyMMHPsJ8yg!@9hChk{RY|0#$TO|Wuv^+`Cp4`gM0z8ZL)7>bzUI8U-zqha{0@6 zYt|sYfzz>7)8^*pm3laRYkNwty{yzm6NS?a|08;Ii7*qQ|=IomYyM5>6!z&w3 zMv^Gc5M~$~68@+a0YQqCerCncgk6XYOv%BpgoC4h8{ZC{Ey4@Q<*+j?Wod#@u$MMpj+mBG ziE+5iG~e%TFSfAyPS|z{yH8nR_-b>Vt-Ucda@XgQ>1^f4WbMC{NV0k3>UWAUuY3-W zRo|I=gBtP=9H&u_KB=-T=yR~wf`tbohpiHAmfP&Ij2>GDJQ&OFNKOH%k4qs{oio4N zMmr{)K#G1me`HNhTdE#F(>%M)YGiyWY4c4+D6=$tW}ATo>CT6~_ZA%nyPK_s8V@yn z2l5c8UL}^Vjj@%^Vli@&UKeBYJC~FO%Cnt7t>mhLY|NW>MSDdh9ziSG8flxdogCy3 z{EkH(qV5K>H60>tWivGtF+qW7Qp36ZiQjB7u&e3$vFP{2_d9}KzNCZnT%invS#-|2 zLK4yifj*4z%1R_X1iQgCcTfzkhZtIhxcRBmw z9C2Dc+ZO()*REKvRNF8YAFj6aR=R@z#=TVhchl~YhT+$Q7e3xSWu^~UPPhNun=H@G z5_X?_bN8d~2>qP+ow?h1ScVCy!mo0l(I8li{CcHy8~eq4%9~Gnvs=i>Xzx>mAr9^x z(?5?W&(-DR=sC3+q;^L?)I9VPE2X9R5N9OYJ*Xsln`4PWMcJ77?M5lPYnvNUqBx|c zQuzMZ=5aT9)cDJAUm(YG&n{(`mN^S3ZylT#Wt{w{eS+g3a~5*(u1HeGZV!xn58HH5 zB&B;rNOfjOe*%H%?l5Z|w_HfADiES}uM!lhWh#v?e3mOQTsIh5mT29QJn}8m0}q0v zK0j3#)%LAa#ig~${f3bx$(&9Mg+uPaJ|LpwE_dTNKEiNZkbd{;95uc(oK`> z0L2X#N%OX8u_k)o?&1l&mp8FrYcLm?$X% zV+`t$hYB(SeGWeW!f4XNW;9!_xvyy6V$Y5^FB}0)bJfFWXN&p^*Vf*`S|xVG)v$Tl z2HQ1Q(jjcI-d&p9W!#*F&iqPMX@=GJAY+WF+x^n20sWpI-N!1C4YeySg z2VPGE&8p4Y|MU4&4ZMa%(~;PpcksW%Y) zu0D~AJT1Grz%R6A|7%a5w$O^&qvV=*+jYGBIF#mk7!1&cx~R*env;308o}_I{6Gnr zu6oihNqE63d3R52)1e1Gd)i>ezP|c(;De~R?<2qCnRCY=Y)ZRuPo0j57)6U6*0{lxlT zUux2&%*^O+vq(H9c<~1p^t~KOiMQm`Zg;=uTS{gNwVRpoXC43K?u0EO7{;QMwe3R{2$EYDoPd^Ef9 F>^}rd`5*uQ literal 0 HcmV?d00001 diff --git a/portal/views/truenth.py b/portal/views/truenth.py index ff2af4fc6b..20addde87a 100644 --- a/portal/views/truenth.py +++ b/portal/views/truenth.py @@ -165,7 +165,7 @@ def branded_logos(): filename="img/{}.png".format(brand_name), _external=True), url_for('static', - filename="img/{}-small.png".format(brand_name), + filename="img/{}_sm.png".format(brand_name), _external=True) ) From 723a279007c127459f9711d2018d637e0f27738b Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Thu, 8 Dec 2016 15:25:46 -0800 Subject: [PATCH 008/708] Tony provided another round of WC icons. --- portal/static/img/wisercare.png | Bin 2550 -> 2365 bytes portal/static/img/wisercare_sm.png | Bin 2367 -> 2224 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/portal/static/img/wisercare.png b/portal/static/img/wisercare.png index d6cb301e1df2a45f14a28b2048f6c9139885a278..2621f833a90b1a83b2e4b5d30d39b298272c8e55 100644 GIT binary patch delta 2192 zcmV;B2ygfH6TK3UPJffmqo&uhGNsvd!HKQfxp~8l@%j8}zT?X0(#+}hvE04&`~A%5 z)T!C;TDRartlV+I<%P-U;qm2hzlPoL`BSsswcqh8qSqju)xF~Lso1vV^XcL8`J>nF z?)Ut`*B;PTk*-Yuip zwcfz#_3VJgllA)c+3xs##E`w=$J_7WJ*wMYxZ!oe<@^2q)a=^E=Jn?E`ql0ChRK+k z(y8C^etuq_<+af)a%+er`o~f^NY)z zp3|)S{rvFw{J-MJu-x$b{rjBL?2yf&R<+<6n$-XQ{}2EF|4O0Ipa1{>fOJw$Q1;s%D1r@Cp+!sVerS4X()#BdT zMX@Ng)Y`??+MD_7?`9#HOmM}%_j*p|98Yp5m&unqcN>f7-!qC#R+H6aHCatolhtH3 zSxr`x)nqkU|87=6c+fWp>U!5Ntm+9@=@rB(+ zng*lFKh#Ni78W!#O@)8?ZCDutt8211IwGBhj+3&_sUWbH(*z;!g12SWdpkGJzLT9} z_P{XB2$Wq|U?1rgEQnRxEJh(OtER}P=3`aA%*ZMf$jV!dV`bblk1ES5XslG{Mi0hn zw11(&u3VKKp&db=(tz=fYBrVE!MnI&atu@G{3~6j(ChLF5YWCVDtI2AznymL@?b>s@=a|VlcO4@+-0lkhtIc>J zuQ?g(wcsePd{!vyW|i05#!P0s8Af{Q9e=Kf6t7eig4&*p9v+fI`-}3}_rh3@X8;yg z)wu~D^e|*AP)ciIt?~Y-mHh;VJ!%&(Z>9?K*TE3LxH{(qfE`uZorW&DkSZ>@H>wQ@ z55}IITs+>M8oIQgf0-R!7}+`v4(1|rFEHm0 z<07G+mD4zVW+m2c!+RfBmLDW1>zA|Jly-isq;$I}*3mSH&T?pfe$Yqhhu9C>x5;_i z#|ECY5tr^#U7#uq5L6^TfCDd`vVSL+qO&L?<&tRYhSiBu8NU>IP#Si*!i%Bblv)U} z8BiZd9xRRLw$(lKI1ckWM4CcoP49klzas&KH)<=0Yx1?6@G_I3bj)dykZ%$%*D z*%QSuy|6j{$&*;$%nTQc@z;V|5a~*I=7J*_pOR^-k@Pemo8X8p*@<e-0Ut?#{LXuOuU{pkes<`YB3pB?%2bgJfqYmJej(m7uV)b0f zC1nD%6cgrLMCMwhM98caE-5*kZT)gc zH8{QqYeAm}jGJlDTQ0IEe7khi#6thz0-Lujm`3%_e|iOC*A zaDNfJb?F-d0~D`@MSnYStkNEgZ>0d7qQ4qj zMLlbumDraI{`&qd{+jPxfDMe>D^*n4C8pbaslmLgIDUzxEdr^;4XpIF5*fNYFn?1X zXug594ZsIgxhOxyN~Ts?BkTDZgvDl|~vrR9V~Yxst?H&5MCSvxO@wdRJiA+{f&p1V4$(99D$*6|5d zwFJ|bU+Tin9O`7eYa$Qp_as_&Xh0S&rs~sTn@HMKhfYU%RvJ4b{V`Sg(A;Na_2=qr zuIiRjmh}iiwKKtY4#)b&XtA>UJJ?LRTDlyGIe*($kAGB6cvgBL9t`YBi_K<9MzNd1 zh6Z7adb49?z4K3q+GlC36#E&H?9EO!)HF9)`juVKv0@IIW!h@3rNx>MYNY3%8@ zx3AsH^>_wqxYe;1S0WHnh$);~M`5nupYrZ6QC SNJQTN0000DBDpyy3>=^XRhO@!|31i_4s2y5h{} z)2`aOaleME+V4a}M7Q6=HGkZ!<<6wmvEK3do`2QtPO;uat=#ke)$I4e$B5D@YB z^Ml8h$mjJ)uYcWhz=y2c@1@x8rq{Ed)UDd@_&}@M@A&dVt=yf{tB8n*W@ct0A|kuH zyP27pGcz;p?(R6J+IM$%s;a70RaNZv@BaS&n9--?^ZLQ$^TXuK(CXLM?)Y!O|{17<5*hRcF;%byl5K zXVqDC*8iVX5XyY|ff!`h7_fx;Z`e+f7e>3X?8rV+>qL|6@ zg%VPtct}VZE-5G37Y;>Q4g?`1iZEc1b`28YP!OF*aAO}>1=bZbAf%v^kmWPT6DNI8 zCJ7p#k07+)le|x|0)!JrWvtNyk{0)~Y#4eA=0M{rz$p75yi6vW&19gOglZ7}oCnY) zn}0fDtnd(LiZ4|3kp7rt#XQxkkUYrK2?^1B@a6Baj+C&%HI?M7z(|hRqGuUvmTQ1Y zB`BuzqLaY9oI-}sDP~y&p(&YStQo2pu8G48ITFNdK1w+2{7V{*wCEfg`8C#m?`KUZ zSkr7cXcLbz(k;fCWUOhX2r*5oAP|s6mVfD0Vofsk14+iwd@6l(B(d^Gr=JqHK}k4r zpI+5*jpjjabaXW<3!=1FIMKe6mB*ELi6+JDAw221tWf6XA7=%<0sv(_h-z6$it1Mk z(rPjlq%v2s!ZHUXy^q zVTlZSTthoUH8=4JLS_+Ko6rpVv#Z}4vh=YHvNBD`*a{(G`{(QHHv@JH>wgfgJv*zd z?9s9=Ixv1MwM6i$gGqvwTNB=vI|nxDJXmpDcv;g->nd-^HFN%U>~MjIu$z925(`3G znozDJmbrYFXYJuoY=Dru*lZir8yn%{nvjL>uXu-E{}+vC!MYF0i(b=ElteRjK*Y=~ zM)({Z#(1xJ+|%&dShLQ0On=+}lxy7MF~5NAIm21u+qj{kqhjzBrt==5el==UFG3r2 z){s3>&4B&^R=BkJ`o_lIU7c4FJ1G3U{p`#Ri}LDR%{mBCkV3qNJr!>3=8{;<;c>XI zy%pHO?s#E21KxJ{1k~11-f)`3?pw~98ByT3KkhilJ&*HY>|$V?@R3g>W!aHPYch1j7^DAQ%i|-7RWbDlTklFV>70 zx2swEAztdYxee5-P=D|j4J*vWQoL_3)(WiMP25yrmjTe-I*l&7?#irFFo6!`s2~uB zVvB>BnANOSg#0SjN`w;R5muPXmLl1MDErm<0GwSeCO7veix?f3#PMo{%-WrU*qbAZ z@?!?wxJEH5sJvCp3Q?(3vDTLEHVrE@tSS5VqBb>agj?(lUw?Fv#4uAaH&@0=Zgb3> zYF-D7gwTMB)mpN|T2_erY?q?+qH0Z^1^X#Z0!@5Vbx%@X0C$*E zu#yh*@H#V7(++Wh({vwwlzlH#3EvG&E#@=r}%yB=e$0>`Q?DHaXu9fWewSU3czO$J`zO`>9D zr|{@5oC@m|kk(viPR~--`?(u!kPhGQ{nSq9t9tAb@ z7;eHlPq5MzpH-b&rPiQf?b(tA`Y`J@=n${o6@9@ZpMSI(R=W8bzlGb1SC|334I}9vr(S+!!i{lTndZ-;w;S$rjkCiTY<9SIB;Ick;Zh;Y;OgzO( z6IZX|eE=k>WL>n47JYvmJG$8pbS=q8!?yPxR;mKzuYAkFx&|)EN64W;1OSC@AGK%(I;+mAv+ArmtIn#kLitaC0RWP#-3mQ)8T|kN002ovPDHLkV1m2W`LX~2 diff --git a/portal/static/img/wisercare_sm.png b/portal/static/img/wisercare_sm.png index c28e31c6a7d2fd7f0eb29d51ebbeae1850ce9db8..32657b70a1cc53a02dd3143c5037c787f7b1cc1b 100644 GIT binary patch delta 2048 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 SQymll0000FS From 7d92ea2a248bc0815bda20270b79511c0ebdcf0e Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Fri, 9 Dec 2016 05:28:26 -0800 Subject: [PATCH 009/708] Update docutils from 0.12 to 0.13.1 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8d68b37774..f3e5a5379a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,7 @@ CommonMark==0.5.4 # pyup: <=0.5.4 # pin as workaround to https://github.com/rtf coverage==4.2 decorator==4.0.10 docopt==0.6.2 -docutils==0.12 +docutils==0.13.1 Flask==0.11.1 Flask-Babel==0.11.1 Flask-Celery-Helper==1.1.0 From 16c15c95519423e1c7913326aa187583c9947e1e Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Fri, 9 Dec 2016 11:02:52 -0800 Subject: [PATCH 010/708] Update flask-migrate from 2.0.1 to 2.0.2 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8d68b37774..15a0ec71b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,7 +23,7 @@ Flask-Babel==0.11.1 Flask-Celery-Helper==1.1.0 Flask-Login==0.4.0 Flask-Mail==0.9.1 -Flask-Migrate==2.0.1 +Flask-Migrate==2.0.2 Flask-OAuthlib==0.9.3 Flask-Script==2.0.5 Flask-Session==0.3.0 From 4cc464672b29375f31bcaa4903eb40aafe6cc0b6 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Fri, 9 Dec 2016 13:25:41 -0800 Subject: [PATCH 011/708] Update celery from 4.0.0 to 4.0.1 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cb9ebc7828..b218894d32 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ bcrypt==3.1.1 beautifulsoup4==4.5.1 billiard==3.5.0.2 blinker==1.4 -celery==4.0.0 +celery==4.0.1 cffi==1.9.1 click==6.6 CommonMark==0.5.4 # pyup: <=0.5.4 # pin as workaround to https://github.com/rtfd/recommonmark/issues/24 From f5631adff57c37822b0e6e470ecbc86e5beed43a Mon Sep 17 00:00:00 2001 From: Ivan Cvitkovic Date: Mon, 12 Dec 2016 11:42:21 -0800 Subject: [PATCH 012/708] Add check before attempting to extract git hash from version --- portal/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portal/app.py b/portal/app.py index a7b0ecf721..e3f8458796 100644 --- a/portal/app.py +++ b/portal/app.py @@ -215,7 +215,7 @@ def configure_metadata(app): # Get git hash from version if present # Todo: extend Develop instead of monkey patching - if '+ng' in metadata.version: + if metadata.version and '+ng' in metadata.version: metadata.git_hash = metadata.version.split('+ng')[-1].split('.')[0] app.config.metadata = metadata From d3ca0aa031cdb57f5ff4bc1cb3b2af01709a5c35 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Mon, 12 Dec 2016 16:03:15 -0800 Subject: [PATCH 013/708] Another round of WC icons. --- portal/static/img/wisercare.png | Bin 2365 -> 3337 bytes portal/static/img/wisercare_sm.png | Bin 2224 -> 2611 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/portal/static/img/wisercare.png b/portal/static/img/wisercare.png index 2621f833a90b1a83b2e4b5d30d39b298272c8e55..23700b937ea8841eecb56879eb57f9ef1290a7dc 100644 GIT binary patch delta 3332 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 diff --git a/portal/static/img/wisercare_sm.png b/portal/static/img/wisercare_sm.png index 32657b70a1cc53a02dd3143c5037c787f7b1cc1b..0cbed17caa05a8941c0765fd31feb4b4af773cf3 100644 GIT binary patch delta 2600 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 Date: Mon, 12 Dec 2016 20:07:02 -0800 Subject: [PATCH 014/708] /api/coredata/acquire - relabel "Skip This" button to "Continue" https://www.pivotaltracker.com/story/show/135986271 --- portal/templates/coredata.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portal/templates/coredata.html b/portal/templates/coredata.html index 9fe716fba8..5f4875b5a6 100644 --- a/portal/templates/coredata.html +++ b/portal/templates/coredata.html @@ -173,7 +173,7 @@

{{ _("More About You") }}



-
{{ _("Skip This") }}
+
{{ _("Continue") }}
{% endif %} From cb7a8d7959f06639ee31b9d46a05cdc81da99ab9 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Tue, 13 Dec 2016 04:08:16 -0800 Subject: [PATCH 015/708] Restoring write_only restriction on generating access_url tokens. --- portal/views/user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/portal/views/user.py b/portal/views/user.py index 5c125a029d..a09df7fd05 100644 --- a/portal/views/user.py +++ b/portal/views/user.py @@ -253,10 +253,10 @@ def access_url(user_id): if not has.isdisjoint(not_allowed): abort(400, "Access URL not provided for privileged accounts") - #if not ROLE.WRITE_ONLY in has: + if not ROLE.WRITE_ONLY in has: # KEEP this restriction. Weak authentication (which the # returned URL provides) should only be available for WRITE_ONLY users - # abort(400, "Account {} lacks WRITE_ONLY role".format(user_id)) + abort(400, "Account {} lacks WRITE_ONLY role".format(user_id)) # generate an access token token = user_manager.token_manager.generate_token(user_id) From b9c1eb8c6031e2eab64595f6088173cc9d76638d Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Tue, 13 Dec 2016 04:09:53 -0800 Subject: [PATCH 016/708] Adding role to enable promotion of new user accounts from write_only without forcing the user to pass the identity challenge. --- portal/models/role.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/portal/models/role.py b/portal/models/role.py index f099b6313f..65dd09d7a8 100644 --- a/portal/models/role.py +++ b/portal/models/role.py @@ -42,6 +42,9 @@ def __str__(self): 'patient': 'Default role for all patients, may only view their own ' 'patient data', + 'promote_without_challenge': + 'Members of "write_only" may be promoted without the standard ' + 'identity challenge if they are also a member of this role', 'provider': 'Health care provider at a TrueNTH-collaborating clinic', 'service': From 2e726ba9f6246dd9a227831774bf2bf728359163 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Tue, 13 Dec 2016 05:13:26 -0800 Subject: [PATCH 017/708] Adding role 'promote_without_identity_challenge': Users with "write_only" may be promoted without the standard identity challenge if they are also have this role --- portal/models/role.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/portal/models/role.py b/portal/models/role.py index 65dd09d7a8..6debf76c69 100644 --- a/portal/models/role.py +++ b/portal/models/role.py @@ -42,9 +42,9 @@ def __str__(self): 'patient': 'Default role for all patients, may only view their own ' 'patient data', - 'promote_without_challenge': - 'Members of "write_only" may be promoted without the standard ' - 'identity challenge if they are also a member of this role', + 'promote_without_identity_challenge': + 'Users with "write_only" may be promoted without the standard ' + 'identity challenge if they are also have this role', 'provider': 'Health care provider at a TrueNTH-collaborating clinic', 'service': From 4db8fc6130f6822c2c7fff23eb7f2e1a60a0efd6 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Tue, 13 Dec 2016 05:28:33 -0800 Subject: [PATCH 018/708] Improvements to user.merge_with() - capture QuestionnaireResponses and avoid copying over temp roles used to mark weak authenticated invite account. --- portal/models/user.py | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/portal/models/user.py b/portal/models/user.py index 5d40bd47ba..6b4f7a186b 100644 --- a/portal/models/user.py +++ b/portal/models/user.py @@ -209,6 +209,7 @@ def user_extension_map(user, extension): class User(db.Model, UserMixin): + ## PLEASE maintain merge_with() as user model changes ## __tablename__ = 'users' # Override default 'user' id = db.Column(db.Integer, primary_key=True) first_name = db.Column(db.String(64)) @@ -245,6 +246,8 @@ class User(db.Model, UserMixin): secondary="user_ethnicities") groups = db.relationship('Group', secondary='user_groups', backref=db.backref('users', lazy='dynamic')) + questionnaire_responses = db.relationship('QuestionnaireResponse', + lazy='dynamic') races = db.relationship(Coding, lazy='dynamic', secondary="user_races") observations = db.relationship('Observation', lazy='dynamic', @@ -259,6 +262,10 @@ class User(db.Model, UserMixin): deleted = db.relationship('Audit', cascade="save-update", foreign_keys=[deleted_id]) + ### + ## PLEASE maintain merge_with() as user model changes ## + ### + # FIXME kludge for random demo data due_date = datetime(random.randint(2016, 2017), random.randint(1, 12), random.randint(1, 28)) random_due_date_status = 'due' @@ -637,11 +644,11 @@ def allow_org_change(org, user, acting_user): def merge_with(self, other_id): """merge details from other user into self - Part of an account generation or login flow - scenarios include - a provider setting up an account (typically the *other_id*) and - then a user logs into an existing account or registers a new (self) - and now we need to pull the data set up by the provider into the - new account. + Part of an account generation or login flow. Scenarios include + a provider or an intervention setting up a weakly authenticated + account (typically the *other_id*) for the invitation process. + Once the user logs into an existing account or registers a new (self) + we need to pull the data set up by the provider into this user account. """ other = User.query.get(other_id) @@ -658,11 +665,19 @@ def merge_with(self, other_id): for relationship in ('organizations', '_consents', 'procedures', 'observations', 'relationships', 'roles', - 'races', 'ethnicities', 'groups'): + 'races', 'ethnicities', 'groups', + 'questionnaire_responses'): self_entity = getattr(self, relationship) other_entity = getattr(other, relationship) - append_list = [item for item in other_entity if item not in - self_entity] + if relationship == 'roles': + # We don't copy over the roles used to mark the weak account + append_list = [item for item in other_entity if item not in + self_entity and item.name not in + ('write_only', + 'promote_without_identity_challenge')] + else: + append_list = [item for item in other_entity if item not in + self_entity] for item in append_list: self_entity.append(item) From 36ca49f703ace9a7e94705fd1019774dcdc7888f Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Tue, 13 Dec 2016 05:30:11 -0800 Subject: [PATCH 019/708] access_via_token() now handles weak auth'd accounts marked with PROMOTE_WITHOUT_IDENTITY_CHALLENGE --- portal/views/portal.py | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/portal/views/portal.py b/portal/views/portal.py index 07f545a227..0e3353a48f 100644 --- a/portal/views/portal.py +++ b/portal/views/portal.py @@ -154,17 +154,21 @@ def access_via_token(token): if current_user(): abort(500, "Already logged in - can't continue") + def verify_token(valid_seconds): + is_valid, has_expired, user_id =\ + user_manager.token_manager.verify_token(token, valid_seconds) + if has_expired: + flash('Your access token has expired.', 'error') + return redirect(url_for('portal.landing')) + if not is_valid: + flash('Your access token is invalid.', 'error') + return redirect(url_for('portal.landing')) + return user_id + # Confirm the token is valid, and not expired. valid_seconds = current_app.config.get( 'TOKEN_LIFE_IN_DAYS', 30) * 24 * 3600 - is_valid, has_expired, user_id = user_manager.token_manager.verify_token( - token, valid_seconds) - if has_expired: - flash('Your access token has expired.', 'error') - return redirect(url_for('portal.landing')) - if not is_valid: - flash('Your access token is invalid.', 'error') - return redirect(url_for('portal.landing')) + user_id = verify_token(valid_seconds) # Valid token - confirm user id looks legit user = get_user(user_id) @@ -176,6 +180,17 @@ def access_via_token(token): if not has.isdisjoint(not_allowed): abort(400, "Access URL not allowed for privileged accounts") if ROLE.WRITE_ONLY in has: + # write only users with special role skip the challenge protocol + if ROLE.PROMOTE_WITHOUT_IDENTITY_CHALLENGE in has: + # only give such tokens 5 minutes - recheck validity + verify_token(valid_seconds=5*60) + auditable_event("promoting user without challeng via token, " + "pending registration", user_id=user.id) + user.mask_email() + db.session.commit() + session['invited_verified_user_id'] = user.id + return redirect(url_for('user.register', email=user.email)) + # legit - log in and redirect auditable_event("login using access_via_token", user_id=user.id) session['id'] = user.id From a4b1b5f1e4ba356de58db8579be3b12578db615a Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Tue, 13 Dec 2016 10:05:15 -0800 Subject: [PATCH 020/708] Update sphinx from 1.5 to 1.5.1 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b218894d32..fae9a2bd6a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -81,7 +81,7 @@ simplegeneric==0.8.1 six==1.10.0 snowballstemmer==1.2.1 speaklater==1.3 -Sphinx==1.5 +Sphinx==1.5.1 sphinx-rtd-theme==0.1.9 SQLAlchemy==1.1.4 swagger-spec-validator==2.0.2 From b4f61e21143b5188e15c05d1462bf49ad2758822 Mon Sep 17 00:00:00 2001 From: Justin McReynolds Date: Tue, 13 Dec 2016 10:55:29 -0800 Subject: [PATCH 021/708] portal-wrapper api docs: added info about piwik and timeout code --- portal/views/truenth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portal/views/truenth.py b/portal/views/truenth.py index 20addde87a..670a1234bd 100644 --- a/portal/views/truenth.py +++ b/portal/views/truenth.py @@ -76,7 +76,7 @@ def auditlog_addevent(): def portal_wrapper_html(): """Returns portal wrapper for insertion at top of interventions - Get html for the portal site UI wrapper (top-level nav elements, etc) + Get html for the portal site UI wrapper (top-level nav elements, timeout code, piwik analytics, etc) CORS headers will only be included when the request includes well defined Origin header. From 67871b17a7f54897928349ce093b91423cfe3b34 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Tue, 13 Dec 2016 11:07:22 -0800 Subject: [PATCH 022/708] Plugging in dark header background from Tony. --- portal/static/css/topnav.css | 4 ++-- portal/static/img/header_background.jpg | Bin 0 -> 17686 bytes portal/static/less/topnav.less | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 portal/static/img/header_background.jpg diff --git a/portal/static/css/topnav.css b/portal/static/css/topnav.css index 4d5c321259..964c11e132 100644 --- a/portal/static/css/topnav.css +++ b/portal/static/css/topnav.css @@ -52,7 +52,7 @@ width: 100%; max-width: 100%; background-color: #1A1919; - background-image: url('../img/truenth_forest_background.jpg'); + background-image: url('../img/header_background.jpg'); height: 135px; background-size: cover; margin: 0; @@ -68,7 +68,7 @@ margin: 0; width: 100%; background-color: #83885F; - background-image: url('../img/truenth_forest_background.jpg'); + background-image: url('../img/header_background.jpg'); background-size: auto 81px; background-repeat: no-repeat; padding: 10px 15px; diff --git a/portal/static/img/header_background.jpg b/portal/static/img/header_background.jpg new file mode 100644 index 0000000000000000000000000000000000000000..13d6463822799ac100ab4b28bf54af414e455403 GIT binary patch literal 17686 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 literal 0 HcmV?d00001 diff --git a/portal/static/less/topnav.less b/portal/static/less/topnav.less index 9c246b7aa5..07238e924c 100644 --- a/portal/static/less/topnav.less +++ b/portal/static/less/topnav.less @@ -50,7 +50,7 @@ width: 100%; max-width: 100%; background-color: #1A1919; - background-image: url('../img/truenth_forest_background.jpg'); + background-image: url('../img/header_background.jpg'); height: 135px; background-size: cover; margin: 0; @@ -66,7 +66,7 @@ margin: 0; width: 100%; background-color: #83885F; - background-image: url('../img/truenth_forest_background.jpg'); + background-image: url('../img/header_background.jpg'); background-size: auto 81px; background-repeat: no-repeat; padding: 10px 15px; @@ -330,4 +330,4 @@ #tnthTopLinks { margin-right: 0; } -} \ No newline at end of file +} From 19d1531eae3788bfe23dbd82b8ed917da8986bdf Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Tue, 13 Dec 2016 12:58:39 -0800 Subject: [PATCH 023/708] Adding test and fix for bad procedure date as seen in logs. --- portal/views/procedure.py | 2 ++ tests/test_procedure.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/portal/views/procedure.py b/portal/views/procedure.py index 6dbbd723f7..c0901ad83f 100644 --- a/portal/views/procedure.py +++ b/portal/views/procedure.py @@ -111,6 +111,8 @@ def post_procedure(): audit = Audit(user_id=current_user().id) procedure = Procedure.from_fhir(request.json, audit) + if procedure.start_time.year < 1900: + abort(400, "Invalid datetime; pre 1900") # check the permission now that we know the subject patient_id = procedure.user_id diff --git a/tests/test_procedure.py b/tests/test_procedure.py index d700f686dd..e1ba09ee7b 100644 --- a/tests/test_procedure.py +++ b/tests/test_procedure.py @@ -75,6 +75,18 @@ def test_procedure_from_fhir(self): self.assertEquals(proc.code.codings[0].code, '80146002') self.assertEquals(proc.start_time, dateutil.parser.parse("2013-04-05")) + def test_procedure_bad_date(self): + with open(os.path.join(os.path.dirname(__file__), + 'procedure-example.json'), 'r') as fhir_data: + data = json.load(fhir_data) + data['performedDateTime'] = '1843-07-01' # can't handle pre 1900 + self.login() + rv = self.app.post( + '/api/procedure', content_type='application/json', + data=json.dumps(data)) + + self.assert400(rv) + def test_procedurePOST(self): with open(os.path.join(os.path.dirname(__file__), 'procedure2-example.json'), 'r') as fhir_data: From 0b8ac151fc190d69352b131de847f9633bb70854 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Tue, 13 Dec 2016 13:15:58 -0800 Subject: [PATCH 024/708] Catch another case for bad dates in procedure api. --- portal/views/procedure.py | 5 ++++- tests/test_procedure.py | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/portal/views/procedure.py b/portal/views/procedure.py index c0901ad83f..a428ce556b 100644 --- a/portal/views/procedure.py +++ b/portal/views/procedure.py @@ -110,7 +110,10 @@ def post_procedure(): abort(400, "Requires FHIR resourceType of 'Procedure'") audit = Audit(user_id=current_user().id) - procedure = Procedure.from_fhir(request.json, audit) + try: + procedure = Procedure.from_fhir(request.json, audit) + except ValueError as e: + abort(400, str(e)) if procedure.start_time.year < 1900: abort(400, "Invalid datetime; pre 1900") diff --git a/tests/test_procedure.py b/tests/test_procedure.py index e1ba09ee7b..4b906b1410 100644 --- a/tests/test_procedure.py +++ b/tests/test_procedure.py @@ -84,7 +84,12 @@ def test_procedure_bad_date(self): rv = self.app.post( '/api/procedure', content_type='application/json', data=json.dumps(data)) + self.assert400(rv) + data['performedDateTime'] = '1943-17-01' # month 17 doesn't fly + rv = self.app.post( + '/api/procedure', content_type='application/json', + data=json.dumps(data)) self.assert400(rv) def test_procedurePOST(self): From 998fc6de83e8f0bb6fccf2c16e2ad81eac0b0073 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Tue, 13 Dec 2016 14:51:38 -0800 Subject: [PATCH 025/708] Extending ToU api to allow users to accept ToU on behalf of others --- portal/views/tou.py | 62 ++++++++++++++++++++++++++++++++++++++++++--- tests/test_tou.py | 51 +++++++++++++++++++++++-------------- 2 files changed, 91 insertions(+), 22 deletions(-) diff --git a/portal/views/tou.py b/portal/views/tou.py index 537db45e84..2dd9fef54b 100644 --- a/portal/views/tou.py +++ b/portal/views/tou.py @@ -48,9 +48,62 @@ def get_tou(user_id): return jsonify(accepted=False) +@tou_api.route('/user//tou/accepted', methods=('POST',)) +@oauth.require_oauth() +def post_user_accepted_tou(user_id): + """Accept Terms of Use on behalf of user + + POST simple JSON describing ToU the user accepted for persistence. This + endpoint enables service or other users to set accepted ToU on behalf of + any user they have permission to edit. + + --- + tags: + - Terms Of Use + operationId: userAcceptToU + produces: + - application/json + parameters: + - name: user_id + in: path + description: TrueNTH user ID + required: true + type: integer + format: int64 + - name: body + in: body + schema: + id: acceptedToU + description: Details of accepted ToU + required: + - text + properties: + text: + description: Full text agreed to + type: string + responses: + 200: + description: message detailing success + 400: + description: if the required JSON is ill formed + 401: + description: + if missing valid OAuth token or logged-in user lacks permission + to edit requested user + + """ + authd_user = current_user() + authd_user.check_role(permission='edit', other_id=user_id) + audit = Audit(user_id = authd_user.id, + comment = "user {} posting accepted ToU for user {}".format( + authd_user.id, user_id)) + db.session.add(audit) + return accept_tou(user_id) + + @tou_api.route('/tou/accepted', methods=('POST',)) @oauth.require_oauth() -def accept_tou(): +def accept_tou(user_id=None): """Accept Terms of Use info for authenticated user POST simple JSON describing ToU the user accepted for persistence. @@ -81,10 +134,13 @@ def accept_tou(): 401: description: if missing valid OAuth token or logged-in user lacks permission - to view requested patient + to edit requested user """ - user = current_user() + if user_id: + user=get_user(user_id) + else: + user = current_user() if not request.json or 'text' not in request.json: abort(400, "Requires JSON with the ToU 'text'") audit = Audit(user_id = user.id, comment = "ToU accepted") diff --git a/tests/test_tou.py b/tests/test_tou.py index 1a8a7d7c23..d9c1f526f8 100644 --- a/tests/test_tou.py +++ b/tests/test_tou.py @@ -8,6 +8,26 @@ from portal.models.tou import ToU +tou_text = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. +Sed gravida, urna sed faucibus laoreet, turpis sapien euismod turpis, a +feugiat velit neque sed ligula. Sed magna risus, tincidunt a mauris ut, +finibus ultricies justo. Phasellus lobortis, dui fringilla dignissim +ornare, risus augue pharetra odio, ac vulputate lacus leo at odio. +Donec lobortis pellentesque dapibus. Quisque et convallis elit, in +placerat nibh. Donec ac lacus eu justo lobortis sollicitudin vel eget +mi. Morbi placerat egestas elit sit amet tempus. Nam sodales mattis +nisi gravida dignissim. + +Donec sed odio quis justo porttitor auctor. Aliquam nisl enim, faucibus +eget tristique non, tincidunt eget neque. Suspendisse in ex eu sem +ultricies euismod vel dapibus lorem. Vivamus tortor metus, laoreet +vulputate ornare eu, lacinia et enim. Duis sodales, mi scelerisque +laoreet egestas, dui enim commodo ex, in posuere magna nisi eu lacus. +Integer id libero in enim volutpat auctor. Proin sed lacinia lacus. +Nulla facilisi. Nulla facilisi. Integer purus diam, gravida quis neque +sit amet, laoreet imperdiet risus.""" + + class TestTou(TestCase): """Terms Of Use tests""" @@ -18,25 +38,6 @@ def test_tou_str(self): self.assertTrue('Your data is safe' in results) def test_accept(self): - tou_text = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Sed gravida, urna sed faucibus laoreet, turpis sapien euismod turpis, a - feugiat velit neque sed ligula. Sed magna risus, tincidunt a mauris ut, - finibus ultricies justo. Phasellus lobortis, dui fringilla dignissim - ornare, risus augue pharetra odio, ac vulputate lacus leo at odio. - Donec lobortis pellentesque dapibus. Quisque et convallis elit, in - placerat nibh. Donec ac lacus eu justo lobortis sollicitudin vel eget - mi. Morbi placerat egestas elit sit amet tempus. Nam sodales mattis - nisi gravida dignissim. - - Donec sed odio quis justo porttitor auctor. Aliquam nisl enim, faucibus - eget tristique non, tincidunt eget neque. Suspendisse in ex eu sem - ultricies euismod vel dapibus lorem. Vivamus tortor metus, laoreet - vulputate ornare eu, lacinia et enim. Duis sodales, mi scelerisque - laoreet egestas, dui enim commodo ex, in posuere magna nisi eu lacus. - Integer id libero in enim volutpat auctor. Proin sed lacinia lacus. - Nulla facilisi. Nulla facilisi. Integer purus diam, gravida quis neque - sit amet, laoreet imperdiet risus.""" - self.login() data = {'text': tou_text} rv = self.app.post('/api/tou/accepted', @@ -47,6 +48,18 @@ def test_accept(self): self.assertEquals(tou.text, tou_text) self.assertEquals(tou.audit.user_id, TEST_USER_ID) + def test_service_accept(self): + service_user = self.add_service_user() + self.login(user_id=service_user.id) + data = {'text': tou_text} + rv = self.app.post('/api/user/{}/tou/accepted'.format(TEST_USER_ID), + content_type='application/json', + data=json.dumps(data)) + self.assert200(rv) + tou = ToU.query.one() + self.assertEquals(tou.text, tou_text) + self.assertEquals(tou.audit.user_id, TEST_USER_ID) + def test_get(self): audit = Audit(user_id=TEST_USER_ID) tou = ToU(audit=audit, text='terms text') From 12ac6a6ceceff178cbad87442cef6ff58170d29e Mon Sep 17 00:00:00 2001 From: Justin McReynolds Date: Tue, 13 Dec 2016 17:31:30 -0800 Subject: [PATCH 026/708] Changing default inactivity timeout from 15 minutes to 60. https://www.pivotaltracker.com/story/show/136077541 --- portal/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portal/config.py b/portal/config.py index d0c61484f4..b731d12512 100644 --- a/portal/config.py +++ b/portal/config.py @@ -22,7 +22,7 @@ class BaseConfig(object): CONTACT_SENDTO_EMAIL = MAIL_USERNAME ERROR_SENDTO_EMAIL = MAIL_USERNAME OAUTH2_PROVIDER_TOKEN_EXPIRES_IN = 4 * 60 * 60 # units: seconds - SS_TIMEOUT = 15 * 60 # seconds for session cookie, reset on ping + SS_TIMEOUT = 60 * 60 # seconds for session cookie, reset on ping PERMANENT_SESSION_LIFETIME = SS_TIMEOUT PIWIK_DOMAINS = "" PIWIK_SITEID = 0 From 71b3dfb84ea6ec984a1eaa674d7f9d4696893027 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Tue, 13 Dec 2016 17:58:39 -0800 Subject: [PATCH 027/708] Modify ToU table and APIs to store the agreement_url rather than text. --- migrations/versions/533fc09a4d32_.py | 28 +++++++++++++++++++++++ portal/models/tou.py | 10 ++++---- portal/templates/initial_queries.html | 2 +- portal/views/tou.py | 18 +++++++-------- tests/__init__.py | 2 +- tests/test_tou.py | 33 +++++++-------------------- 6 files changed, 51 insertions(+), 42 deletions(-) create mode 100644 migrations/versions/533fc09a4d32_.py diff --git a/migrations/versions/533fc09a4d32_.py b/migrations/versions/533fc09a4d32_.py new file mode 100644 index 0000000000..57c2a93a62 --- /dev/null +++ b/migrations/versions/533fc09a4d32_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 533fc09a4d32 +Revises: 7793ca45b7f9 +Create Date: 2016-12-13 16:14:27.123196 + +""" + +# revision identifiers, used by Alembic. +revision = '533fc09a4d32' +down_revision = '7793ca45b7f9' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tou', sa.Column('agreement_url', sa.Text(), server_default='predates agreement_url', nullable=False)) + op.drop_column('tou', 'text') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tou', sa.Column('text', sa.TEXT(), autoincrement=False, nullable=False)) + op.drop_column('tou', 'agreement_url') + # ### end Alembic commands ### diff --git a/portal/models/tou.py b/portal/models/tou.py index 872d256ca9..ff8feea3c0 100644 --- a/portal/models/tou.py +++ b/portal/models/tou.py @@ -6,15 +6,13 @@ class ToU(db.Model): """SQLAlchemy class for `tou` table""" __tablename__ = 'tou' id = db.Column(db.Integer(), primary_key=True) - - text = db.Column(db.Text, nullable=False) - """Actual text the user agreed to""" - + agreement_url = db.Column(db.Text, + server_default='predates agreement_url', + nullable=False) audit_id = db.Column(db.ForeignKey('audit.id'), nullable=False) audit = db.relationship('Audit', cascade="save-update", lazy='joined') """tracks when and by whom the terms were agreed to""" def __str__(self): - return "ToU ({audit}) {text_snip}".format( - audit=self.audit, text_snip=self.text[:50]) + return "ToU ({0.audit}) {0.agreement_url}".format(self) diff --git a/portal/templates/initial_queries.html b/portal/templates/initial_queries.html index 03921ee825..f697433231 100644 --- a/portal/templates/initial_queries.html +++ b/portal/templates/initial_queries.html @@ -263,7 +263,7 @@

{{ _("Thank you for registering.") }}

var theTerms = {}; //theTerms["text"] = $(this).attr('data-terms'); // Grab the current text of the Terms of Use - theTerms["text"] = $("#termsText").text(); + theTerms["agreement_url"] = "http://TODO.FIXME"; // Post terms agreement via API tnthAjax.postTerms(theTerms); // Update UI diff --git a/portal/views/tou.py b/portal/views/tou.py index 2dd9fef54b..2d4f0f8699 100644 --- a/portal/views/tou.py +++ b/portal/views/tou.py @@ -76,10 +76,10 @@ def post_user_accepted_tou(user_id): id: acceptedToU description: Details of accepted ToU required: - - text + - agreement_url properties: - text: - description: Full text agreed to + agreement_url: + description: URL for Terms of Use text type: string responses: 200: @@ -121,10 +121,10 @@ def accept_tou(user_id=None): id: acceptedToU description: Details of accepted ToU required: - - text + - agreement_url properties: - text: - description: Full text agreed to + agreement_url: + description: URL for Terms of Use text type: string responses: 200: @@ -141,10 +141,10 @@ def accept_tou(user_id=None): user=get_user(user_id) else: user = current_user() - if not request.json or 'text' not in request.json: - abort(400, "Requires JSON with the ToU 'text'") + if not request.json or 'agreement_url' not in request.json: + abort(400, "Requires JSON with the ToU 'agreement_url'") audit = Audit(user_id = user.id, comment = "ToU accepted") - tou = ToU(audit=audit, text=request.json['text']) + tou = ToU(audit=audit, agreement_url=request.json['agreement_url']) db.session.add(tou) db.session.commit() # Note: skipping auditable_event, as there's a audit row created above diff --git a/tests/__init__.py b/tests/__init__.py index 51f8a3569d..0c3eb39700 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -145,7 +145,7 @@ def bless_with_basics(self): # Agree to Terms of Use and sign consent audit = Audit(user_id=TEST_USER_ID) - tou = ToU(audit=audit, text="filler text") + tou = ToU(audit=audit, agreement_url='http://not.really.org') parent_org = OrgTree().find(org.id).top_level() consent = UserConsent(user_id=TEST_USER_ID, organization_id=parent_org, audit=audit, agreement_url='http://fake.org') diff --git a/tests/test_tou.py b/tests/test_tou.py index d9c1f526f8..0b60c89cdd 100644 --- a/tests/test_tou.py +++ b/tests/test_tou.py @@ -8,24 +8,7 @@ from portal.models.tou import ToU -tou_text = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. -Sed gravida, urna sed faucibus laoreet, turpis sapien euismod turpis, a -feugiat velit neque sed ligula. Sed magna risus, tincidunt a mauris ut, -finibus ultricies justo. Phasellus lobortis, dui fringilla dignissim -ornare, risus augue pharetra odio, ac vulputate lacus leo at odio. -Donec lobortis pellentesque dapibus. Quisque et convallis elit, in -placerat nibh. Donec ac lacus eu justo lobortis sollicitudin vel eget -mi. Morbi placerat egestas elit sit amet tempus. Nam sodales mattis -nisi gravida dignissim. - -Donec sed odio quis justo porttitor auctor. Aliquam nisl enim, faucibus -eget tristique non, tincidunt eget neque. Suspendisse in ex eu sem -ultricies euismod vel dapibus lorem. Vivamus tortor metus, laoreet -vulputate ornare eu, lacinia et enim. Duis sodales, mi scelerisque -laoreet egestas, dui enim commodo ex, in posuere magna nisi eu lacus. -Integer id libero in enim volutpat auctor. Proin sed lacinia lacus. -Nulla facilisi. Nulla facilisi. Integer purus diam, gravida quis neque -sit amet, laoreet imperdiet risus.""" +tou_url = 'http://fake-tou.org' class TestTou(TestCase): @@ -33,36 +16,36 @@ class TestTou(TestCase): def test_tou_str(self): audit = Audit(user_id=TEST_USER_ID, comment="Agreed to ToU") - tou = ToU(audit=audit, text="Your data is safe, trust us") + tou = ToU(audit=audit, agreement_url=tou_url) results = "{}".format(tou) - self.assertTrue('Your data is safe' in results) + self.assertTrue(tou_url in results) def test_accept(self): self.login() - data = {'text': tou_text} + data = {'agreement_url': tou_url} rv = self.app.post('/api/tou/accepted', content_type='application/json', data=json.dumps(data)) self.assert200(rv) tou = ToU.query.one() - self.assertEquals(tou.text, tou_text) + self.assertEquals(tou.agreement_url, tou_url) self.assertEquals(tou.audit.user_id, TEST_USER_ID) def test_service_accept(self): service_user = self.add_service_user() self.login(user_id=service_user.id) - data = {'text': tou_text} + data = {'agreement_url': tou_url} rv = self.app.post('/api/user/{}/tou/accepted'.format(TEST_USER_ID), content_type='application/json', data=json.dumps(data)) self.assert200(rv) tou = ToU.query.one() - self.assertEquals(tou.text, tou_text) + self.assertEquals(tou.agreement_url, tou_url) self.assertEquals(tou.audit.user_id, TEST_USER_ID) def test_get(self): audit = Audit(user_id=TEST_USER_ID) - tou = ToU(audit=audit, text='terms text') + tou = ToU(audit=audit, agreement_url=tou_url) with SessionScope(db): db.session.add(tou) db.session.commit() From d3607d9ce38d300ad1187f06155027d90f57a23a Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Wed, 14 Dec 2016 00:50:29 -0800 Subject: [PATCH 028/708] Add simple endpoint to deliver current Terms of Use in html format --- portal/views/tou.py | 24 ++++++++++++++++++++++++ tests/test_tou.py | 5 +++++ 2 files changed, 29 insertions(+) diff --git a/portal/views/tou.py b/portal/views/tou.py index 2d4f0f8699..3e80580406 100644 --- a/portal/views/tou.py +++ b/portal/views/tou.py @@ -1,7 +1,9 @@ """Views for Terms of Use""" +import requests from flask import abort, jsonify, Blueprint, request from ..extensions import db, oauth +from ..models.app_text import app_text, ToU_ATMA from ..models.audit import Audit from ..models.user import current_user, get_user from ..models.tou import ToU @@ -9,6 +11,28 @@ tou_api = Blueprint('tou_api', __name__, url_prefix='/api') + +@tou_api.route('/tou') +def get_current_tou(): + """Return current ToU asset (html) + + --- + tags: + - Terms Of Use + operationId: getCurrentToU + produces: + - application/json + responses: + 200: + description: + Returns html for current Terms of Use, with respect to current + system configuration. + + """ + response = requests.get(app_text(ToU_ATMA.name_key())) + return response.text + + @tou_api.route('/user//tou') @oauth.require_oauth() def get_tou(user_id): diff --git a/tests/test_tou.py b/tests/test_tou.py index 0b60c89cdd..fb0464ce04 100644 --- a/tests/test_tou.py +++ b/tests/test_tou.py @@ -20,6 +20,11 @@ def test_tou_str(self): results = "{}".format(tou) self.assertTrue(tou_url in results) + def test_get_tou(self): + rv = self.app.get('/api/tou') + self.assert200(rv) + self.assertTrue('

When you agree to the Terms of Use' in rv.data) + def test_accept(self): self.login() data = {'agreement_url': tou_url} From 30f750b5dd9ce4fbb569e343df14bfc8110f44c0 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 14 Dec 2016 05:30:19 -0800 Subject: [PATCH 029/708] Update requests from 2.12.3 to 2.12.4 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b218894d32..9340940642 100644 --- a/requirements.txt +++ b/requirements.txt @@ -73,7 +73,7 @@ pytz==2016.10 PyYAML==3.12 recommonmark==0.4.0 redis==2.10.5 -requests==2.12.3 +requests==2.12.4 requests-cache==0.4.12 requests-oauthlib==0.7.0 selenium==3.0.2 From 11d7d236f748323dca244c3f4260ce69ac1c56ab Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Wed, 14 Dec 2016 15:45:38 -0800 Subject: [PATCH 030/708] Refactored obtaining versioned URL from Liferay. Correctly storing permanent version for ToU. Fixed bug with multiple version args in the permanent URL. --- portal/app.py | 5 +- portal/models/app_text.py | 85 +++++++++++++------- portal/templates/initial_queries.html | 4 +- portal/templates/initial_queries_macros.html | 4 +- portal/views/patients.py | 19 ++--- portal/views/portal.py | 43 ++++------ tests/test_app_text.py | 29 ++++++- 7 files changed, 108 insertions(+), 81 deletions(-) diff --git a/portal/app.py b/portal/app.py index e3f8458796..b8adaf1e5d 100644 --- a/portal/app.py +++ b/portal/app.py @@ -223,8 +223,9 @@ def configure_metadata(app): def configure_cache(app): """Configure requests-cache""" - requests_cache.install_cache(cache_name=app.name, backend='redis', - expire_after=180, old_data_on_error=True) + requests_cache.install_cache( + cache_name=app.name, backend='redis', expire_after=180, + include_get_headers=True, old_data_on_error=True) @babel.localeselector def get_locale(): diff --git a/portal/models/app_text.py b/portal/models/app_text.py index 64085ed429..5646b602ac 100644 --- a/portal/models/app_text.py +++ b/portal/models/app_text.py @@ -7,9 +7,10 @@ `app_text(string)` method. """ +from abc import ABCMeta, abstractmethod from flask import current_app from flask_babel import gettext -from abc import ABCMeta, abstractmethod +import requests from urllib import urlencode from urlparse import parse_qsl, urlparse @@ -97,35 +98,6 @@ def name_key(**kwargs): raise ValueError("required organization parameter not defined") return "{} organization consent URL".format(organization.name) - @staticmethod - def permanent_url(generic_url, version): - """Produce a permanent url from the metadata provided - - Consent agreements are versioned - but the link maintained - in the app_text table is not. - - On request for the consent URL, the effective version number is - returned. This method returns a permanent URL including the version - number. - - """ - parsed = urlparse(generic_url) - qs = parse_qsl(parsed.query) - if not qs: - qs = [] - if 'version' not in qs: - qs.append(('version', version)) - path = parsed.path - if path.endswith('/detailed'): - path = path[:-(len('/detailed'))] - format_dict = { - 'scheme': parsed.scheme, - 'netloc': parsed.netloc, - 'path': path, - 'qs': urlencode(qs)} - url = "{scheme}://{netloc}{path}?{qs}".format(**format_dict) - return url - class ToU_ATMA(AppTextModelAdapter): """AppTextModelAdapter for Terms Of Use agreements - namely the URL""" @@ -172,6 +144,59 @@ def name_key(**kwargs): return "Legal URL" +class VersionedResource(object): + """Helper to manage versioned resource URLs (typically on Liferay)""" + + @staticmethod + def permanent_url(generic_url, version): + """Produce a permanent url from the metadata provided + + Resources are versioned - but the link maintained in the app_text + table is not. + + When requesting the detailed resource, the effective version number is + returned. This method returns a permanent URL including the version + number, useful for audit and tracking information. + + """ + parsed = urlparse(generic_url) + qs = dict(parse_qsl(parsed.query)) + qs['version'] = version + + path = parsed.path + if path.endswith('/detailed'): + path = path[:-(len('/detailed'))] + format_dict = { + 'scheme': parsed.scheme, + 'netloc': parsed.netloc, + 'path': path, + 'qs': urlencode(qs)} + url = "{scheme}://{netloc}{path}?{qs}".format(**format_dict) + return url + + + @staticmethod + def fetch_elements(url): + """Given a URL, fetch the asset and permanent version of URL + + Pulls and returns the 'asset' (i.e. response.text) from the given URL. + If version info is provided in the `detailed` response, a permanent + version of the URL is also returned. + + :param url: the URL to pull details and asset from + :returns: (asset, url) + + """ + response = requests.get(url) + try: + return ( + response.json()['asset'], + VersionedResource.permanent_url( + version=response.json()['version'], + generic_url=url)) + except ValueError: # thrown when no json is available in response + return (response.text, url) + def app_text(name, *args): """Look up and return cusomized application text string diff --git a/portal/templates/initial_queries.html b/portal/templates/initial_queries.html index f697433231..7eba321b8d 100644 --- a/portal/templates/initial_queries.html +++ b/portal/templates/initial_queries.html @@ -261,9 +261,7 @@

{{ _("Thank you for registering.") }}

$("#agreeLabel").on("click",function(){ if ($(this).attr("data-agree") == "false") { var theTerms = {}; - //theTerms["text"] = $(this).attr('data-terms'); - // Grab the current text of the Terms of Use - theTerms["agreement_url"] = "http://TODO.FIXME"; + theTerms["agreement_url"] = $("#termsURL").data().url; // Post terms agreement via API tnthAjax.postTerms(theTerms); // Update UI diff --git a/portal/templates/initial_queries_macros.html b/portal/templates/initial_queries_macros.html index 6d00d53218..07fd2280cc 100644 --- a/portal/templates/initial_queries_macros.html +++ b/portal/templates/initial_queries_macros.html @@ -6,8 +6,10 @@

{{ _("Terms of Use") }}

{{ _("Thanks for signing up for TrueNTH. First, please review the terms of use to access the TrueNTH tools") }}:

- {{ terms|safe if terms}} + {{ terms['asset']|safe if terms}}
+ diff --git a/portal/views/patients.py b/portal/views/patients.py index d83382e270..9b72969917 100644 --- a/portal/views/patients.py +++ b/portal/views/patients.py @@ -1,5 +1,4 @@ """Patient view functions (i.e. not part of the API or auth)""" -import requests import random from datetime import datetime from flask import abort, Blueprint, render_template @@ -10,7 +9,7 @@ from ..models.user import User, current_user, get_user from ..models.user_consent import UserConsent from ..models.organization import Organization, OrgTree -from ..models.app_text import app_text, ConsentATMA +from ..models.app_text import app_text, ConsentATMA, VersionedResource from ..extensions import oauth patients = Blueprint('patients', __name__, url_prefix='/patients') @@ -113,16 +112,10 @@ def get_orgs_consent_agreements(): consent_agreements = {} for org_id in OrgTree().all_top_level_ids(): org = Organization.query.get(org_id) - consent_url = app_text(ConsentATMA.name_key(organization=org)) - response = requests.get(consent_url) - if response.json: - consent_agreements[org.id] = { + asset, url = VersionedResource.fetch_elements( + app_text(ConsentATMA.name_key(organization=org))) + consent_agreements[org.id] = { 'organization_name': org.name, - 'asset': response.json()['asset'], - 'agreement_url': ConsentATMA.permanent_url( - version=response.json()['version'], - generic_url=consent_url)} - else: - consent_agreements[org.id] = { - 'asset': response.text, 'agreement_url': consent_url, 'organization_name': org.name} + 'asset': asset, + 'agreement_url': url} return consent_agreements diff --git a/portal/views/portal.py b/portal/views/portal.py index 0e3353a48f..3f79dd3d60 100644 --- a/portal/views/portal.py +++ b/portal/views/portal.py @@ -1,6 +1,4 @@ """Portal view functions (i.e. not part of the API or auth)""" -import requests - from flask import current_app, Blueprint, jsonify, render_template, flash from flask import abort, make_response, redirect, request, session, url_for from flask_login import login_user @@ -14,7 +12,7 @@ from .auth import next_after_login from ..audit import auditable_event from .crossdomain import crossdomain -from ..models.app_text import app_text +from ..models.app_text import app_text, VersionedResource from ..models.app_text import AboutATMA, ConsentATMA, LegalATMA, ToU_ATMA from ..models.coredata import Coredata from ..models.identifier import Identifier @@ -312,22 +310,16 @@ def initial_queries(): still_needed = Coredata().still_needed(user) terms, consent_agreements = None, {} if 'tou' in still_needed: - response = requests.get(app_text(ToU_ATMA.name_key())) - terms = response.text + asset, url = VersionedResource.fetch_elements( + app_text(ToU_ATMA.name_key())) + terms = {'asset': asset, 'agreement_url': url} if 'org' in still_needed: for org_id in OrgTree().all_top_level_ids(): org = Organization.query.get(org_id) - consent_url = app_text(ConsentATMA.name_key(organization=org)) - response = requests.get(consent_url) - if response.json: - consent_agreements[org.id] = { - 'asset': response.json()['asset'], - 'agreement_url': ConsentATMA.permanent_url( - version=response.json()['version'], - generic_url=consent_url)} - else: - consent_agreements[org.id] = { - 'asset': response.text, 'agreement_url': consent_url} + asset, url = VersionedResource.fetch_elements( + app_text(ConsentATMA.name_key(organization=org))) + consent_agreements[org.id] = { + 'asset': asset, 'agreement_url': url} return render_template( 'initial_queries.html', user=user, terms=terms, consent_agreements=consent_agreements, still_needed=still_needed) @@ -429,19 +421,14 @@ def profile(user_id): consent_agreements = {} for org_id in OrgTree().all_top_level_ids(): org = Organization.query.get(org_id) - consent_url = app_text(ConsentATMA.name_key(organization=org)) - response = requests.get(consent_url) - if response.json: - consent_agreements[org.id] = { + asset, url = VersionedResource.fetch_elements( + app_text(ConsentATMA.name_key(organization=org))) + consent_agreements[org.id] = { 'organization_name': org.name, - 'asset': response.json()['asset'], - 'agreement_url': ConsentATMA.permanent_url( - version=response.json()['version'], - generic_url=consent_url)} - else: - consent_agreements[org.id] = { - 'asset': response.text, 'agreement_url': consent_url, 'organization_name': org.name} - return render_template('profile.html', user=user, consent_agreements=consent_agreements) + 'asset': asset, + 'agreement_url': url} + return render_template( + 'profile.html', user=user, consent_agreements=consent_agreements) @portal.route('/legal') def legal(): diff --git a/tests/test_app_text.py b/tests/test_app_text.py index af3f16af4a..924a62b98b 100644 --- a/tests/test_app_text.py +++ b/tests/test_app_text.py @@ -3,10 +3,30 @@ from flask_webtest import SessionScope from portal.extensions import db -from portal.models.app_text import AppText, ConsentATMA, app_text +from portal.models.app_text import AppText, app_text, VersionedResource from tests import TestCase +from urlparse import urlparse, parse_qsl +from urllib import unquote_plus + +class Url(object): + '''A url object that can be compared with other url orbjects + without regard to the vagaries of encoding, escaping, and ordering + of parameters in query strings.''' + + def __init__(self, url): + parts = urlparse(url) + _query = frozenset(parse_qsl(parts.query)) + _path = unquote_plus(parts.path) + parts = parts._replace(query=_query, path=_path) + self.parts = parts + + def __eq__(self, other): + return self.parts == other.parts + + def __hash__(self): return hash(self.parts) + class TestAppText(TestCase): def test_expansion(self): @@ -25,12 +45,13 @@ def test_missing_arg(self): self.assertRaises(ValueError, render_template, 'landing.html') def test_permanent_url(self): - sample = 'https://stg-lr7.us.truenth.org/c/portal/truenth/asset/detailed?groupId=20147&articleId=52668' + sample = 'https://stg-lr7.us.truenth.org/c/portal/truenth/asset/detailed?groupId=20147&articleId=52668&version=latest' version = '1.3' expected = 'https://stg-lr7.us.truenth.org/c/portal/truenth/asset?groupId=20147&articleId=52668&version=1.3' - result = ConsentATMA.permanent_url(generic_url=sample, version=version) - self.assertEquals(result, expected) + result = VersionedResource.permanent_url( + generic_url=sample, version=version) + self.assertTrue(Url(result) == Url(expected)) def test_config_value_in_custom_text(self): self.app.application.config['CT_TEST'] = 'found!' From a2d9690d16031de7b85564162cb122205896d982 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 14 Dec 2016 16:05:22 -0800 Subject: [PATCH 031/708] Update amqp from 2.1.3 to 2.1.4 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a87562c91a..74f9788aae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ alabaster==0.7.9 alembic==0.8.9 -amqp==2.1.3 +amqp==2.1.4 anyjson==0.3.3 argcomplete==1.7.0 Authomatic==0.1.0.post1 From e2930be3598ee6ac17a63582f6c285bc4ab887e0 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Wed, 14 Dec 2016 16:05:33 -0800 Subject: [PATCH 032/708] Return the URL rather than the contents for /api/tou --- portal/views/tou.py | 15 +++++++-------- tests/test_tou.py | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/portal/views/tou.py b/portal/views/tou.py index 3e80580406..3af68c28bb 100644 --- a/portal/views/tou.py +++ b/portal/views/tou.py @@ -1,9 +1,8 @@ """Views for Terms of Use""" -import requests from flask import abort, jsonify, Blueprint, request from ..extensions import db, oauth -from ..models.app_text import app_text, ToU_ATMA +from ..models.app_text import app_text, ToU_ATMA, VersionedResource from ..models.audit import Audit from ..models.user import current_user, get_user from ..models.tou import ToU @@ -13,8 +12,8 @@ @tou_api.route('/tou') -def get_current_tou(): - """Return current ToU asset (html) +def get_current_tou_url(): + """Return current ToU URL --- tags: @@ -25,12 +24,12 @@ def get_current_tou(): responses: 200: description: - Returns html for current Terms of Use, with respect to current - system configuration. + Returns URL for current Terms of Use, with respect to current + system configuration in simple json {url:"http..."} """ - response = requests.get(app_text(ToU_ATMA.name_key())) - return response.text + _, url = VersionedResource.fetch_elements(app_text(ToU_ATMA.name_key())) + return jsonify(url=url) @tou_api.route('/user//tou') diff --git a/tests/test_tou.py b/tests/test_tou.py index fb0464ce04..6f3a522f8d 100644 --- a/tests/test_tou.py +++ b/tests/test_tou.py @@ -23,7 +23,7 @@ def test_tou_str(self): def test_get_tou(self): rv = self.app.get('/api/tou') self.assert200(rv) - self.assertTrue('

When you agree to the Terms of Use' in rv.data) + self.assertTrue('url' in rv.json) def test_accept(self): self.login() From d27baffb00b70f3c01500b8684679e851034a205 Mon Sep 17 00:00:00 2001 From: OptimusRhine Date: Thu, 15 Dec 2016 10:34:33 -0800 Subject: [PATCH 033/708] user document upload API (POST only) --- migrations/versions/b1d13b4b175a_.py | 36 ++++++++++++++ portal/models/user_document.py | 63 ++++++++++++++++++++++++ portal/uploads/.gitignore | 4 ++ portal/views/user.py | 73 ++++++++++++++++++++++++++++ tests/test_user_document.py | 25 ++++++++++ 5 files changed, 201 insertions(+) create mode 100644 migrations/versions/b1d13b4b175a_.py create mode 100644 portal/models/user_document.py create mode 100644 portal/uploads/.gitignore create mode 100644 tests/test_user_document.py diff --git a/migrations/versions/b1d13b4b175a_.py b/migrations/versions/b1d13b4b175a_.py new file mode 100644 index 0000000000..bcfd4ec79e --- /dev/null +++ b/migrations/versions/b1d13b4b175a_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: b1d13b4b175a +Revises: 7793ca45b7f9 +Create Date: 2016-12-14 12:55:17.942109 + +""" + +# revision identifiers, used by Alembic. +revision = 'b1d13b4b175a' +down_revision = '7793ca45b7f9' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.create_table('user_documents', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('document_type', sa.Text(), nullable=False), + sa.Column('filename', sa.Text(), nullable=False), + sa.Column('filetype', sa.Text(), nullable=False), + sa.Column('uuid', sa.Text(), nullable=False), + sa.Column('uploaded_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_table('user_documents') + ### end Alembic commands ### diff --git a/portal/models/user_document.py b/portal/models/user_document.py new file mode 100644 index 0000000000..4782cb7465 --- /dev/null +++ b/portal/models/user_document.py @@ -0,0 +1,63 @@ +"""User Document module""" +from datetime import datetime +from validators import url as url_validation +from werkzeug.utils import secure_filename +from uuid import uuid4 +from flask import current_app +import os + +from ..extensions import db +from .fhir import FHIR_datetime +from .user import User + +class UserDocument(db.Model): + """ORM class for user document upload data + + Capture and store uploaded user documents (e.g. WiserCare Patient Report, user avatar image, etc). + + """ + __tablename__ = 'user_documents' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.ForeignKey('users.id'), nullable=False) + document_type = db.Column(db.Text, nullable=False) + filename = db.Column(db.Text, nullable=False) + filetype = db.Column(db.Text, nullable=False) + uuid = db.Column(db.Text, nullable=False) + uploaded_at = db.Column(db.DateTime, nullable=False) + + def __str__(self): + return self.filename + + def as_json(self): + d = {} + d['user_id'] = self.user_id + d['document_type'] = self.description + d['uploaded_at'] = FHIR_datetime.as_fhir(self.uploaded_at) + d['filename'] = self.filename + d['filetype'] = self.filetype + d['uuid'] = self.uuid + + return d + + @classmethod + def from_post(cls, upload_file, data): + user = User.query.get(data['user_id']) + if not user: + raise ValueError("user not found") + if not data['document_type']: + raise ValueError('must provide document type') + if upload_file.filename == '': + raise ValueError("invalid filename") + filename = secure_filename(upload_file.filename) + filetype = filename.rsplit('.', 1)[1] + if filetype.lower() not in data['allowed_extensions']: + raise ValueError("filetype must be one of: " + ", ".join(data['allowed_extensions'])) + file_uuid = uuid4() + try: + upload_file.save(os.path.join(current_app.root_path,"uploads",str(file_uuid))) + except: + raise ValueError("could not save file") + + return cls(user_id=data['user_id'],document_type=data['document_type'],filename=filename, + filetype=filetype,uuid=file_uuid,uploaded_at=datetime.utcnow()) + diff --git a/portal/uploads/.gitignore b/portal/uploads/.gitignore new file mode 100644 index 0000000000..2087acdb74 --- /dev/null +++ b/portal/uploads/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# ...except this file +!.gitignore diff --git a/portal/views/user.py b/portal/views/user.py index a09df7fd05..652d38bd3b 100644 --- a/portal/views/user.py +++ b/portal/views/user.py @@ -13,6 +13,7 @@ from ..models.user import current_user, get_user from ..models.user import User, UserRelationship from ..models.user_consent import UserConsent +from ..models.user_document import UserDocument user_api = Blueprint('user_api', __name__, url_prefix='/api') @@ -1235,3 +1236,75 @@ def unique_email(): if user_id != result.id: return jsonify(unique=False) return jsonify(unique=True) + +@user_api.route('/user//patient_report', methods=('POST',)) +@oauth.require_oauth() +def upload_user_document(user_id): + """Add a WiserCare Patient Report for the user + + File must be included in the POST call, and must be a valid PDF file. + File will be stored on server using uuid as filename; file metadata (including + reference uuid) will be stored in the db. + + --- + tags: + - User + - User Document + - Patient Report + operationId: post_patient_report + produces: + - application/json + parameters: + - name: user_id + in: path + description: TrueNTH user ID + required: true + type: integer + format: int64 + - in: body + name: body + schema: + id: file + required: + - file + responses: + 200: + description: successful operation + schema: + id: response + required: + - message + properties: + message: + type: string + description: Result, typically "ok" + 400: + description: if the request incudes invalid data + 401: + description: + if missing valid OAuth token or if the authorized user lacks + permission to edit requested user_id + 404: + description: if user_id doesn't exist + + """ + user = current_user() + if user.id != user_id: + current_user().check_role(permission='edit', other_id=user_id) + user = get_user(user_id) + if user.deleted: + abort(400, "deleted user - operation not permitted") + + if ('file' not in request.files) or not request.files['file']: + abort(400, "no file found") + + data = {'user_id': user_id, 'document_type': "PatientReport", 'allowed_extensions': ['pdf']} + try: + doc = UserDocument.from_post(request.files['file'],data) + except ValueError as e: + abort(400, str(e)) + + db.session.add(doc) + db.session.commit() + + return jsonify(message="ok") \ No newline at end of file diff --git a/tests/test_user_document.py b/tests/test_user_document.py new file mode 100644 index 0000000000..3183533a93 --- /dev/null +++ b/tests/test_user_document.py @@ -0,0 +1,25 @@ +"""Unit test module for terms of use logic""" +import json +from flask_webtest import SessionScope +from tempfile import NamedTemporaryFile +from StringIO import StringIO + +from tests import TestCase, TEST_USER_ID +from portal.extensions import db +from portal.models.user_document import UserDocument + + +class TestUserDocument(TestCase): + """User Document tests""" + + def test_post_patient_report(self): + #tests whether we can successfully post a patient report -type user doc file + service_user = self.add_service_user() + self.login(user_id=service_user.id) + tmpfile = NamedTemporaryFile(suffix='.pdf') + tmpfileIO = StringIO(tmpfile.read()) + rv = self.app.post('/api/user/{}/patient_report'.format(service_user.id), + content_type='multipart/form-data', + data=dict({'file': (tmpfileIO, tmpfile.name)})) + self.assert200(rv) + tmpfile.close() From 978f2c7764cee72252228c1f187a6564e90b09ee Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Thu, 15 Dec 2016 11:57:44 -0800 Subject: [PATCH 034/708] Implement patient search in a FHIR compliant manner. (only search on email functional at this time) --- portal/app.py | 2 + portal/views/patient.py | 87 +++++++++++++++++++++++++++++++++++++++++ tests/test_patient.py | 34 ++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 portal/views/patient.py create mode 100644 tests/test_patient.py diff --git a/portal/app.py b/portal/app.py index b8adaf1e5d..05baf978f6 100644 --- a/portal/app.py +++ b/portal/app.py @@ -26,6 +26,7 @@ from .views.filters import filters_blueprint from .views.group import group_api from .views.intervention import intervention_api +from .views.patient import patient_api from .views.patients import patients from .views.procedure import procedure_api from .views.portal import portal, page_not_found, server_error @@ -47,6 +48,7 @@ group_api, intervention_api, org_api, + patient_api, patients, procedure_api, portal, diff --git a/portal/views/patient.py b/portal/views/patient.py new file mode 100644 index 0000000000..15b6ae77c0 --- /dev/null +++ b/portal/views/patient.py @@ -0,0 +1,87 @@ +"""Patient API - implements patient specific views such as patient search + +NB - this is not to be confused with 'patients', which defines views +for providers + +""" +from flask import abort, Blueprint, request +from werkzeug.exceptions import Unauthorized + +from ..audit import auditable_event +from .demographics import demographics +from ..extensions import oauth +from ..models.role import ROLE +from ..models.user import current_user, User + + +patient_api = Blueprint('patient_api', __name__, url_prefix='/api/patient') + + +@patient_api.route('/') +@oauth.require_oauth() +def patient_search(): + """Looks up patient from given parameters, returns FHIR Patient if found + + Takes key=value pairs to look up. At this time, only email is supported. + + Example search: + /api/patient?email=username@server.com + + Returns a FHIR patient resource (http://www.hl7.org/fhir/patient.html) + formatted in JSON if a match is found, 404 otherwise. + + NB - the results are restricted to users with the patient role. It is + therefore possible to get no results from this and still see a unique email + collision from existing non-patient users. + + --- + tags: + - Patient + operationId: patient_search + produces: + - application/json + parameters: + - name: search_parameters + in: query + description: Search parameters, such as `email` + required: true + type: string + responses: + 200: + description: + Returns FHIR patient resource (http://www.hl7.org/fhir/patient.html) + in JSON if a match is found. Otherwise responds with a 404 status + code. + 401: + description: + if missing valid OAuth token + 404: + description: + if there is no match found, or the user lacks permission to look + up details on the match. + + """ + ## search criteria - only email used at this time + search_params = {} + for k,v in request.args.items(): + if k == 'email': + search_params[k] = v + else: + abort(400, "can't search on '{}' at this time".format(k)) + + match = User.query.filter_by(**search_params) + if match.count() > 1: + abort(400, "can't yet bundle results, multiple found") + if match.count() == 1: + user = match.one() + try: + current_user().check_role(permission='view', other_id=user.id) + if user.has_role(ROLE.PATIENT): + return demographics(patient_id=user.id) + except Unauthorized: + # Mask unauthorized as a not-found. Don't want unauthed users + # farming information + auditable_event("looking up users with inadequate permission", + user_id=current_user().id) + abort(404) + abort(404) diff --git a/tests/test_patient.py b/tests/test_patient.py new file mode 100644 index 0000000000..78c0073679 --- /dev/null +++ b/tests/test_patient.py @@ -0,0 +1,34 @@ +"""Test module for patient specific APIs""" +from tests import TestCase, TEST_USERNAME +from portal.models.role import ROLE + + +class TestPatient(TestCase): + + def test_email_search(self): + self.promote_user(role_name=ROLE.PATIENT) + self.login() + rv = self.app.get( + '/api/patient?email={}'.format(TEST_USERNAME), + follow_redirects=True) + # Known patient but w/o patient role should 404 + self.assert200(rv) + self.assertTrue(rv.json['resourceType'] == 'Patient') + + def test_email_search_non_patient(self): + self.login() + rv = self.app.get( + '/api/patient?email={}'.format(TEST_USERNAME), + follow_redirects=True) + # Known patient but w/o patient role should 404 + self.assert404(rv) + + def test_inadequate_perms(self): + dummy = self.add_user(username='dummy@example.com') + self.promote_user(user=dummy, role_name=ROLE.PATIENT) + self.login() + rv = self.app.get( + '/api/patient?email={}'.format('dummy@example.com'), + follow_redirects=True) + # w/o permission, should see a 404 not a 401 on search + self.assert404(rv) From 717b716a7ad14778e2f39eb235ef1fda10f7da64 Mon Sep 17 00:00:00 2001 From: OptimusRhine Date: Thu, 15 Dec 2016 12:01:22 -0800 Subject: [PATCH 035/708] fixing schema error, expanding test, minor text fixes --- portal/config.py | 1 + portal/models/user_document.py | 4 ++-- portal/views/user.py | 10 ++++------ tests/test_user_document.py | 19 +++++++++++++++---- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/portal/config.py b/portal/config.py index b731d12512..536756b411 100644 --- a/portal/config.py +++ b/portal/config.py @@ -52,6 +52,7 @@ class BaseConfig(object): GOOGLE_CONSUMER_SECRET = os.environ.get('GOOGLE_CONSUMER_SECRET', '') DEFAULT_LOCALE = 'en_US' + FILE_UPLOAD_DIR = 'uploads' LR_ORIGIN = 'https://stg-lr7.us.truenth.org' LR_GROUP = 20147 diff --git a/portal/models/user_document.py b/portal/models/user_document.py index 4782cb7465..3de54b5354 100644 --- a/portal/models/user_document.py +++ b/portal/models/user_document.py @@ -46,7 +46,7 @@ def from_post(cls, upload_file, data): raise ValueError("user not found") if not data['document_type']: raise ValueError('must provide document type') - if upload_file.filename == '': + if not (upload_file.filename and upload_file.filename.strip()): raise ValueError("invalid filename") filename = secure_filename(upload_file.filename) filetype = filename.rsplit('.', 1)[1] @@ -54,7 +54,7 @@ def from_post(cls, upload_file, data): raise ValueError("filetype must be one of: " + ", ".join(data['allowed_extensions'])) file_uuid = uuid4() try: - upload_file.save(os.path.join(current_app.root_path,"uploads",str(file_uuid))) + upload_file.save(os.path.join(current_app.root_path,current_app.config.get("FILE_UPLOAD_DIR"),str(file_uuid))) except: raise ValueError("could not save file") diff --git a/portal/views/user.py b/portal/views/user.py index 652d38bd3b..f6d96b10f0 100644 --- a/portal/views/user.py +++ b/portal/views/user.py @@ -1261,12 +1261,10 @@ def upload_user_document(user_id): required: true type: integer format: int64 - - in: body - name: body - schema: - id: file - required: - - file + properties: + file: + type: file + description: File to upload responses: 200: description: successful operation diff --git a/tests/test_user_document.py b/tests/test_user_document.py index 3183533a93..b5659a73e6 100644 --- a/tests/test_user_document.py +++ b/tests/test_user_document.py @@ -1,8 +1,9 @@ -"""Unit test module for terms of use logic""" -import json +"""Unit test module for user document logic""" from flask_webtest import SessionScope from tempfile import NamedTemporaryFile from StringIO import StringIO +from flask import current_app +import os from tests import TestCase, TEST_USER_ID from portal.extensions import db @@ -16,10 +17,20 @@ def test_post_patient_report(self): #tests whether we can successfully post a patient report -type user doc file service_user = self.add_service_user() self.login(user_id=service_user.id) + test_contents = "This is a test." tmpfile = NamedTemporaryFile(suffix='.pdf') + tmpfile.write(test_contents) + tmpfile.seek(0) tmpfileIO = StringIO(tmpfile.read()) rv = self.app.post('/api/user/{}/patient_report'.format(service_user.id), - content_type='multipart/form-data', - data=dict({'file': (tmpfileIO, tmpfile.name)})) + content_type='multipart/form-data', + data=dict({'file': (tmpfileIO, tmpfile.name)})) self.assert200(rv) tmpfile.close() + udoc = db.session.query(UserDocument).order_by(UserDocument.id.desc()).first() + fpath = os.path.join(current_app.root_path, + current_app.config.get("FILE_UPLOAD_DIR"), + str(udoc.uuid)) + with open(fpath, 'r') as udoc_file: + self.assertEqual(udoc_file.read(),test_contents) + os.remove(fpath) From 1054bf6aa853c1f49d65325062022834f74972b5 Mon Sep 17 00:00:00 2001 From: OptimusRhine Date: Thu, 15 Dec 2016 12:06:09 -0800 Subject: [PATCH 036/708] using 'with' for tempfile (cleaner) --- tests/test_user_document.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/test_user_document.py b/tests/test_user_document.py index b5659a73e6..2fe7e53f36 100644 --- a/tests/test_user_document.py +++ b/tests/test_user_document.py @@ -18,15 +18,18 @@ def test_post_patient_report(self): service_user = self.add_service_user() self.login(user_id=service_user.id) test_contents = "This is a test." - tmpfile = NamedTemporaryFile(suffix='.pdf') - tmpfile.write(test_contents) - tmpfile.seek(0) - tmpfileIO = StringIO(tmpfile.read()) - rv = self.app.post('/api/user/{}/patient_report'.format(service_user.id), - content_type='multipart/form-data', - data=dict({'file': (tmpfileIO, tmpfile.name)})) - self.assert200(rv) - tmpfile.close() + with NamedTemporaryFile( + prefix='udoc_test_', + suffix='.pdf', + delete=True, + ) as temp_pdf: + temp_pdf.write(test_contents) + temp_pdf.seek(0) + tempfileIO = StringIO(temp_pdf.read()) + rv = self.app.post('/api/user/{}/patient_report'.format(service_user.id), + content_type='multipart/form-data', + data=dict({'file': (tempfileIO, temp_pdf.name)})) + self.assert200(rv) udoc = db.session.query(UserDocument).order_by(UserDocument.id.desc()).first() fpath = os.path.join(current_app.root_path, current_app.config.get("FILE_UPLOAD_DIR"), From 156e0e55e0eaf38b67191c2ea29da09dc759cf17 Mon Sep 17 00:00:00 2001 From: OptimusRhine Date: Thu, 15 Dec 2016 12:38:26 -0800 Subject: [PATCH 037/708] create dir if nonexistant, changing dir config for tests --- portal/config.py | 1 + portal/models/user_document.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/portal/config.py b/portal/config.py index 536756b411..e9255345e4 100644 --- a/portal/config.py +++ b/portal/config.py @@ -73,6 +73,7 @@ class TestConfig(BaseConfig): ) WTF_CSRF_ENABLED = False + FILE_UPLOAD_DIR = 'test_uploads' class ConfigServer(Server): # pragma: no cover diff --git a/portal/models/user_document.py b/portal/models/user_document.py index 3de54b5354..107f7f0de5 100644 --- a/portal/models/user_document.py +++ b/portal/models/user_document.py @@ -54,7 +54,10 @@ def from_post(cls, upload_file, data): raise ValueError("filetype must be one of: " + ", ".join(data['allowed_extensions'])) file_uuid = uuid4() try: - upload_file.save(os.path.join(current_app.root_path,current_app.config.get("FILE_UPLOAD_DIR"),str(file_uuid))) + upload_dir = os.path.join(current_app.root_path,current_app.config.get("FILE_UPLOAD_DIR")) + if not os.path.exists(upload_dir): + os.makedirs(upload_dir) + upload_file.save(os.path.join(upload_dir,str(file_uuid))) except: raise ValueError("could not save file") From 091ce3b0cf0568ca3ade0aad5150cb780fa007fc Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Thu, 15 Dec 2016 14:32:01 -0800 Subject: [PATCH 038/708] Overlapping migration scripts - update down_revision to version checked in another branch, or this migration is missed. --- migrations/versions/b1d13b4b175a_.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/versions/b1d13b4b175a_.py b/migrations/versions/b1d13b4b175a_.py index bcfd4ec79e..10be289b61 100644 --- a/migrations/versions/b1d13b4b175a_.py +++ b/migrations/versions/b1d13b4b175a_.py @@ -8,7 +8,7 @@ # revision identifiers, used by Alembic. revision = 'b1d13b4b175a' -down_revision = '7793ca45b7f9' +down_revision = '533fc09a4d32' from alembic import op import sqlalchemy as sa From f3c891f99cddd56bff91e6080aeb83821d56c23d Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Thu, 15 Dec 2016 14:35:15 -0800 Subject: [PATCH 039/708] Remove dead whitespace and unused imports --- portal/models/user_document.py | 1 - portal/views/user.py | 6 +++--- tests/test_user_document.py | 3 +-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/portal/models/user_document.py b/portal/models/user_document.py index 107f7f0de5..40a1cbcd10 100644 --- a/portal/models/user_document.py +++ b/portal/models/user_document.py @@ -1,6 +1,5 @@ """User Document module""" from datetime import datetime -from validators import url as url_validation from werkzeug.utils import secure_filename from uuid import uuid4 from flask import current_app diff --git a/portal/views/user.py b/portal/views/user.py index f6d96b10f0..edae30cd5d 100644 --- a/portal/views/user.py +++ b/portal/views/user.py @@ -1292,10 +1292,10 @@ def upload_user_document(user_id): user = get_user(user_id) if user.deleted: abort(400, "deleted user - operation not permitted") - + if ('file' not in request.files) or not request.files['file']: abort(400, "no file found") - + data = {'user_id': user_id, 'document_type': "PatientReport", 'allowed_extensions': ['pdf']} try: doc = UserDocument.from_post(request.files['file'],data) @@ -1305,4 +1305,4 @@ def upload_user_document(user_id): db.session.add(doc) db.session.commit() - return jsonify(message="ok") \ No newline at end of file + return jsonify(message="ok") diff --git a/tests/test_user_document.py b/tests/test_user_document.py index 2fe7e53f36..a3bd8400fb 100644 --- a/tests/test_user_document.py +++ b/tests/test_user_document.py @@ -1,11 +1,10 @@ """Unit test module for user document logic""" -from flask_webtest import SessionScope from tempfile import NamedTemporaryFile from StringIO import StringIO from flask import current_app import os -from tests import TestCase, TEST_USER_ID +from tests import TestCase from portal.extensions import db from portal.models.user_document import UserDocument From bba563d1bb7a9d2ef1e4a243d68df3cbf2768029 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Thu, 15 Dec 2016 15:17:56 -0800 Subject: [PATCH 040/708] Real world testing exposed different calling conventions when mulitipart files are attached. Handle POSTs where the filename is the key, rather than 'file' as seen in online flask docs. --- portal/views/user.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/portal/views/user.py b/portal/views/user.py index edae30cd5d..4d01d7bb6a 100644 --- a/portal/views/user.py +++ b/portal/views/user.py @@ -1293,16 +1293,30 @@ def upload_user_document(user_id): if user.deleted: abort(400, "deleted user - operation not permitted") - if ('file' not in request.files) or not request.files['file']: - abort(400, "no file found") - - data = {'user_id': user_id, 'document_type': "PatientReport", 'allowed_extensions': ['pdf']} + def posted_filename(request): + """Return file regardless of POST convention + + Depending on POST convention, filename is either the key or + the second part of the file tuple, not always available as 'file'. + + :return: the posted file + """ + if not request.files or len(request.files) != 1: + abort(400, "no file found - POST single file") + key = request.files.keys()[0] # either 'file' or actual filename + filedata = request.files[key] + return filedata + + file = posted_filename(request) + data = {'user_id': user_id, 'document_type': "PatientReport", + 'allowed_extensions': ['pdf']} try: - doc = UserDocument.from_post(request.files['file'],data) + doc = UserDocument.from_post(file, data) except ValueError as e: abort(400, str(e)) db.session.add(doc) db.session.commit() - + auditable_event("patient report {} posted for user {}".format( + doc.uuid, user_id), user_id=current_user().id) return jsonify(message="ok") From e5b3c929d0a2d7be8c053d174af13958c29815bf Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Thu, 15 Dec 2016 15:28:15 -0800 Subject: [PATCH 041/708] Restore requests import - accidentally removed in earlier commit on a refactor. --- portal/views/portal.py | 1 + 1 file changed, 1 insertion(+) diff --git a/portal/views/portal.py b/portal/views/portal.py index 3f79dd3d60..a3f00ec01b 100644 --- a/portal/views/portal.py +++ b/portal/views/portal.py @@ -8,6 +8,7 @@ from sqlalchemy.orm.exc import NoResultFound from wtforms import validators, HiddenField, IntegerField, StringField from datetime import datetime +import requests from .auth import next_after_login from ..audit import auditable_event From 9b9db9f4aa25eb12e340c73ee3809eb2e1e46a12 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Thu, 15 Dec 2016 17:09:29 -0800 Subject: [PATCH 042/708] Update celery from 4.0.1 to 4.0.2 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 74f9788aae..eab5b5db49 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ bcrypt==3.1.1 beautifulsoup4==4.5.1 billiard==3.5.0.2 blinker==1.4 -celery==4.0.1 +celery==4.0.2 cffi==1.9.1 click==6.6 CommonMark==0.5.4 # pyup: <=0.5.4 # pin as workaround to https://github.com/rtfd/recommonmark/issues/24 From 032d1f4811c1cb379aceb243f72663ebeecdfb9d Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Fri, 16 Dec 2016 09:00:34 -0800 Subject: [PATCH 043/708] Update py from 1.4.31 to 1.4.32 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 74f9788aae..83de673792 100644 --- a/requirements.txt +++ b/requirements.txt @@ -60,7 +60,7 @@ prompt-toolkit==1.0.9 psycopg2==2.6.2 ptpython==0.36 ptyprocess==0.5.1 -py==1.4.31 +py==1.4.32 pycparser==2.17 pycrypto==2.6.1 pyflakes==1.3.0 From 6336fd63c0e1ef85aefbe93c4702459202ec8152 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Fri, 16 Dec 2016 12:16:32 -0800 Subject: [PATCH 044/708] Update webtest from 2.0.23 to 2.0.24 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 74f9788aae..3f6bd685f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -93,7 +93,7 @@ virtualenv==15.1.0 waitress==1.0.1 wcwidth==0.1.7 WebOb==1.6.3 -WebTest==2.0.23 +WebTest==2.0.24 Werkzeug==0.11.11 WTForms==2.1 xvfbwrapper==0.2.8 From 90ac65d475c71c15231bdbf2c8de55f02962e354 Mon Sep 17 00:00:00 2001 From: Amy Chen Date: Sun, 18 Dec 2016 21:05:00 -0800 Subject: [PATCH 045/708] timeout fixes to include timeout message on logout page and start timeout session when user is loginned in. --- portal/templates/landing.html | 19 +++++++++++-- portal/templates/portal_wrapper.html | 40 +++++++++++++++------------- portal/views/auth.py | 2 +- portal/views/portal.py | 4 ++- 4 files changed, 42 insertions(+), 23 deletions(-) diff --git a/portal/templates/landing.html b/portal/templates/landing.html index 5acf7b38e1..3d63bf5fe9 100644 --- a/portal/templates/landing.html +++ b/portal/templates/landing.html @@ -7,6 +7,16 @@ #mainDiv { background: none; } + #timeOutMessageContainer { + margin: 1em auto; + padding: 0.5em 1em; + font-weight: bold; + background-color: #ddd; + color:#a94442; + border-radius: 4px; + width: 400px; + max-width: 100% + }

@@ -17,13 +27,18 @@
-
+
{{ app_text('landing title') }}
{{ app_text('landing sub-title') }}
{{ _("Register") }}    {{ _("Login") }} - + {% if timed_out %} +
+ You have been logged out due to inactivity. Please log in again to continue. +
+ {% endif %}
+ diff --git a/portal/templates/portal_wrapper.html b/portal/templates/portal_wrapper.html index b876bdb318..0f796c8a99 100644 --- a/portal/templates/portal_wrapper.html +++ b/portal/templates/portal_wrapper.html @@ -42,24 +42,26 @@ setTimeout('$("#tnthNavWrapper").css("visibility","visible");', 10); // Configure and start the session timeout monitor + {% if user %} + var sessMon = sessionMonitor({ + sessionLifetime: DEFAULT_SESSION_LIFETIME, + timeBeforeWarning: 1 * 60 * 1000, + minPingInterval: 1 * 60 * 1000, // 1 minute + activityEvents: "mouseup", + pingUrl: '{{PORTAL}}/api/ping', + logoutUrl: '{{PORTAL}}/logout', + timeoutUrl: '{{PORTAL}}/logout?timed_out=1', + onwarning: function() {$("#session-warning-modal").modal("show");} + }); + window.sessMon = sessMon; + // Configure the session timeout warning modal + $("#session-warning-modal").modal({"backdrop": false,"keyboard": false,"show": false}) + .on("click", "#stay-logged-in", sessMon.extendsess) + .on("click", "#log-out", sessMon.logout) + .find("#remaining-time").text(sessMon.timeBeforeWarning / 1000); + {% endif %} + }); - var sessMon = sessionMonitor({ - sessionLifetime: DEFAULT_SESSION_LIFETIME, - timeBeforeWarning: 1 * 60 * 1000, - minPingInterval: 1 * 60 * 1000, // 1 minute - activityEvents: "mouseup", - pingUrl: '{{PORTAL}}/api/ping', - logoutUrl: '{{PORTAL}}/logout', - timeoutUrl: '{{PORTAL}}/logout?timed_out=1', - onwarning: function() {$("#session-warning-modal").modal("show");} - }); - window.sessMon = sessMon; - // Configure the session timeout warning modal - $("#session-warning-modal").modal({"backdrop": false,"keyboard": false,"show": false}) - .on("click", "#stay-logged-in", sessMon.extendsess) - .on("click", "#log-out", sessMon.logout) - .find("#remaining-time").text(sessMon.timeBeforeWarning / 1000); - }); {% if config.PIWIK_SITEID %} + {% endif %} {%- endmacro %} @@ -718,7 +719,7 @@

{{ _("for") + " " + person.username }}

{{ _("Clinical Question") }}

{{ _("Question inquired of the patient at account creation") }}

- +

@@ -763,7 +764,7 @@

{{ _("Clinical Question

{{ _("Clinical Questions") }}

{{ _("Questions inquired of the patient at registration") }}

- +
{{patientQGroup(current_user)}}
@@ -847,7 +848,7 @@

{{ _("Questions inquired of the patient at registration")

{{ _("Intervention Reports") }}

- {% if person.provider_html() %} + {% if person and person.provider_html() %} {{person.provider_html() | safe}} {% else %}
{{ _("No Reports Available") }}
@@ -951,7 +952,7 @@

{{ _("PRO Assessments") }} {{profileClinicalPCALocalized(person, current_user) | show_macro('pca_localized')}} {% endif %} - {% if person.has_role('patient') %} + {% if person and person.has_role('patient') %}
@@ -964,7 +965,7 @@

{{ _("Clinical Data") }}

{% endif %} - {% if person.has_role('patient') %} + {% if person and person.has_role('patient') %} {{profileInterventionReports(person) | show_macro('intervention_reports')}} {% endif %} @@ -977,7 +978,7 @@

{{ _("Organization Affiliation") }}

- {%if person.has_role('patient')%} + {%if person and person.has_role('patient')%}
@@ -1035,11 +1036,11 @@

{{ _("Audit Log") }}

#procedureForm { margin-left: 0.5em;} .procedureDateLabel { margin-bottom: 0;} -

{%if current_user.has_role('provider') and current_user.id != person.id %}{{ _("Which of the following prostate cancer treatments has the patient had, if any? If you don't remember the exact date, please make your best guess.") }}{%else%}{{ _("Which of the following prostate cancer treatments have you had, if any? If you don't remember the exact +

{%if current_user and current_user.has_role('provider') and current_user.id != person.id %}{{ _("Which of the following prostate cancer treatments has the patient had, if any? If you don't remember the exact date, please make your best guess.") }}{%else%}{{ _("Which of the following prostate cancer treatments have you had, if any? If you don't remember the exact date, please make your best guess.") }}{%endif%}

-

{%if current_user.id == person.id%}{{ _("Your treatments") }}{%else%}{{ _("Treatments") }}{%endif%}:

+

{%if current_user and (current_user.id == person.id) %}{{ _("Your treatments") }}{%else%}{{ _("Treatments") }}{%endif%}:

@@ -1083,10 +1084,10 @@

{{ _("Audit Log") }}

- +
- @@ -1103,7 +1104,7 @@

{{ _("Audit Log") }}

- +
@@ -1119,12 +1120,12 @@

{{ _("Audit Log") }}


From cfa8b2b09a51224398e7a2f2aaef2c771a162933 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 19 Dec 2016 19:17:42 -0800 Subject: [PATCH 051/708] Update xvfbwrapper from 0.2.8 to 0.2.9 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8b88327967..c7c6b39c29 100644 --- a/requirements.txt +++ b/requirements.txt @@ -96,7 +96,7 @@ WebOb==1.6.3 WebTest==2.0.24 Werkzeug==0.11.11 WTForms==2.1 -xvfbwrapper==0.2.8 +xvfbwrapper==0.2.9 -e . ### From 016d572ef52a2870d988a021146fe825c69a973f Mon Sep 17 00:00:00 2001 From: Amy Chen Date: Mon, 19 Dec 2016 22:02:02 -0800 Subject: [PATCH 052/708] front end password refinements to include refined validation message --- portal/static/css/eproms.css | 2 +- portal/static/less/eproms.less | 2 +- portal/templates/flask_user/_macros.html | 2 +- portal/templates/flask_user/register.html | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/portal/static/css/eproms.css b/portal/static/css/eproms.css index a1e5a1e497..eea35dfc49 100644 --- a/portal/static/css/eproms.css +++ b/portal/static/css/eproms.css @@ -51,7 +51,7 @@ body { #socialMediaRegistrationContainer { display: none; } -#loginEmailReminderText { +#emailInfoText { display: none; } #mainHolder { diff --git a/portal/static/less/eproms.less b/portal/static/less/eproms.less index 362d2b4ae1..fea99f233f 100644 --- a/portal/static/less/eproms.less +++ b/portal/static/less/eproms.less @@ -80,7 +80,7 @@ body { display: none; } -#loginEmailReminderText { +#emailInfoText { display: none; } diff --git a/portal/templates/flask_user/_macros.html b/portal/templates/flask_user/_macros.html index 3eb265a2a6..bf9146306c 100644 --- a/portal/templates/flask_user/_macros.html +++ b/portal/templates/flask_user/_macros.html @@ -16,7 +16,7 @@ {% endif %} {% endif %}
-
{{infoText|safe}}
+
{{infoText|safe}}
{%- endmacro %} diff --git a/portal/templates/flask_user/register.html b/portal/templates/flask_user/register.html index 0cc9b3e6c1..6a7cdda14e 100644 --- a/portal/templates/flask_user/register.html +++ b/portal/templates/flask_user/register.html @@ -49,7 +49,8 @@

Register for TrueNTH

{{ render_field(form.email, label="Email", label_visible=false, tabindex=230, data_customemail="true" ) }} {% endif %} - {{ render_field(form.password, label="Password", label_visible=false, helpText="Password must have at least 6 characters with one lowercase letter, one uppercase letter and one number", tabindex=230) }} + {{ render_field(form.password, label="Password", label_visible=false, data_error="Oops, the password does not meet the minimum requirements.", pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{6,}$", + helpText="Password must have at least 6 characters with one lowercase letter, one uppercase letter and one number", tabindex=230) }} {% if user_manager.enable_retype_password %} {{ render_field(form.retype_password, label="Retype Password", label_visible=false, data_match="#password", data_match_error="Oops, the two password fields do not match.", tabindex=240) }} From 9396e099c042013c8099aa4a2100d3d8ad4cdbe2 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Tue, 20 Dec 2016 10:00:14 -0800 Subject: [PATCH 053/708] Small refactoring of common db loading of codeable concepts for given codings. --- portal/models/fhir.py | 51 ++++++++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/portal/models/fhir.py b/portal/models/fhir.py index 5d094509ed..1fa3846f50 100644 --- a/portal/models/fhir.py +++ b/portal/models/fhir.py @@ -184,6 +184,29 @@ def add_if_not_found(self): return self +def codeable_concept_with_coding(system, code, display=None): + """Lookup codeable_concept which includes the given coding(system, code) + + If no such coding is found, a new one will be generated IFF display + is provided. + + If no codeable_concept is found for the given coding, one will be + generated, stored in the db, and returned + + NB - a CodeableConcept may have any number of codings, but for any + given coding, exactly one CodeableConcept should contain it. + + """ + if display: + coding = Coding( + system=system, code=code, display=display).add_if_not_found() + else: + coding = Coding.query.filter_by(system=system, code=code).one() + cc = CodeableConcept(codings=[coding,]).add_if_not_found(True) + assert coding in cc.codings + return cc + + """ TrueNTH Clinical Codes """ class ClinicalConstants(object): @@ -195,35 +218,23 @@ def __iter__(self): @lazyprop def BIOPSY(self): - coding = Coding.query.filter_by( - system=TRUENTH_CLINICAL_CODE_SYSTEM, code='111').one() - cc = CodeableConcept(codings=[coding,]).add_if_not_found(True) - assert coding in cc.codings - return cc + return codeable_concept_with_coding( + system=TRUENTH_CLINICAL_CODE_SYSTEM, code='111') @lazyprop def PCaDIAG(self): - coding = Coding.query.filter_by( - system=TRUENTH_CLINICAL_CODE_SYSTEM, code='121').one() - cc = CodeableConcept(codings=[coding,]).add_if_not_found(True) - assert coding in cc.codings - return cc + return codeable_concept_with_coding( + system=TRUENTH_CLINICAL_CODE_SYSTEM, code='121') @lazyprop def PCaLocalized(self): - coding = Coding.query.filter_by( - system=TRUENTH_CLINICAL_CODE_SYSTEM, code='141').one() - cc = CodeableConcept(codings=[coding,]).add_if_not_found(True) - assert coding in cc.codings - return cc + return codeable_concept_with_coding( + system=TRUENTH_CLINICAL_CODE_SYSTEM, code='141') @lazyprop def TX(self): - coding = Coding.query.filter_by( - system=TRUENTH_CLINICAL_CODE_SYSTEM, code='131').one() - cc = CodeableConcept(codings=[coding,]).add_if_not_found(True) - assert coding in cc.codings - return cc + return codeable_concept_with_coding( + system=TRUENTH_CLINICAL_CODE_SYSTEM, code='131') @lazyprop def TRUE_VALUE(self): From f967986b1bb948bbdd50e1b7fb7fce3a45f3627b Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Tue, 20 Dec 2016 10:01:38 -0800 Subject: [PATCH 054/708] For #134797341, implemented functions to determine if we've gathered data for treatment started state. --- portal/models/procedure_codes.py | 119 +++++++++++++++++++++++++++++++ tests/test_procedure.py | 44 ++++++++++-- 2 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 portal/models/procedure_codes.py diff --git a/portal/models/procedure_codes.py b/portal/models/procedure_codes.py new file mode 100644 index 0000000000..dc521409dc --- /dev/null +++ b/portal/models/procedure_codes.py @@ -0,0 +1,119 @@ +"""Module for pre defined procedure codes and shortcuts""" + +from .fhir import codeable_concept_with_coding +from .lazy import lazyprop + + +SNOMED='http://snomed.info/sct' + + +def known_treatment_started(user): + """Returns True if the user has a procedure suggesting Tx started + + If the user has a procedure suggesting treatment has started, such as + 'Radical prostatectomy' or 'Androgen deprivation therapy' (and several + others), this function will return True. + + A lack of information will return False, i.e. if user hasn't yet + set any procedure information. + + """ + cc_ids = set(cc.id for cc in TxStartedConstants()) + has_procs = set(proc.code_id for proc in user.procedures) + return not cc_ids.isdisjoint(has_procs) + + +def known_treatment_not_started(user): + """Returns True if the user has a procedure suggesting no Tx started + + If the user has a procedure suggesting treatment hasn't started, such as + 'Started watchful waiting', 'Started active surveillance' or + 'None of the Above', this will return True. + + A lack of information will return False, i.e. if user hasn't yet + set any procedure information. + + """ + cc_ids = set(cc.id for cc in TxNotStartedConstants()) + has_procs = set(proc.code_id for proc in user.procedures) + return not cc_ids.isdisjoint(has_procs) + + +class TxStartedConstants(object): + """Attributes for known 'treatment started' codings + + Simple containment class with lazy loaded attributes for each + codeable concept containing a known treatment started coding. + + """ + + def __iter__(self): + for attr in dir(self): + if attr.startswith('_'): + continue + yield getattr(self, attr) + + @lazyprop + def RadicalProstatectomy(self): + return codeable_concept_with_coding( + system=SNOMED, code='26294005', + display='Radical prostatectomy (nerve-sparing)') + + @lazyprop + def RadicalProstatectomyNNS(self): + return codeable_concept_with_coding( + system=SNOMED, code='26294005-nns', + display='Radical prostatectomy (non-nerve-sparing)') + + @lazyprop + def ExternalBeamRadiationTherapy(self): + return codeable_concept_with_coding( + system=SNOMED, code='33195004', + display='External beam radiation therapy') + + @lazyprop + def Brachytherapy(self): + return codeable_concept_with_coding( + system=SNOMED, code='228748004', + display='Brachytherapy') + + @lazyprop + def AndrogenDeprivationTherapy(self): + return codeable_concept_with_coding( + system=SNOMED, code='707266006', + display='Androgen deprivation therapy') + + +class TxNotStartedConstants(object): + """Attributes for known 'treatment not started' codings + + Simple containment class with lazy loaded attributes for each + codeable concept containing a known procedure started coding. + + """ + + def __iter__(self): + for attr in dir(self): + if attr.startswith('_'): + continue + yield getattr(self, attr) + + @lazyprop + def StartedWatchfulWaiting(self): + return codeable_concept_with_coding( + system=SNOMED, code='373818007', + display='Started watchful waiting') + + @lazyprop + def StartedActiveSurveillance(self): + return codeable_concept_with_coding( + system=SNOMED, code='424313000', + display='Started active surveillance') + + @lazyprop + def NoneOfTheAbove(self): + return codeable_concept_with_coding( + system=SNOMED, code='999999999', + display='None of the above') + + diff --git a/tests/test_procedure.py b/tests/test_procedure.py index 4b906b1410..21dd124e3c 100644 --- a/tests/test_procedure.py +++ b/tests/test_procedure.py @@ -13,20 +13,22 @@ from portal.models.audit import Audit from portal.models.fhir import Coding, CodeableConcept, FHIR_datetime from portal.models.procedure import Procedure +from portal.models.procedure_codes import known_treatment_started +from portal.models.procedure_codes import known_treatment_not_started from portal.models.reference import Reference class TestProcedure(TestCase): - def prep_db_for_procedure(self): + def prep_db_for_procedure(self, code='367336001', display='Chemotherapy'): # First push some procedure data into the db for the test user with SessionScope(db): audit = Audit(user_id=TEST_USER_ID) procedure = Procedure(audit=audit) coding = Coding(system='http://snomed.info/sct', - code='367336001', - display='Chemotherapy') - code = CodeableConcept(codings=[coding,]) + code=code, + display=display).add_if_not_found() + code = CodeableConcept(codings=[coding,]).add_if_not_found() procedure.code = code procedure.user = self.test_user procedure.start_time = datetime.utcnow() @@ -145,3 +147,37 @@ def test_procedureDELETE(self): self.assert200(rv) self.assertRaises(NoResultFound, Procedure.query.one) self.assertEquals(self.test_user.procedures.count(), 0) + + def test_treatment_started(self): + # list of codes indicating 'treatment started' - handle accordingly + started_codes = ( + ('26294005', 'Radical prostatectomy (nerve-sparing)'), + ('26294005-nns', 'Radical prostatectomy (non-nerve-sparing)'), + ('33195004', 'External beam radiation therapy'), + ('228748004', 'Brachytherapy'), + ('707266006', 'Androgen deprivation therapy') + ) + + # prior to setting any procedures, should return false + self.assertFalse(known_treatment_started(self.test_user)) + + for code, display in started_codes: + self.prep_db_for_procedure(code, display) + self.test_user = db.session.merge(self.test_user) + self.assertTrue(known_treatment_started(self.test_user)) + + def test_treatment_not_started(self): + # list of codes indicating 'treatment not started' - handle accordingly + not_started_codes = ( + ('373818007', 'Started watchful waiting'), + ('424313000', 'Started active surveillance'), + ('999999999', 'None of the above') + ) + + # prior to setting any procedures, should return false + self.assertFalse(known_treatment_not_started(self.test_user)) + + for code, display in not_started_codes: + self.prep_db_for_procedure(code, display) + self.test_user = db.session.merge(self.test_user) + self.assertTrue(known_treatment_not_started(self.test_user)) From 123f721b38c1aa4dbadf8b882f6ce552b1f510ff Mon Sep 17 00:00:00 2001 From: Amy Chen Date: Tue, 20 Dec 2016 11:16:22 -0800 Subject: [PATCH 055/708] add write-only role for newly created account --- portal/templates/profile_create.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portal/templates/profile_create.html b/portal/templates/profile_create.html index 7d36220344..3380fa4712 100644 --- a/portal/templates/profile_create.html +++ b/portal/templates/profile_create.html @@ -287,7 +287,7 @@

{{ _("New User") }}

//Adding roles of patient and write_only for user Promise.all([ _get('/api/demographics/'+_userId, 'PUT', JSON.stringify(_demoArray)), - _get('/api/user/'+_userId+'/roles', 'PUT', JSON.stringify({'roles': [{'name': 'patient'}]}))]) + _get('/api/user/'+_userId+'/roles', 'PUT', JSON.stringify({'roles': [{'name': 'patient'}, {'name': 'write_only'}]}))]) .then(function(results) { // Both promises resolved //console.log('promises resolved'); From f68c40d2a6837d2301402b26cf5c2f35a503953d Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Tue, 20 Dec 2016 12:18:27 -0800 Subject: [PATCH 056/708] Need to purge procedures on each loop in test. --- tests/test_procedure.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_procedure.py b/tests/test_procedure.py index 21dd124e3c..8f9d7befe5 100644 --- a/tests/test_procedure.py +++ b/tests/test_procedure.py @@ -165,6 +165,7 @@ def test_treatment_started(self): self.prep_db_for_procedure(code, display) self.test_user = db.session.merge(self.test_user) self.assertTrue(known_treatment_started(self.test_user)) + self.test_user.procedures.delete() # reset for next iteration def test_treatment_not_started(self): # list of codes indicating 'treatment not started' - handle accordingly @@ -181,3 +182,4 @@ def test_treatment_not_started(self): self.prep_db_for_procedure(code, display) self.test_user = db.session.merge(self.test_user) self.assertTrue(known_treatment_not_started(self.test_user)) + self.test_user.procedures.delete() # reset for next iteration From 9e4ad1e23430aff79564e833f251c04bfe279123 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Tue, 20 Dec 2016 12:19:14 -0800 Subject: [PATCH 057/708] Move SNOMED constant into logical place. --- portal/models/procedure_codes.py | 4 +--- portal/system_uri.py | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/portal/models/procedure_codes.py b/portal/models/procedure_codes.py index dc521409dc..96f3f6390d 100644 --- a/portal/models/procedure_codes.py +++ b/portal/models/procedure_codes.py @@ -2,9 +2,7 @@ from .fhir import codeable_concept_with_coding from .lazy import lazyprop - - -SNOMED='http://snomed.info/sct' +from ..system_uri import SNOMED def known_treatment_started(user): diff --git a/portal/system_uri.py b/portal/system_uri.py index f53632d325..9f9fdf9c88 100644 --- a/portal/system_uri.py +++ b/portal/system_uri.py @@ -1,5 +1,7 @@ """Namespace module to house system URIs for use in FHIR""" +SNOMED='http://snomed.info/sct' + # Our common, unique namespace TRUENTH_NAMESPACE = 'http://us.truenth.org' From 93966b21ec282b36f9c713e5aaf47540e8ae4acd Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Tue, 20 Dec 2016 13:53:25 -0800 Subject: [PATCH 058/708] Removing /api/clinical/tx in favor of always using /api/procedure See https://www.pivotaltracker.com/n/projects/1225464/stories/134797341 for back story. --- portal/models/coredata.py | 2 +- portal/models/fhir.py | 9 +- portal/models/intervention_strategies.py | 2 +- portal/static/js/main.js | 2 - portal/templates/initial_queries.html | 23 ++--- portal/templates/initial_queries_macros.html | 18 ---- portal/templates/profile.html | 2 +- portal/templates/profile_create.html | 2 +- portal/templates/profile_macros.html | 8 +- portal/templates/questions.html | 19 +---- portal/views/clinical.py | 90 -------------------- tests/__init__.py | 2 +- tests/test_clinical.py | 27 ------ 13 files changed, 19 insertions(+), 187 deletions(-) diff --git a/portal/models/coredata.py b/portal/models/coredata.py index 79de78243d..5f7223fec9 100644 --- a/portal/models/coredata.py +++ b/portal/models/coredata.py @@ -147,7 +147,7 @@ def hasdata(self, user): return len(user.roles) > 0 required = {item: False for item in ( - CC.BIOPSY, CC.PCaDIAG, CC.TX, CC.PCaLocalized)} + CC.BIOPSY, CC.PCaDIAG, CC.PCaLocalized)} for obs in user.observations: if obs.codeable_concept in required: diff --git a/portal/models/fhir.py b/portal/models/fhir.py index 1fa3846f50..4e761327ef 100644 --- a/portal/models/fhir.py +++ b/portal/models/fhir.py @@ -231,11 +231,6 @@ def PCaLocalized(self): return codeable_concept_with_coding( system=TRUENTH_CLINICAL_CODE_SYSTEM, code='141') - @lazyprop - def TX(self): - return codeable_concept_with_coding( - system=TRUENTH_CLINICAL_CODE_SYSTEM, code='131') - @lazyprop def TRUE_VALUE(self): value_quantity = ValueQuantity( @@ -504,10 +499,8 @@ def add_static_concepts(only_quick=False): display='PCa diagnosis') PCaLocalized = Coding(system=TRUENTH_CLINICAL_CODE_SYSTEM, code='141', display='PCa localized diagnosis') - TX = Coding(system=TRUENTH_CLINICAL_CODE_SYSTEM, code='131', - display='treatment begun') - concepts = [BIOPSY, PCaDIAG, PCaLocalized, TX] + concepts = [BIOPSY, PCaDIAG, PCaLocalized] concepts += fetch_local_valueset(NHHD_291036) if not only_quick: concepts += fetch_HL7_V3_Namespace('Ethnicity') diff --git a/portal/models/intervention_strategies.py b/portal/models/intervention_strategies.py index ac161e5330..9887939949 100644 --- a/portal/models/intervention_strategies.py +++ b/portal/models/intervention_strategies.py @@ -303,7 +303,7 @@ def observation_check(display, boolean_value): def user_has_matching_observation(intervention, user): obs = [o for o in user.observations if o.codeable_concept_id == cc_id] if obs and obs[0].value_quantity == vq: - _log(result=True, func_name='diag_no_tx', user=user, + _log(result=True, func_name='observation_check', user=user, intervention=intervention.name, message='{}:{}'.format(coding.display, vq.value)) return True diff --git a/portal/static/js/main.js b/portal/static/js/main.js index d23f8c3fd1..92f8f623bd 100644 --- a/portal/static/js/main.js +++ b/portal/static/js/main.js @@ -110,8 +110,6 @@ var fillContent = { var clinicalItem = val.content.code.coding[0].display; if (clinicalItem == "PCa diagnosis") { clinicalItem = "pca_diag"; - } else if (clinicalItem == "treatment begun") { - clinicalItem = "tx"; } else if (clinicalItem == "PCa localized diagnosis") { clinicalItem = "pca_localized"; } diff --git a/portal/templates/initial_queries.html b/portal/templates/initial_queries.html index b5bde863a7..8420604ac9 100644 --- a/portal/templates/initial_queries.html +++ b/portal/templates/initial_queries.html @@ -93,7 +93,7 @@

{{ _("Thank you for registering.") }}

'nameGroup': [$("#firstname"), $("#lastname")], 'rolesGroup': [$("input[name='user_type']")], 'bdGroup': [$("#month"), $("#date"), $("#year")], - 'patientQ': [$("input[name='biopsy']"), $("input[name='pca_diag']"), $("input[name='pca_localized']"), $("input[name='tx']")], + 'patientQ': [$("input[name='biopsy']"), $("input[name='pca_diag']"), $("input[name='pca_localized']")], 'clinics': [$("#userOrgs input[name='organization'][data-parent-id]")] }; var _isChecked = false; @@ -225,38 +225,29 @@

{{ _("Thank you for registering.") }}

tnthAjax.putClinical({{user.id}},toCall,toSend); if (toSend == "true" || toCall == "pca_localized") { thisItem.parents(".pat-q").next().fadeIn(); - if (toCall == "tx") { - $("#clinics").fadeIn(); + var nextRadio = thisItem.closest(".pat-q").next(".pat-q"); + var nextItem = nextRadio.length > 0 ? nextRadio : thisItem.parents(".pat-q").next(); + if (nextItem.length > 0) { $('html, body').animate({ - scrollTop: $('#clinics').offset().top + scrollTop: nextItem.offset().top }, 500); - } else { - var nextRadio = thisItem.closest(".pat-q").next(".pat-q"); - var nextItem = nextRadio.length > 0 ? nextRadio : thisItem.parents(".pat-q").next(); - if (nextItem.length > 0) { - $('html, body').animate({ - scrollTop: nextItem.offset().top - }, 500); - }; }; } else { if (toCall == "biopsy") { - ["pca_diag", "pca_localized", "tx"].forEach(function(fieldName) { + ["pca_diag", "pca_localized"].forEach(function(fieldName) { $("input[name='" + fieldName + "']").each(function() { $(this).prop("checked", false); }); }); tnthAjax.putClinical({{user.id}},"pca_diag","false"); tnthAjax.putClinical({{user.id}},"pca_localized","false"); - tnthAjax.putClinical({{user.id}},"tx","false"); } else if (toCall == "pca_diag") { - ["pca_localized", "tx"].forEach(function(fieldName) { + ["pca_localized"].forEach(function(fieldName) { $("input[name='" + fieldName + "']").each(function() { $(this).prop("checked", false); }); }); tnthAjax.putClinical({{user.id}},"pca_localized","false"); - tnthAjax.putClinical({{user.id}},"tx","false"); } thisItem.parents(".pat-q").nextAll().fadeOut(); $("#clinics").fadeIn(); diff --git a/portal/templates/initial_queries_macros.html b/portal/templates/initial_queries_macros.html index ed25df73ae..acf0f705d5 100644 --- a/portal/templates/initial_queries_macros.html +++ b/portal/templates/initial_queries_macros.html @@ -164,24 +164,6 @@

{{ _("Terms of Use") }}

- -
-
-

{{ _("Have you begun prostate cancer treatment?") }}

-
-
- -
-
- -
-
-
-
{%- endmacro %} diff --git a/portal/templates/profile.html b/portal/templates/profile.html index 609ac0d73d..01d0255906 100644 --- a/portal/templates/profile.html +++ b/portal/templates/profile.html @@ -228,7 +228,7 @@

{{ _("New User") }}

}); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/portal/templates/profile_macros.html b/portal/templates/profile_macros.html index 450215bc2f..bf8635142a 100644 --- a/portal/templates/profile_macros.html +++ b/portal/templates/profile_macros.html @@ -811,22 +811,20 @@

{{ _("Questions inquired of the patient at registration") } else { if (toCall == "biopsy") { - ["pca_diag", "pca_localized", "tx"].forEach(function(fieldName) { + ["pca_diag", "pca_localized"].forEach(function(fieldName) { $("input[name='" + fieldName + "']").each(function() { $(this).prop("checked", false); }); }); tnthAjax.putClinical({{person.id}},"pca_diag","false"); tnthAjax.putClinical({{person.id}},"pca_localized","false"); - tnthAjax.putClinical({{person.id}},"tx","false"); } else if (toCall == "pca_diag") { - ["pca_localized", "tx"].forEach(function(fieldName) { + ["pca_localized"].forEach(function(fieldName) { $("input[name='" + fieldName + "']").each(function() { $(this).prop("checked", false); }); }); tnthAjax.putClinical({{person.id}},"pca_localized","false"); - tnthAjax.putClinical({{person.id}},"tx","false"); } thisItem.parents(".pat-q").nextAll().fadeOut(); }; @@ -1147,4 +1145,4 @@

{{ _("Audit Log") }}

  {{ _("Profile changes have been saved") }} -{%- endmacro %} \ No newline at end of file +{%- endmacro %} diff --git a/portal/templates/questions.html b/portal/templates/questions.html index dded1a451b..51d9dbc66b 100644 --- a/portal/templates/questions.html +++ b/portal/templates/questions.html @@ -52,19 +52,6 @@

{{ _("Explore TrueNTH without registering") }}

-
-
-

{{ _("Have you begun prostate cancer treatment?") }}

-
- - -
-
-
@@ -122,7 +109,7 @@

{{ _("Explore TrueNTH without registering") }}

if (theVal == "true") { $("div[data-topic='"+toCall+"']").next().fadeIn("slow"); // If last question, then always display continue - if (toCall == "tx") { + if (toCall == "pca_diag") { $("#continueBtn").fadeIn(); } } else { @@ -137,7 +124,7 @@

{{ _("Explore TrueNTH without registering") }}

/** - var getQs = [ "biopsy", "pca_diag", "tx"]; + var getQs = [ "biopsy", "pca_diag"]; $.each(getQs, function(i,val){ $.ajax ({ type: "GET", @@ -171,7 +158,7 @@

{{ _("Explore TrueNTH without registering") }}

if (theVal == "true") { $("div[data-topic='"+toCall+"']").next().fadeIn("slow"); // If last question, then always display continue - if (toCall == "tx") { + if (toCall == "pca_diag") { $("#continueBtn").fadeIn(); } } else { diff --git a/portal/views/clinical.py b/portal/views/clinical.py index 5c4c1cb9a5..768829476a 100644 --- a/portal/views/clinical.py +++ b/portal/views/clinical.py @@ -116,41 +116,6 @@ def pca_localized(patient_id): codeable_concept=CC.PCaLocalized) -@clinical_api.route('/patient//clinical/tx') -@oauth.require_oauth() -def treatment(patient_id): - """Simplified API for getting clinical treatment begun status w/o FHIR - - Returns 'true', 'false' or 'unknown' for the patient's clinical treatment - begun value in JSON, i.e. '{"value": true}' - --- - tags: - - Clinical - operationId: getTx - produces: - - application/json - parameters: - - name: patient_id - in: path - description: TrueNTH patient ID - required: true - type: integer - format: int64 - responses: - 200: - description: - Returns 'true', 'false' or 'unknown' for the patient's clinical - treatment begun status in JSON - 401: - description: - if missing valid OAuth token or logged-in user lacks permission - to view requested patient - - """ - return clinical_api_shortcut_get(patient_id=patient_id, - codeable_concept=CC.TX) - - @clinical_api.route('/patient//clinical/biopsy', methods=('POST', 'PUT')) @oauth.require_oauth() @@ -318,61 +283,6 @@ def pca_localized_set(patient_id): codeable_concept=CC.PCaLocalized) -@clinical_api.route('/patient//clinical/tx', - methods=('POST', 'PUT')) -@oauth.require_oauth() -def tx_set(patient_id): - """Simplified API for setting clinical treatment status w/o FHIR - - Requires a simple JSON doc to set treatment status: '{"value": true}' - - Raises 401 if logged-in user lacks permission to edit requested - patient. - - --- - operationId: setTx - tags: - - Clinical - produces: - - application/json - parameters: - - name: patient_id - in: path - description: TrueNTH patient ID - required: true - type: integer - format: int64 - - in: body - name: body - schema: - id: Tx - required: - - value - properties: - value: - type: boolean - description: the patient's treatment status - responses: - 200: - description: successful operation - schema: - id: response - required: - - message - properties: - message: - type: string - description: Result, typically "ok" - 401: - description: - if missing valid OAuth token or logged-in user lacks permission - to view requested patient - - """ - return clinical_api_shortcut_set(patient_id=patient_id, - codeable_concept=CC.TX) - - @clinical_api.route('/patient//clinical') @oauth.require_oauth() def clinical(patient_id): diff --git a/tests/__init__.py b/tests/__init__.py index 0c3eb39700..1f1b5af71a 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -128,7 +128,7 @@ def add_service_user(self, sponsor=None): def add_required_clinical_data(self): " Add clinical data to get beyond the landing page " - for cc in CC.BIOPSY, CC.PCaDIAG, CC.TX, CC.PCaLocalized: + for cc in CC.BIOPSY, CC.PCaDIAG, CC.PCaLocalized: self.test_user.save_constrained_observation( codeable_concept=cc, value_quantity=CC.TRUE_VALUE, audit=Audit(user_id=TEST_USER_ID)) diff --git a/tests/test_clinical.py b/tests/test_clinical.py index f82fc173be..77767f578b 100644 --- a/tests/test_clinical.py +++ b/tests/test_clinical.py @@ -204,33 +204,6 @@ def test_clinical_pca_localized(self): data = json.loads(rv.data) self.assertEquals(data['value'], 'true') - def test_clinical_tx(self): - """Shortcut API - just treatment w/o FHIR overhead""" - self.login() - rv = self.app.post('/api/patient/%s/clinical/tx' % TEST_USER_ID, - content_type='application/json', - data=json.dumps({'value': True})) - self.assert200(rv) - result = json.loads(rv.data) - self.assertEquals(result['message'], 'ok') - - # Can we get it back in FHIR? - rv = self.app.get('/api/patient/%s/clinical' % TEST_USER_ID) - data = json.loads(rv.data) - coding = data['entry'][0]['content']['code']['coding'][0] - vq = data['entry'][0]['content']['valueQuantity'] - - self.assertEquals(coding['code'], '131') - self.assertEquals(coding['display'], 'treatment begun') - self.assertEquals(coding['system'], - 'http://us.truenth.org/clinical-codes') - self.assertEquals(vq['value'], 'true') - - # Access the direct tx api - rv = self.app.get('/api/patient/%s/clinical/tx' % TEST_USER_ID) - data = json.loads(rv.data) - self.assertEquals(data['value'], 'true') - def test_weight(self): with open(os.path.join(os.path.dirname(__file__), 'weight_example.json'), 'r') as fhir_data: From 329bf8d981f6ec419db5fb65535ed5ab7093fcc3 Mon Sep 17 00:00:00 2001 From: Amy Chen Date: Tue, 20 Dec 2016 14:30:03 -0800 Subject: [PATCH 059/708] fix JS error for clinical Items - quote around attribute reference and check the existence of the element --- portal/static/js/main.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/portal/static/js/main.js b/portal/static/js/main.js index 92f8f623bd..b105628dc8 100644 --- a/portal/static/js/main.js +++ b/portal/static/js/main.js @@ -113,12 +113,15 @@ var fillContent = { } else if (clinicalItem == "PCa localized diagnosis") { clinicalItem = "pca_localized"; } - $('div[data-topic='+clinicalItem+']').fadeIn().next().fadeIn(); + var ci = $('div[data-topic="'+clinicalItem+'"]'); + if (ci.length > 0) ci.fadeIn().next().fadeIn(); var clinicalValue = val.content.valueQuantity.value; - var $radios = $('input:radio[name='+clinicalItem+']'); - if($radios.is(':checked') === false) { - $radios.filter('[value='+clinicalValue+']').prop('checked', true); - } + var $radios = $('input:radio[name="'+clinicalItem+'"]'); + if ($radios.length > 0) { + if($radios.is(':checked') === false) { + $radios.filter('[value='+clinicalValue+']').prop('checked', true); + } + }; // Display clinics if any value is false (except localized) or if all are answered if ((clinicalValue == "false" && clinicalItem != "pca_localized") || i == 3) { $("#clinics").fadeIn(); From 3c796bcd34db5d5b79a8b5e7da828f8fbf55248c Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Tue, 20 Dec 2016 15:44:44 -0800 Subject: [PATCH 060/708] Move add_procedure test helper to top scope for use in other tests --- tests/__init__.py | 19 ++++++++++++++++++- tests/test_procedure.py | 26 +++++--------------------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 1f1b5af71a..8f00451a1c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -17,11 +17,12 @@ from portal.extensions import db from portal.models.audit import Audit from portal.models.auth import Client -from portal.models.fhir import CC +from portal.models.fhir import CC, Coding, CodeableConcept from portal.models.fhir import add_static_concepts from portal.models.intervention import add_static_interventions, INTERVENTION from portal.models.organization import Organization, add_static_organization from portal.models.organization import OrgTree +from portal.models.procedure import Procedure from portal.models.relationship import add_static_relationships from portal.models.role import Role, add_static_roles, ROLE from portal.models.tou import ToU @@ -133,6 +134,22 @@ def add_required_clinical_data(self): codeable_concept=cc, value_quantity=CC.TRUE_VALUE, audit=Audit(user_id=TEST_USER_ID)) + def add_procedure(self, code='367336001', display='Chemotherapy'): + "Add procedure data into the db for the test user" + with SessionScope(db): + audit = Audit(user_id=TEST_USER_ID) + procedure = Procedure(audit=audit) + coding = Coding(system='http://snomed.info/sct', + code=code, + display=display).add_if_not_found() + code = CodeableConcept(codings=[coding,]).add_if_not_found() + procedure.code = code + procedure.user = self.test_user + procedure.start_time = datetime.utcnow() + procedure.end_time = datetime.utcnow() + db.session.add(procedure) + db.session.commit() + def bless_with_basics(self): """Bless test user with basic requirements for coredata""" self.test_user = db.session.merge(self.test_user) diff --git a/tests/test_procedure.py b/tests/test_procedure.py index 8f9d7befe5..e93ced8e90 100644 --- a/tests/test_procedure.py +++ b/tests/test_procedure.py @@ -20,30 +20,14 @@ class TestProcedure(TestCase): - def prep_db_for_procedure(self, code='367336001', display='Chemotherapy'): - # First push some procedure data into the db for the test user - with SessionScope(db): - audit = Audit(user_id=TEST_USER_ID) - procedure = Procedure(audit=audit) - coding = Coding(system='http://snomed.info/sct', - code=code, - display=display).add_if_not_found() - code = CodeableConcept(codings=[coding,]).add_if_not_found() - procedure.code = code - procedure.user = self.test_user - procedure.start_time = datetime.utcnow() - procedure.end_time = datetime.utcnow() - db.session.add(procedure) - db.session.commit() - def test_procedureGET_404(self): - self.prep_db_for_procedure() + self.add_procedure() self.login() rv = self.app.get('/api/patient/666/procedure') self.assert404(rv) def test_procedureGET(self): - self.prep_db_for_procedure() + self.add_procedure() self.login() rv = self.app.get('/api/patient/%s/procedure' % TEST_USER_ID) @@ -140,7 +124,7 @@ def test_timezone_procedure_POST(self): self.assertEquals(proc.start_time, st) def test_procedureDELETE(self): - self.prep_db_for_procedure() + self.add_procedure() proc_id = Procedure.query.one().id self.login() rv = self.app.delete('/api/procedure/{}'.format(proc_id)) @@ -162,7 +146,7 @@ def test_treatment_started(self): self.assertFalse(known_treatment_started(self.test_user)) for code, display in started_codes: - self.prep_db_for_procedure(code, display) + self.add_procedure(code, display) self.test_user = db.session.merge(self.test_user) self.assertTrue(known_treatment_started(self.test_user)) self.test_user.procedures.delete() # reset for next iteration @@ -179,7 +163,7 @@ def test_treatment_not_started(self): self.assertFalse(known_treatment_not_started(self.test_user)) for code, display in not_started_codes: - self.prep_db_for_procedure(code, display) + self.add_procedure(code, display) self.test_user = db.session.merge(self.test_user) self.assertTrue(known_treatment_not_started(self.test_user)) self.test_user.procedures.delete() # reset for next iteration From 4878710ba1d0d47031dc794e8e5706e4e60f6b41 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Tue, 20 Dec 2016 16:17:14 -0800 Subject: [PATCH 061/708] Adjust access stratgies and tests to use procedures for tx status. --- portal/models/intervention_strategies.py | 22 ++++++++++++ tests/__init__.py | 2 +- tests/test_intervention.py | 43 +++++++----------------- tests/test_site_persistence.py | 5 ++- 4 files changed, 37 insertions(+), 35 deletions(-) diff --git a/portal/models/intervention_strategies.py b/portal/models/intervention_strategies.py index 9887939949..f048a6af17 100644 --- a/portal/models/intervention_strategies.py +++ b/portal/models/intervention_strategies.py @@ -25,6 +25,8 @@ from .fhir import CC, Coding, CodeableConcept, QuestionnaireResponse from .organization import Organization from .intervention import Intervention, INTERVENTION, UserIntervention +from .procedure_codes import known_treatment_started +from .procedure_codes import known_treatment_not_started from .role import Role from ..system_uri import TRUENTH_CLINICAL_CODE_SYSTEM @@ -275,6 +277,26 @@ def update_user_card_html(intervention, user): return update_user_card_html +def tx_begun(boolean_value): + """Returns strategy function testing if user is known to have started Tx + + :param boolean_value: true for known treatment started (i.e. procedure + indicating tx has begun), false for known treatment not started, + (i.e. watchful waiting, etc.) + + """ + if boolean_value == 'true': + check_func = known_treatment_started + elif boolean_value == 'false': + check_func = known_treatment_not_started + else: + raise ValueError("expected 'true' or 'false' for boolean_value") + + def user_has_desired_tx(intervention, user): + return check_func(user) + return user_has_desired_tx + + def observation_check(display, boolean_value): """Returns strategy function for a particular observation and logic value diff --git a/tests/__init__.py b/tests/__init__.py index 8f00451a1c..58927f3e14 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -144,7 +144,7 @@ def add_procedure(self, code='367336001', display='Chemotherapy'): display=display).add_if_not_found() code = CodeableConcept(codings=[coding,]).add_if_not_found() procedure.code = code - procedure.user = self.test_user + procedure.user = db.session.merge(self.test_user) procedure.start_time = datetime.utcnow() procedure.end_time = datetime.utcnow() db.session.add(procedure) diff --git a/tests/test_intervention.py b/tests/test_intervention.py index 1956b41831..cd0c13d388 100644 --- a/tests/test_intervention.py +++ b/tests/test_intervention.py @@ -183,10 +183,8 @@ def test_no_tx(self): cp_id = cp.id with SessionScope(db): - d = {'function': 'observation_check', - 'kwargs': [{'name': 'display', 'value': - CC.TX.codings[0].display}, - {'name': 'boolean_value', 'value': 'false'}]} + d = {'function': 'tx_begun', + 'kwargs': [{'name': 'boolean_value', 'value': 'false'}]} strat = AccessStrategy( name="has not stared treatment", intervention_id = cp_id, @@ -199,9 +197,8 @@ def test_no_tx(self): # Prior to declaring TX, user shouldn't have access self.assertFalse(cp.display_for_user(user).access) - user.save_constrained_observation( - codeable_concept=CC.TX, value_quantity=CC.FALSE_VALUE, - audit=Audit(user_id=TEST_USER_ID)) + self.add_procedure( + code='424313000', display='Started active surveillance') with SessionScope(db): db.session.commit() user, cp = map(db.session.merge, (user, cp)) @@ -209,16 +206,6 @@ def test_no_tx(self): # Declaring they started TX, should grant access self.assertTrue(cp.display_for_user(user).access) - # Say user starts treatment, should lose access - user.save_constrained_observation( - codeable_concept=CC.TX, value_quantity=CC.TRUE_VALUE, - audit=Audit(user_id=TEST_USER_ID)) - with SessionScope(db): - db.session.commit() - user, cp = map(db.session.merge, (user, cp)) - - self.assertFalse(cp.display_for_user(user).access) - def test_exclusive_stategy(self): """Test exclusive intervention strategy""" user = self.test_user @@ -531,11 +518,9 @@ def test_p3p_conditions(self): ]}, # Not Started TX (strat 3) {'name': 'strategy_3', - 'value': 'observation_check'}, + 'value': 'tx_begun'}, {'name': 'strategy_3_kwargs', - 'value': [{'name': 'display', - 'value': CC.TX.codings[0].display}, - {'name': 'boolean_value', 'value': 'false'}]}, + 'value': [{'name': 'boolean_value', 'value': 'false'}]}, # Has Localized PCa (strat 4) {'name': 'strategy_4', 'value': 'observation_check'}, @@ -559,9 +544,8 @@ def test_p3p_conditions(self): # only first two strats true so far, therfore, should be False self.assertFalse(ds_p3p.display_for_user(user).access) - user.save_constrained_observation( - codeable_concept=CC.TX, value_quantity=CC.FALSE_VALUE, - audit=Audit(user_id=TEST_USER_ID)) + self.add_procedure( + code='424313000', display='Started active surveillance') user.save_constrained_observation( codeable_concept=CC.PCaLocalized, value_quantity=CC.TRUE_VALUE, audit=Audit(user_id=TEST_USER_ID)) @@ -633,11 +617,9 @@ def test_eproms_p3p_conditions(self): ]}, # Not Started TX (strat 3) {'name': 'strategy_3', - 'value': 'observation_check'}, + 'value': 'tx_begun'}, {'name': 'strategy_3_kwargs', - 'value': [{'name': 'display', - 'value': CC.TX.codings[0].display}, - {'name': 'boolean_value', 'value': 'false'}]}, + 'value': [{'name': 'boolean_value', 'value': 'false'}]}, # Has Localized PCa (strat 4) {'name': 'strategy_4', 'value': 'observation_check'}, @@ -667,9 +649,8 @@ def test_eproms_p3p_conditions(self): # only first two strats true so far, therfore, should be False self.assertFalse(ds_p3p.display_for_user(user).access) - user.save_constrained_observation( - codeable_concept=CC.TX, value_quantity=CC.FALSE_VALUE, - audit=Audit(user_id=TEST_USER_ID)) + self.add_procedure( + code='424313000', display='Started active surveillance') user.save_constrained_observation( codeable_concept=CC.PCaLocalized, value_quantity=CC.TRUE_VALUE, audit=Audit(user_id=TEST_USER_ID)) diff --git a/tests/test_site_persistence.py b/tests/test_site_persistence.py index 7d7b47a6d3..acc13f9cf3 100644 --- a/tests/test_site_persistence.py +++ b/tests/test_site_persistence.py @@ -39,9 +39,8 @@ def testP3Pstrategy(self): INTERVENTION.DECISION_SUPPORT_P3P.display_for_user(user).access) # Fulfill conditions - user.save_constrained_observation( - codeable_concept=CC.TX, value_quantity=CC.FALSE_VALUE, - audit=Audit(user_id=TEST_USER_ID)) + self.add_procedure( + code='424313000', display='Started active surveillance') user.save_constrained_observation( codeable_concept=CC.PCaLocalized, value_quantity=CC.TRUE_VALUE, audit=Audit(user_id=TEST_USER_ID)) From 9545ba127741b6b14d6abf471ed6dc5156e220e7 Mon Sep 17 00:00:00 2001 From: Ivan Cvitkovic Date: Wed, 21 Dec 2016 10:52:33 -0800 Subject: [PATCH 062/708] Change code style --- portal/models/auth.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/portal/models/auth.py b/portal/models/auth.py index b6a7104bef..7640bd8660 100644 --- a/portal/models/auth.py +++ b/portal/models/auth.py @@ -148,8 +148,12 @@ def notify(self, data): # Use celery asynchronous task 'post_request' kwargs = {'url': self.callback_url, 'data': formdata} res = post_request.apply_async(kwargs=kwargs) - context = {"id": res.task_id, "url": kwargs['url'], - "data": kwargs['data']} + + context = { + "id": res.task_id, + "data": kwargs['data'], + "url": self.callback_url, + } current_app.logger.debug(str(context)) def lookup_service_token(self): From 352a3357f497b07f8fd195e5af6196afab81080f Mon Sep 17 00:00:00 2001 From: Ivan Cvitkovic Date: Wed, 21 Dec 2016 10:56:49 -0800 Subject: [PATCH 063/708] Log all data sent to notify() callback --- portal/models/auth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/portal/models/auth.py b/portal/models/auth.py index 7640bd8660..28008fab43 100644 --- a/portal/models/auth.py +++ b/portal/models/auth.py @@ -151,8 +151,9 @@ def notify(self, data): context = { "id": res.task_id, - "data": kwargs['data'], "url": self.callback_url, + "formdata": formdata, + "data": data, } current_app.logger.debug(str(context)) From 4de455bafadda8a7b857fb9e70e52c7a00cc3480 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 21 Dec 2016 13:11:04 -0800 Subject: [PATCH 064/708] Update flask from 0.11.1 to 0.12 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c7c6b39c29..b4342d8254 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ coverage==4.2 decorator==4.0.10 docopt==0.6.2 docutils==0.13.1 -Flask==0.11.1 +Flask==0.12 Flask-Babel==0.11.1 Flask-Celery-Helper==1.1.0 Flask-Login==0.4.0 From f0d59d23f4323ebad5eed85072c2856a74e88d5c Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Wed, 21 Dec 2016 15:32:41 -0800 Subject: [PATCH 065/708] Include the list of Tx started and not started procedure codes in seed() --- portal/models/fhir.py | 49 ++++++++++++++++++------------------------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/portal/models/fhir.py b/portal/models/fhir.py index 4e761327ef..15f91a5a0f 100644 --- a/portal/models/fhir.py +++ b/portal/models/fhir.py @@ -184,29 +184,6 @@ def add_if_not_found(self): return self -def codeable_concept_with_coding(system, code, display=None): - """Lookup codeable_concept which includes the given coding(system, code) - - If no such coding is found, a new one will be generated IFF display - is provided. - - If no codeable_concept is found for the given coding, one will be - generated, stored in the db, and returned - - NB - a CodeableConcept may have any number of codings, but for any - given coding, exactly one CodeableConcept should contain it. - - """ - if display: - coding = Coding( - system=system, code=code, display=display).add_if_not_found() - else: - coding = Coding.query.filter_by(system=system, code=code).one() - cc = CodeableConcept(codings=[coding,]).add_if_not_found(True) - assert coding in cc.codings - return cc - - """ TrueNTH Clinical Codes """ class ClinicalConstants(object): @@ -218,18 +195,27 @@ def __iter__(self): @lazyprop def BIOPSY(self): - return codeable_concept_with_coding( - system=TRUENTH_CLINICAL_CODE_SYSTEM, code='111') + coding = Coding.query.filter_by( + system=TRUENTH_CLINICAL_CODE_SYSTEM, code='111').one() + cc = CodeableConcept(codings=[coding,]).add_if_not_found(True) + assert coding in cc.codings + return cc @lazyprop def PCaDIAG(self): - return codeable_concept_with_coding( - system=TRUENTH_CLINICAL_CODE_SYSTEM, code='121') + coding = Coding.query.filter_by( + system=TRUENTH_CLINICAL_CODE_SYSTEM, code='121').one() + cc = CodeableConcept(codings=[coding,]).add_if_not_found(True) + assert coding in cc.codings + return cc @lazyprop def PCaLocalized(self): - return codeable_concept_with_coding( - system=TRUENTH_CLINICAL_CODE_SYSTEM, code='141') + coding = Coding.query.filter_by( + system=TRUENTH_CLINICAL_CODE_SYSTEM, code='141').one() + cc = CodeableConcept(codings=[coding,]).add_if_not_found(True) + assert coding in cc.codings + return cc @lazyprop def TRUE_VALUE(self): @@ -493,6 +479,8 @@ def add_static_concepts(only_quick=False): unless the test needs the slow to load race and ethnicity data. """ + from .procedure_codes import TxStartedConstants, TxNotStartedConstants + BIOPSY = Coding(system=TRUENTH_CLINICAL_CODE_SYSTEM, code='111', display='biopsy') PCaDIAG = Coding(system=TRUENTH_CLINICAL_CODE_SYSTEM, code='121', @@ -513,3 +501,6 @@ def add_static_concepts(only_quick=False): for clinical_concepts in CC: if not clinical_concepts in db.session(): db.session.add(clinical_concepts) + + for concept in TxStartedConstants(): pass # looping is adequate + for concept in TxNotStartedConstants(): pass # looping is adequate From 33630b276ef76b9c85c67011d3754da78c9461b6 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Wed, 21 Dec 2016 15:46:40 -0800 Subject: [PATCH 066/708] Expand test to cover ICHOM procedure codes. --- tests/__init__.py | 6 ++++-- tests/test_procedure.py | 45 ++++++++++++++++++++++++++--------------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 58927f3e14..4ef60a3876 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -29,6 +29,7 @@ from portal.models.user import User, UserRoles from portal.models.user_consent import UserConsent from portal.site_persistence import SitePersistence +from portal.system_uri import SNOMED TEST_USER_ID = 1 TEST_USERNAME = 'testy@example.com' @@ -134,12 +135,13 @@ def add_required_clinical_data(self): codeable_concept=cc, value_quantity=CC.TRUE_VALUE, audit=Audit(user_id=TEST_USER_ID)) - def add_procedure(self, code='367336001', display='Chemotherapy'): + def add_procedure(self, code='367336001', display='Chemotherapy', + system=SNOMED): "Add procedure data into the db for the test user" with SessionScope(db): audit = Audit(user_id=TEST_USER_ID) procedure = Procedure(audit=audit) - coding = Coding(system='http://snomed.info/sct', + coding = Coding(system=system, code=code, display=display).add_if_not_found() code = CodeableConcept(codings=[coding,]).add_if_not_found() diff --git a/tests/test_procedure.py b/tests/test_procedure.py index e93ced8e90..99ea0c1dc4 100644 --- a/tests/test_procedure.py +++ b/tests/test_procedure.py @@ -2,7 +2,6 @@ from datetime import datetime, timedelta import dateutil from flask import current_app -from flask_webtest import SessionScope import json import os import pytz @@ -11,11 +10,12 @@ from portal.extensions import db from portal.models.audit import Audit -from portal.models.fhir import Coding, CodeableConcept, FHIR_datetime +from portal.models.fhir import FHIR_datetime from portal.models.procedure import Procedure from portal.models.procedure_codes import known_treatment_started from portal.models.procedure_codes import known_treatment_not_started from portal.models.reference import Reference +from portal.system_uri import ICHOM, SNOMED, TRUENTH_CLINICAL_CODE_SYSTEM class TestProcedure(TestCase): @@ -135,35 +135,48 @@ def test_procedureDELETE(self): def test_treatment_started(self): # list of codes indicating 'treatment started' - handle accordingly started_codes = ( - ('26294005', 'Radical prostatectomy (nerve-sparing)'), - ('26294005-nns', 'Radical prostatectomy (non-nerve-sparing)'), - ('33195004', 'External beam radiation therapy'), - ('228748004', 'Brachytherapy'), - ('707266006', 'Androgen deprivation therapy') + ('3', 'Radical prostatectomy (nerve-sparing)', ICHOM), + ('3-nns', 'Radical prostatectomy (non-nerve-sparing)', ICHOM), + ('4', 'External beam radiation therapy', ICHOM), + ('5', 'Brachytherapy', ICHOM), + ('6', 'ADT', ICHOM), + ('7', 'Focal therapy', ICHOM), + ('26294005', 'Radical prostatectomy (nerve-sparing)', SNOMED), + ('26294005-nns', 'Radical prostatectomy (non-nerve-sparing)', + SNOMED), + ('33195004', 'External beam radiation therapy', SNOMED), + ('228748004', 'Brachytherapy', SNOMED), + ('707266006', 'Androgen deprivation therapy', SNOMED) ) # prior to setting any procedures, should return false self.assertFalse(known_treatment_started(self.test_user)) - for code, display in started_codes: - self.add_procedure(code, display) + for code, display, system in started_codes: + self.add_procedure(code, display, system) self.test_user = db.session.merge(self.test_user) - self.assertTrue(known_treatment_started(self.test_user)) + self.assertTrue(known_treatment_started(self.test_user), + "treatment {} didn't show as started".format( + (system, code))) self.test_user.procedures.delete() # reset for next iteration def test_treatment_not_started(self): # list of codes indicating 'treatment not started' - handle accordingly not_started_codes = ( - ('373818007', 'Started watchful waiting'), - ('424313000', 'Started active surveillance'), - ('999999999', 'None of the above') + ('1', 'Watchful waiting', ICHOM), + ('2', 'Active surveillance', ICHOM), + ('373818007', 'Started watchful waiting', SNOMED), + ('424313000', 'Started active surveillance', SNOMED), + ('999', 'None', TRUENTH_CLINICAL_CODE_SYSTEM) ) # prior to setting any procedures, should return false self.assertFalse(known_treatment_not_started(self.test_user)) - for code, display in not_started_codes: - self.add_procedure(code, display) + for code, display, system in not_started_codes: + self.add_procedure(code, display, system) self.test_user = db.session.merge(self.test_user) - self.assertTrue(known_treatment_not_started(self.test_user)) + self.assertTrue(known_treatment_not_started(self.test_user), + "treatment '{}' didn't show as not started".format( + (system, code))) self.test_user.procedures.delete() # reset for next iteration From 37d3be05c6fc8402a38505bf6bc88a71cc569cae Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Wed, 21 Dec 2016 15:47:07 -0800 Subject: [PATCH 067/708] Adding ICHOM procedure_codes for Tx started and not started. --- portal/models/procedure_codes.py | 110 ++++++++++++++++++++++++------- portal/system_uri.py | 3 +- 2 files changed, 89 insertions(+), 24 deletions(-) diff --git a/portal/models/procedure_codes.py b/portal/models/procedure_codes.py index 96f3f6390d..4e0c974540 100644 --- a/portal/models/procedure_codes.py +++ b/portal/models/procedure_codes.py @@ -1,8 +1,7 @@ """Module for pre defined procedure codes and shortcuts""" - -from .fhir import codeable_concept_with_coding +from .fhir import Coding, CodeableConcept from .lazy import lazyprop -from ..system_uri import SNOMED +from ..system_uri import ICHOM, SNOMED, TRUENTH_CLINICAL_CODE_SYSTEM def known_treatment_started(user): @@ -43,6 +42,9 @@ class TxStartedConstants(object): Simple containment class with lazy loaded attributes for each codeable concept containing a known treatment started coding. + NB - this also handles the bootstraping necessary to combine concepts + from different coding systems under the same codeable_concept + """ def __iter__(self): @@ -53,33 +55,87 @@ def __iter__(self): @lazyprop def RadicalProstatectomy(self): - return codeable_concept_with_coding( + sno = Coding( system=SNOMED, code='26294005', - display='Radical prostatectomy (nerve-sparing)') + display='Radical prostatectomy (nerve-sparing)').add_if_not_found() + ichom = Coding( + system=ICHOM, code='3', + display='Radical prostatectomy (nerve-sparing)').add_if_not_found() + return CodeableConcept( + codings=[sno, ichom], text='Radical prostatectomy (nerve-sparing' + ).add_if_not_found() @lazyprop def RadicalProstatectomyNNS(self): - return codeable_concept_with_coding( + sno = Coding( system=SNOMED, code='26294005-nns', - display='Radical prostatectomy (non-nerve-sparing)') + display='Radical prostatectomy (non-nerve-sparing)' + ).add_if_not_found() + ichom = Coding( + system=ICHOM, code='3-nns', + display='Radical prostatectomy (non-nerve-sparing)' + ).add_if_not_found() + return CodeableConcept( + codings=[sno, ichom], + text='Radical prostatectomy (non-nerve-sparing').add_if_not_found() @lazyprop def ExternalBeamRadiationTherapy(self): - return codeable_concept_with_coding( + sno = Coding( system=SNOMED, code='33195004', - display='External beam radiation therapy') + display='External beam radiation therapy' + ).add_if_not_found() + ichom = Coding( + system=ICHOM, code='4', + display='External beam radiation therapy' + ).add_if_not_found() + return CodeableConcept( + codings=[sno, ichom], + text='External beam radiation therapy').add_if_not_found() @lazyprop def Brachytherapy(self): - return codeable_concept_with_coding( - system=SNOMED, code='228748004', - display='Brachytherapy') + sno = Coding( + system=SNOMED, code='228748004', display='Brachytherapy' + ).add_if_not_found() + ichom = Coding( + system=ICHOM, code='5', display='Brachytherapy').add_if_not_found() + return CodeableConcept( + codings=[sno, ichom], + text='Brachytherapy').add_if_not_found() @lazyprop def AndrogenDeprivationTherapy(self): - return codeable_concept_with_coding( + sno = Coding( system=SNOMED, code='707266006', - display='Androgen deprivation therapy') + display='Androgen deprivation therapy').add_if_not_found() + ichom = Coding( + system=ICHOM, code='6', display='Androgen deprivation therapy' + ).add_if_not_found() + return CodeableConcept( + codings=[sno, ichom], + text='Androgen deprivation therapy').add_if_not_found() + + @lazyprop + def FocalTherapy(self): + ichom = Coding( + system=ICHOM, code='7', display='Focal therapy' + ).add_if_not_found() + return CodeableConcept( + codings=[ichom,], text='Focal therapy' + ).add_if_not_found() + + @lazyprop + def OtherProcedure(self): + sno = Coding( + system=SNOMED, code='118877007', + display='Procedure on prostate').add_if_not_found() + ichom = Coding( + system=ICHOM, code='888', display='Other (free text)' + ).add_if_not_found() + return CodeableConcept( + codings=[sno, ichom], text='Other procedure on prostate' + ).add_if_not_found() class TxNotStartedConstants(object): @@ -98,20 +154,28 @@ def __iter__(self): @lazyprop def StartedWatchfulWaiting(self): - return codeable_concept_with_coding( + sno = Coding( system=SNOMED, code='373818007', - display='Started watchful waiting') + display='Started watchful waiting').add_if_not_found() + ichom = Coding( + system=ICHOM, code='1', display='Watchful waiting' + ).add_if_not_found() + return CodeableConcept(codings=[sno, ichom], text='Watchful waiting' + ).add_if_not_found() @lazyprop def StartedActiveSurveillance(self): - return codeable_concept_with_coding( + sno = Coding( system=SNOMED, code='424313000', - display='Started active surveillance') + display='Started active surveillance').add_if_not_found() + ichom = Coding( + system=ICHOM, code='2', display='Active surveillance' + ).add_if_not_found() + return CodeableConcept(codings=[sno, ichom], text='Active surveillance' + ).add_if_not_found() @lazyprop def NoneOfTheAbove(self): - return codeable_concept_with_coding( - system=SNOMED, code='999999999', - display='None of the above') - - + tnth = Coding(system=TRUENTH_CLINICAL_CODE_SYSTEM, code='999', + display='None').add_if_not_found() + return CodeableConcept(codings=[tnth], text='None').add_if_not_found() diff --git a/portal/system_uri.py b/portal/system_uri.py index 9f9fdf9c88..5fb4ac60f5 100644 --- a/portal/system_uri.py +++ b/portal/system_uri.py @@ -1,6 +1,7 @@ """Namespace module to house system URIs for use in FHIR""" -SNOMED='http://snomed.info/sct' +SNOMED = 'http://snomed.info/sct' +ICHOM = 'http://www.ichom.org/medical-conditions/localized-prostate-cancer/' # Our common, unique namespace TRUENTH_NAMESPACE = 'http://us.truenth.org' From 028ad06e1f52eefefc112369dda9db1dca7ff777 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Wed, 21 Dec 2016 15:49:36 -0800 Subject: [PATCH 068/708] Use persistence file in feature branch to test pull request. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 0419bf0a61..8d87516372 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,7 @@ env: global: - SQLALCHEMY_DATABASE_URI='postgresql://postgres:@localhost/portal_unit_tests' - LOG_FOLDER='/tmp/shared_service_log' - - PERSISTENCE_FILE='https://raw.githubusercontent.com/uwcirg/TrueNTH-USA-site-config/develop/site_persistence_file.json' + - PERSISTENCE_FILE='https://raw.githubusercontent.com/uwcirg/TrueNTH-USA-site-config/feature/tx-as-procedure/site_persistence_file.json' matrix: include: From bc18fcfcbdc605144222098e5e0853257751d922 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Wed, 21 Dec 2016 21:42:49 -0800 Subject: [PATCH 069/708] Added valueset endpoints and improved docs for procedures marking treatment status. --- portal/views/procedure.py | 81 ++++++++++++++++++++++++++++++++++++++- tests/test_procedure.py | 6 ++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/portal/views/procedure.py b/portal/views/procedure.py index a428ce556b..e292f8850b 100644 --- a/portal/views/procedure.py +++ b/portal/views/procedure.py @@ -1,10 +1,12 @@ -from flask import abort, jsonify, Blueprint, request +from flask import abort, jsonify, Blueprint, request, url_for +from collections import defaultdict from ..audit import auditable_event from ..extensions import db, oauth from ..models.audit import Audit from ..models.user import current_user, get_user from ..models.procedure import Procedure +from ..models.procedure_codes import TxStartedConstants, TxNotStartedConstants procedure_api = Blueprint('procedure_api', __name__, url_prefix='/api') @@ -17,6 +19,11 @@ def procedure(patient_id): Returns a patient's procedures data as a FHIR bundle of procedures (http://www.hl7.org/fhir/procedure.html) in JSON. + + NB a procedure code may enumerate multiple coding values, from different + coding systems. Clients will typically react on (code, system) values of + interest. + --- tags: - Procedure @@ -63,6 +70,14 @@ def post_procedure(): Performed datetime, either a single moment as **performedDateTime** or a range in **performedPeriod** + NB although the system will maintain CodeableConcepts with codings for + all synonymous codes from different systems, only one code need be defined + in the submission, say just the ICHOM system and code value. + + Valuesets available at {} and {} list respective code values known + to the system for procedures indicating a patient has or has not + begun treatment. + Raises 401 if logged-in user lacks permission to edit referenced patient. @@ -101,7 +116,11 @@ def post_procedure(): if missing valid OAuth token or logged-in user lacks permission to edit referenced patient - """ + """.format(( + url_for(procedure_value_sets, valueset='tx-started', _external=True), + url_for(procedure_value_sets, valueset='tx-not-started', + _external=True))) + # patient_id must first be parsed from the JSON subject field # standard role check is below after parse @@ -174,3 +193,61 @@ def procedure_delete(procedure_id): db.session.commit() auditable_event("deleted {}".format(procedure), user_id=current_user().id) return jsonify(message='deleted procedure') + + +@procedure_api.route('/procedure/valueset/') +def procedure_value_sets(valueset): + """Returns Valueset for treatment started codes + + --- + tags: + - Procedure + - Valueset + operationId: procedure_value_sets + produces: + - application/json + parameters: + - name: valueset + in: path + description: Named valueset (either 'tx-started' or 'tx-not-started') + required: true + type: string + responses: + 200: + description: + Returns FHIR like Valueset (https://www.hl7.org/FHIR/valueset.html) + for requested coding type. + + """ + options = ('tx-started', 'tx-not-started') + if valueset not in options: + abort(400, 'unknown valueset, supported options: {}'.format(options)) + + if valueset == 'tx-started': + condition = 'has' + constants_class = TxStartedConstants + else: + condition = 'has not' + constants_class = TxNotStartedConstants + + valueset = { + "resourceType": "ValueSet", + "title": valueset, + "description": ( + "List of procedure codes known to indicate treatment {} " + "stared.".format(condition)), + "url": request.url, + "compose": {"include": []} + } + + code_by_system = defaultdict(list) + for concept in constants_class(): + for code in concept.codings: + code_by_system[code.system].append(code) + + for system in code_by_system.keys(): + item = {"system": system, + "concept": [c.as_fhir() for c in code_by_system[system]]} + valueset["compose"]["include"].append(item) + + return jsonify(**valueset) diff --git a/tests/test_procedure.py b/tests/test_procedure.py index 99ea0c1dc4..2511a267b7 100644 --- a/tests/test_procedure.py +++ b/tests/test_procedure.py @@ -179,4 +179,8 @@ def test_treatment_not_started(self): self.assertTrue(known_treatment_not_started(self.test_user), "treatment '{}' didn't show as not started".format( (system, code))) - self.test_user.procedures.delete() # reset for next iteration + #self.test_user.procedures.delete() # reset for next iteration + + self.login() + rv = self.app.get('api/patient/1/procedure') + print json.dumps(rv.json, indent=2) From 04ae9d7c4aaafabfa7479972ac598f1281a47d30 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Wed, 21 Dec 2016 22:12:07 -0800 Subject: [PATCH 070/708] Removed debugging output --- tests/test_procedure.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/test_procedure.py b/tests/test_procedure.py index 2511a267b7..99ea0c1dc4 100644 --- a/tests/test_procedure.py +++ b/tests/test_procedure.py @@ -179,8 +179,4 @@ def test_treatment_not_started(self): self.assertTrue(known_treatment_not_started(self.test_user), "treatment '{}' didn't show as not started".format( (system, code))) - #self.test_user.procedures.delete() # reset for next iteration - - self.login() - rv = self.app.get('api/patient/1/procedure') - print json.dumps(rv.json, indent=2) + self.test_user.procedures.delete() # reset for next iteration From 4f521588c9ef119c476f7eb231f7e7fef39581f9 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Wed, 21 Dec 2016 22:12:30 -0800 Subject: [PATCH 071/708] Fixed formating error in swagger docs --- portal/views/procedure.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/portal/views/procedure.py b/portal/views/procedure.py index e292f8850b..d658deb3e7 100644 --- a/portal/views/procedure.py +++ b/portal/views/procedure.py @@ -74,9 +74,9 @@ def post_procedure(): all synonymous codes from different systems, only one code need be defined in the submission, say just the ICHOM system and code value. - Valuesets available at {} and {} list respective code values known - to the system for procedures indicating a patient has or has not - begun treatment. + Valuesets available at {tx_started} and {tx_not_started} list + respective code values known to the system for procedures indicating a + patient has or has not begun treatment. Raises 401 if logged-in user lacks permission to edit referenced patient. @@ -116,10 +116,11 @@ def post_procedure(): if missing valid OAuth token or logged-in user lacks permission to edit referenced patient - """.format(( - url_for(procedure_value_sets, valueset='tx-started', _external=True), - url_for(procedure_value_sets, valueset='tx-not-started', - _external=True))) + """.format(**{'tx_started': url_for( + '.procedure_value_sets', valueset='tx-started', _external=True), + 'tx_not_started': url_for( + '.procedure_value_sets', valueset='tx-not-started', + _external=True)}) # patient_id must first be parsed from the JSON subject field # standard role check is below after parse From 484d2d341c0d35446c95661664c4c743219dd024 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Wed, 21 Dec 2016 22:36:05 -0800 Subject: [PATCH 072/708] Swagger can't handle string formatting - back to relative url --- portal/views/procedure.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/portal/views/procedure.py b/portal/views/procedure.py index d658deb3e7..5ce92f7c35 100644 --- a/portal/views/procedure.py +++ b/portal/views/procedure.py @@ -74,9 +74,9 @@ def post_procedure(): all synonymous codes from different systems, only one code need be defined in the submission, say just the ICHOM system and code value. - Valuesets available at {tx_started} and {tx_not_started} list - respective code values known to the system for procedures indicating a - patient has or has not begun treatment. + Valuesets available at `/api/procedure/valueset/{valueset}` list + respective code values known to the system for procedures indicating + patient treatment status. Raises 401 if logged-in user lacks permission to edit referenced patient. @@ -116,11 +116,7 @@ def post_procedure(): if missing valid OAuth token or logged-in user lacks permission to edit referenced patient - """.format(**{'tx_started': url_for( - '.procedure_value_sets', valueset='tx-started', _external=True), - 'tx_not_started': url_for( - '.procedure_value_sets', valueset='tx-not-started', - _external=True)}) + """ # patient_id must first be parsed from the JSON subject field # standard role check is below after parse @@ -198,7 +194,7 @@ def procedure_delete(procedure_id): @procedure_api.route('/procedure/valueset/') def procedure_value_sets(valueset): - """Returns Valueset for treatment started codes + """Returns Valueset for treatment {started,not-started} codes --- tags: From 0150cc5c0f3502f52dcef9471faefb528ef4f839 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Wed, 21 Dec 2016 22:43:36 -0800 Subject: [PATCH 073/708] Revert using branched version of site persistence on travis. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 8d87516372..0419bf0a61 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,7 @@ env: global: - SQLALCHEMY_DATABASE_URI='postgresql://postgres:@localhost/portal_unit_tests' - LOG_FOLDER='/tmp/shared_service_log' - - PERSISTENCE_FILE='https://raw.githubusercontent.com/uwcirg/TrueNTH-USA-site-config/feature/tx-as-procedure/site_persistence_file.json' + - PERSISTENCE_FILE='https://raw.githubusercontent.com/uwcirg/TrueNTH-USA-site-config/develop/site_persistence_file.json' matrix: include: From 75918ac7a799bd8d3f8c576edd4478aabf9bf460 Mon Sep 17 00:00:00 2001 From: Amy Chen Date: Wed, 21 Dec 2016 23:07:54 -0800 Subject: [PATCH 074/708] initial queries refinements --- portal/templates/initial_queries.html | 408 ++++++++++++------- portal/templates/initial_queries_macros.html | 6 +- 2 files changed, 264 insertions(+), 150 deletions(-) diff --git a/portal/templates/initial_queries.html b/portal/templates/initial_queries.html index 8420604ac9..bd9884f222 100644 --- a/portal/templates/initial_queries.html +++ b/portal/templates/initial_queries.html @@ -83,109 +83,159 @@

{{ _("Thank you for registering.") }}

// Check if there's a session variable for associated clinic var preselectClinic = "{{ session.get('associate_clinic_id') }}"; +var fc, incompleteFields = [], mainSections = {}, _isChecked = false, rolesGroupClickEvent, patientQClickEvent, userOrgChangeEvent; +/***** helper class to keep track of missing fields ***/ +var FieldsChecker = function(mainSections) { + this.mainSections = mainSections; + this.incompleteFields = []; + this.skipped = false; +}; -function checkIncompleteFields() { +FieldsChecker.prototype.getIncompleteFields = function() { + return this.incompleteFields; +}; - var sections = []; - var incompleteFields = []; - var mainSections = { - 'nameGroup': [$("#firstname"), $("#lastname")], - 'rolesGroup': [$("input[name='user_type']")], - 'bdGroup': [$("#month"), $("#date"), $("#year")], - 'patientQ': [$("input[name='biopsy']"), $("input[name='pca_diag']"), $("input[name='pca_localized']")], - 'clinics': [$("#userOrgs input[name='organization'][data-parent-id]")] - }; - var _isChecked = false; - for (var section in mainSections) { - var fields = mainSections[section]; - fields.forEach(function(field) { - if (field.length > 0) { - var type = field.attr('type'); - switch(String(type).toLowerCase()) { - case 'checkbox': - _isChecked = false; - field.each(function() { - if ($(this).is(":checked")) { - _isChecked = true; - }; - }); - if (!(_isChecked)) incompleteFields.push({'section': $('#'+ section), 'element':field}); - break; - case 'radio': - if (!field.prop("checked")) incompleteFields.push({'section': $('#'+ section), 'element':field}); - break; - case 'select': - case 'text': - if (field.val() == '') incompleteFields.push({'section': $('#' + section), 'element': field}); - break; - }; - }; - }); - }; +FieldsChecker.prototype.setIncompleteFields = function() { + var self = this; + if (this.mainSections) { + var ms = this.mainSections; + self.reset(); + for (var section in ms) { + var fields = ms[section].fields; + fields.forEach(function(field) { + if (field.length > 0) { + var type = field.attr('type'); + switch(String(type).toLowerCase()) { + case 'checkbox': + _isChecked = false; + field.each(function() { + if ($(this).is(":checked")) { + _isChecked = true; + }; + }); + if (!(_isChecked)) self.incompleteFields.push({'sectionId': section, 'section': $('#'+ section), 'element':field}); + break; + case 'radio': + if (!field.is(":checked")) self.incompleteFields.push({'sectionId': section,'section': $('#'+ section), 'element':field}); + break; + case 'select': + case 'text': + if (field.val() == '') self.incompleteFields.push({'sectionId': section,'section': $('#' + section), 'element': field}); + break; + }; + }; + }); + }; - incompleteFields.forEach(function(field, index) { - var f = field.element; + self.incompleteFields.forEach(function(field, index) { + var fe = field.element; + fe.each(function() { + var f = $(this); f.attr('sectionIndex', index); - var triggerEvent = ($(f).attr("type") == "text" ? "change" : "click"); + }); + }); - f.on(triggerEvent, function() { - if ($(this).attr('sectionIndex') == (incompleteFields.length - 1)) $("#updateProfile").removeAttr("disabled"); - else { - incompleteFields[parseInt($(this).attr('sectionIndex')) + 1].section.show(); - incompleteFields[parseInt($(this).attr('sectionIndex')) + 1].element.show(); - }; - //console.log('field index: ' + f.attr('sectionIndex')); - //console.log('incomplete fields length: ' + incompleteFields.length); - }); + }; +}; +FieldsChecker.prototype.reset = function() { + var self = this; + self.incompleteFields.forEach(function(field) { + var fe = field.element; + fe.each(function() { + var f = $(this); + f.removeAttr('sectionIndex'); }); + }); + self.incompleteFields = []; +}; +FieldsChecker.prototype.getNextField = function(currentIndex) { + currentIndex = parseInt(currentIndex); + if (!isNaN(currentIndex) && currentIndex >= 0) { + if ((currentIndex + 1) <= (this.incompleteFields.length - 1)) { + this.incompleteFields[currentIndex + 1].section.show(); + var el = this.incompleteFields[currentIndex + 1].element; + el.fadeIn(); + $('html, body').animate({ + scrollTop: el.offset().top + }, 500); + }; + }; +}; - if (incompleteFields.length == 0) { - $("#updateProfile").removeAttr("disabled"); - } else { - incompleteFields[0].section.show(); - incompleteFields[0].element.show(); +FieldsChecker.prototype.allFieldsCompleted = function() { + this.setIncompleteFields(); + return this.incompleteFields.length == 0; +}; - }; - /* - incompleteFields.forEach(function(field, index) { - console.log(field.section.attr("id") + " " + field.section.length); +function continueOn() { + $("#continueText").fadeIn(); + $("#updateProfile").removeAttr("disabled"); +}; + +function initIncompleteFields() { + fc.setIncompleteFields(); + incompleteFields = fc.getIncompleteFields(); + incompleteFields.forEach(function(field, index) { + + var fe = field.element; + + fe.each(function() { + var f = $(this); + var triggerEvent = ($(f).attr("type") == "text" ? "change" : "click"); + if (f.get(0).nodeName.toLowerCase() == "select") triggerEvent = "change"; + var customEvent = mainSections[field.sectionId].event; + + if (customEvent) { + f.on(triggerEvent, customEvent); + } + else { + f.on(triggerEvent, function() { + var isComplete = fc.allFieldsCompleted(); + if (isComplete) { + continueOn(); + } + else { + //incompleteFields[parseInt($(this).attr('sectionIndex')) + 1].section.show(); + //incompleteFields[parseInt($(this).attr('sectionIndex')) + 1].element.show(); + fc.getNextField($(this).attr('sectionIndex')); + }; + //console.log('field index: ' + f.attr('sectionIndex')); + //console.log('incomplete fields length: ' + incompleteFields.length); + }); + }; }); - */ + }); - //console.log("length: " + incompleteFields.length); -}; + if (incompleteFields.length == 0) { + $("#updateProfile").removeAttr("disabled"); + } else { + incompleteFields[0].section.show(); + incompleteFields[0].element.show(); + }; + /*********** + incompleteFields.forEach(function(field, index) { + console.log(field.section.attr("id") + " " + field.element.length); + console.log(field.element) -$(document).ready(function(){ + }); + ********/ - //setTimeout("checkIncompleteFields()", 500); - $(document).ajaxStop(function () { - checkIncompleteFields(); - }); - /** 1st - get all current info **/ - /***done in macros now*****/ - //tnthAjax.getRoles({{ user.id }}); - //tnthAjax.getOrgs({{ user.id }}); - //tnthAjax.getDemo({{ user.id}}); - //tnthAjax.getClinical({{ user.id }}); + //console.log("length: " + incompleteFields.length); - /** 2nd - submit changes and update UI **/ - /* - $("#queriesForm input").on("blur",function(){ - //this only works when demo info is present - assembleContent.dob({{user.id}}); - }); - */ +}; - $("#rolesGroup input:radio").on("click",function(){ +$(document).ready(function(){ + + var rolesGroupClickEvent = function(){ var roles = []; var theVal = $(this).val(); roles.push({name: theVal}); @@ -193,78 +243,103 @@

{{ _("Thank you for registering.") }}

tnthAjax.putRoles({{user.id}},toSend); if (theVal == "patient") { // If patient, then we always show bdGroup - $("#bdGroup").fadeIn(); + if ($("#bdGroup").length > 0) { + $("#bdGroup").fadeIn(); + }; // Also, if they already have a valid birthday, then trigger blur to make patientQ appear if ($("#year").val() != "") { $("#year").blur(); - $('html, body').animate({ - scrollTop: $('#patientQ').offset().top - }, 500); + if ($('#patientQ').length > 0) { + $('#patientQ').fadeIn(); + $('html, body').animate({ + scrollTop: $('#patientQ').offset().top + }, 500); + } else fc.getNextField($(this).attr("sectionIndex")); } else { - $('html, body').animate({ - scrollTop: $('#bdGroup').offset().top - }, 500); - } - $("#clinics").hide(); + if ($("#bdGroup").length > 0 ) { + $('html, body').animate({ + scrollTop: $('#bdGroup').offset().top + }, 500); + } else fc.getNextField($(this).attr("sectionIndex")); + }; + //$("#clinics").hide(); + } else { // If partner, they skip questions, fadeIn Clinics - $("#clinics").fadeIn(); $("#patientQ").hide(); $("#bdGroup").hide(); - $('html, body').animate({ - scrollTop: $('#clinics').offset().top - }, 500); + if ($("#clinics").length > 0 ) { + $("#clinics").fadeIn(); + $('html, body').animate({ + scrollTop: $('#clinics').offset().top + }, 500); + } else fc.getNextField($(this).attr("sectionIndex")); }; - }); + if (fc.allFieldsCompleted()) continueOn(); + }; - $(".pat-q input:radio").on("click",function(){ - var thisItem = $(this); - var toCall = thisItem.attr("name") - // Get value from div - either true or false - var toSend = thisItem.val(); - tnthAjax.putClinical({{user.id}},toCall,toSend); - if (toSend == "true" || toCall == "pca_localized") { - thisItem.parents(".pat-q").next().fadeIn(); - var nextRadio = thisItem.closest(".pat-q").next(".pat-q"); - var nextItem = nextRadio.length > 0 ? nextRadio : thisItem.parents(".pat-q").next(); - if (nextItem.length > 0) { - $('html, body').animate({ - scrollTop: nextItem.offset().top - }, 500); - }; - } else { - if (toCall == "biopsy") { - ["pca_diag", "pca_localized"].forEach(function(fieldName) { - $("input[name='" + fieldName + "']").each(function() { - $(this).prop("checked", false); + var patientQClickEvent = function(){ + var thisItem = $(this); + var toCall = thisItem.attr("name") + // Get value from div - either true or false + var toSend = thisItem.val(); + tnthAjax.putClinical({{user.id}},toCall,toSend); + if (toSend == "true" || toCall == "pca_localized") { + thisItem.parents(".pat-q").next().fadeIn(); + var nextRadio = thisItem.closest(".pat-q").next(".pat-q"); + var nextItem = nextRadio.length > 0 ? nextRadio : thisItem.parents(".pat-q").next(); + if (nextItem.length > 0) { + $('html, body').animate({ + scrollTop: nextItem.offset().top + }, 500); + } else { + if ($("#clinics").length > 0) { + $("#clinics").fadeIn(); + $('html, body').animate({ + scrollTop: $('#clinics').offset().top + }, 500); + } else fc.getNextField(thisItem.attr("sectionIndex")); + }; + } else { + if (toCall == "biopsy") { + ["pca_diag", "pca_localized"].forEach(function(fieldName) { + $("input[name='" + fieldName + "']").each(function() { + $(this).prop("checked", false); + }); }); - }); - tnthAjax.putClinical({{user.id}},"pca_diag","false"); - tnthAjax.putClinical({{user.id}},"pca_localized","false"); - } else if (toCall == "pca_diag") { - ["pca_localized"].forEach(function(fieldName) { - $("input[name='" + fieldName + "']").each(function() { - $(this).prop("checked", false); + if ($("input[name='pca_diag']").length > 0) tnthAjax.putClinical({{user.id}},"pca_diag","false"); + if ($("input[name='pca_localized']").length > 0) tnthAjax.putClinical({{user.id}},"pca_localized","false"); + } else if (toCall == "pca_diag") { + ["pca_localized"].forEach(function(fieldName) { + $("input[name='" + fieldName + "']").each(function() { + $(this).prop("checked", false); + }); }); - }); - tnthAjax.putClinical({{user.id}},"pca_localized","false"); - } - thisItem.parents(".pat-q").nextAll().fadeOut(); - $("#clinics").fadeIn(); - $('html, body').animate({ - scrollTop: $('#clinics').offset().top - }, 500); + if ($("input[name='pca_localized']").length > 0) tnthAjax.putClinical({{user.id}},"pca_localized","false"); + //tnthAjax.putClinical({{user.id}},"tx","false"); + } + thisItem.parents(".pat-q").nextAll().fadeOut(); + if ($("#clinics").length > 0) { + $("#clinics").fadeIn(); + $('html, body').animate({ + scrollTop: $('#clinics').offset().top + }, 500); + } else fc.getNextField(thisItem.attr("sectionIndex")); + }; + + if (fc.allFieldsCompleted()) continueOn(); }; - }); - $("#userOrgs").on("change", "input", function() { + var userOrgChangeEvent = function() { //console.log("changed?") assembleContent.demo({{ user.id }}); - $("#continueText").fadeIn(); - $("#updateProfile").removeAttr("disabled"); - }); + //if (fc.allFieldsCompleted()) { + continueOn(); + //}; + }; + - $("#agreeLabel").on("click",function(){ + $("#agreeLabel").on("click",function(){ if ($(this).attr("data-agree") == "false") { var theTerms = {}; theTerms["agreement_url"] = $("#termsURL").data().url; @@ -284,20 +359,59 @@

{{ _("Thank you for registering.") }}

$("#aboutForm").fadeOut(); $(this).attr("data-agree","false"); }; - }); + }); - //if term of use form not present - need to show the form - if ($("#topTerms").length == 0) $("#aboutForm").fadeIn(); + //if term of use form not present - need to show the form + if ($("#topTerms").length == 0) $("#aboutForm").fadeIn(); - $('#queriesForm').validator().on('submit', function (e) { - if (e.isDefaultPrevented()) { - alert("There's a problem with your submission. Please check your answers, then try again."); - } else { - assembleContent.demo({{ user.id }}); - } - }); + $('#queriesForm').validator().on('submit', function (e) { + if (e.isDefaultPrevented()) { + alert("There's a problem with your submission. Please check your answers, then try again. Make sure all required fields are completed."); + } else { + assembleContent.demo({{ user.id }}); + } + }); + + + /**** main object + this will help keeping track of missing fields + *****/ + mainSections = { + + 'nameGroup': { + fields: [$("#firstname"), $("#lastname")], + event: null + }, + 'rolesGroup': { + fields: [$("input[name='user_type']")], + event: rolesGroupClickEvent + }, + 'bdGroup': { + fields: [$("#month"), $("#date"), $("#year")], + event: null + }, + 'patientQ': { + fields: [$("input[name='biopsy']"), $("input[name='pca_diag']"), $("input[name='pca_localized']")], + event: patientQClickEvent + }, + 'clinics': { + fields: [$("#userOrgs input[name='organization'][data-parent-id]")], + event: userOrgChangeEvent + } + }; + + fc = new FieldsChecker(mainSections); + setTimeout("initIncompleteFields();", 500); + + + /** 1st - get all current info **/ + /***done in macros now*****/ + //tnthAjax.getRoles({{ user.id }}); + //tnthAjax.getOrgs({{ user.id }}); + //tnthAjax.getDemo({{ user.id}}); + //tnthAjax.getClinical({{ user.id }}); - /* + /************** old consent stuff $("#userOrgs input[name='organization']").each(function() { $(this).on("click", function() { var parentOrg = $(this).attr("data-parent-id"); @@ -321,7 +435,7 @@

{{ _("Thank you for registering.") }}

}); - });*/ + });********************************/ }); {% endblock %} diff --git a/portal/templates/initial_queries_macros.html b/portal/templates/initial_queries_macros.html index acf0f705d5..e33def423d 100644 --- a/portal/templates/initial_queries_macros.html +++ b/portal/templates/initial_queries_macros.html @@ -25,13 +25,13 @@

{{ _("Terms of Use") }}

{% macro nameGroup() -%}
-
+
-
+
@@ -112,7 +112,7 @@

{{ _("Terms of Use") }}

}); {%- endmacro %} {% macro patientQGroup(user) -%} -
+


From 21c84cc4e51403352859178d831a21324917163d Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Wed, 21 Dec 2016 23:32:09 -0800 Subject: [PATCH 075/708] Update webob from 1.6.3 to 1.7.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b4342d8254..f5ae884d01 100644 --- a/requirements.txt +++ b/requirements.txt @@ -92,7 +92,7 @@ vine==1.1.3 virtualenv==15.1.0 waitress==1.0.1 wcwidth==0.1.7 -WebOb==1.6.3 +WebOb==1.7.0 WebTest==2.0.24 Werkzeug==0.11.11 WTForms==2.1 From 473dfcbf013c2685df38b90a2097982dc0fdd30b Mon Sep 17 00:00:00 2001 From: Amy Chen Date: Thu, 22 Dec 2016 09:33:36 -0800 Subject: [PATCH 076/708] make change in initial queries to skip questions for caregiver user --- portal/templates/initial_queries.html | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/portal/templates/initial_queries.html b/portal/templates/initial_queries.html index bd9884f222..930ae70737 100644 --- a/portal/templates/initial_queries.html +++ b/portal/templates/initial_queries.html @@ -265,15 +265,11 @@

{{ _("Thank you for registering.") }}

//$("#clinics").hide(); } else { - // If partner, they skip questions, fadeIn Clinics + // If partner, skip all questions $("#patientQ").hide(); $("#bdGroup").hide(); - if ($("#clinics").length > 0 ) { - $("#clinics").fadeIn(); - $('html, body').animate({ - scrollTop: $('#clinics').offset().top - }, 500); - } else fc.getNextField($(this).attr("sectionIndex")); + $("#noOrgs").click(); + continueOn(); }; if (fc.allFieldsCompleted()) continueOn(); }; @@ -324,7 +320,7 @@

{{ _("Thank you for registering.") }}

$('html, body').animate({ scrollTop: $('#clinics').offset().top }, 500); - } else fc.getNextField(thisItem.attr("sectionIndex")); + } else continueOn(); }; if (fc.allFieldsCompleted()) continueOn(); From 9a5201e79a9795c2e3035bdaa60ed85ec58bb45d Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Thu, 22 Dec 2016 11:24:27 -0800 Subject: [PATCH 077/708] Bug in codeable concept insertion (comparing id's prior to commit) caused incorrect codings for procedures to land in the respective codeable concepts. This commit may cause server failures as it's erroring noisily if bad data is found in the db. Don't revert the commit - fix the db! --- portal/models/fhir.py | 28 ++++++++++----- portal/models/procedure_codes.py | 61 +++++++++++++++++--------------- portal/views/procedure.py | 2 +- tests/__init__.py | 4 +-- 4 files changed, 55 insertions(+), 40 deletions(-) diff --git a/portal/models/fhir.py b/portal/models/fhir.py index 15f91a5a0f..4305fcdd9c 100644 --- a/portal/models/fhir.py +++ b/portal/models/fhir.py @@ -65,8 +65,8 @@ class CodeableConceptCoding(db.Model): 'codeable_concepts.id'), nullable=False) coding_id = db.Column(db.ForeignKey('codings.id'), nullable=False) - # Maintain a unique relationship between each codeable concepts - # and the its of codings. Therefore, a CodeableConcept always + # Maintain a unique relationship between each codeable concept + # and it list of codings. Therefore, a CodeableConcept always # contains the superset of all codings given for the concept. db.UniqueConstraint('codeable_concept_id', 'coding_id', name='unique_codeable_concept_coding') @@ -114,18 +114,26 @@ def add_if_not_found(self, commit_immediately=False): # we're imposing a constraint, where any CodeableConcept pointing # at a particular Coding will be the ONLY CodeableConcept for that # particular Coding. - coding_ids = [c.id for c in self.codings] + coding_ids = [c.id for c in self.codings if c.id] if not coding_ids: - current_app.logger.error("no coding_ids found for {}".format(self)) - found = CodeableConceptCoding.query.filter( - CodeableConceptCoding.coding_id.in_(coding_ids)).first() - if not found: + raise ValueError("Can't add CodeableConcept without any codings") + query = CodeableConceptCoding.query.filter( + CodeableConceptCoding.coding_id.in_(coding_ids)).distinct( + CodeableConceptCoding.codeable_concept_id) + if query.count() > 1: + raise ValueError( + "DB problem - multiple CodeableConcepts {} found for " + "codings: {}".format( + [cc.codeable_concept_id for cc in query], + [str(c) for c in self.codings])) + if not query.count(): # First time for this (set) of codes, add new rows db.session.add(self) if commit_immediately: db.session.commit() else: # Build a union of all codings found, old and new + found = query.first() old = CodeableConcept.query.get(found.codeable_concept_id) self.text = self.text if self.text else old.text self.codings = list(set(old.codings).union(set(self.codings))) @@ -154,7 +162,7 @@ def from_fhir(cls, data): for i in ("system", "code", "display"): if i in data: cc.__setattr__(i, data[i]) - return cc.add_if_not_found() + return cc.add_if_not_found(True) def as_fhir(self): """Return self in JSON FHIR formatted string""" @@ -164,7 +172,7 @@ def as_fhir(self): d[i] = getattr(self, i) return d - def add_if_not_found(self): + def add_if_not_found(self, commit_immediately=False): """Add self to database, or return existing Queries for similar, existing CodeableConcept (matches on @@ -179,6 +187,8 @@ def add_if_not_found(self): code=self.code).first() if not match: db.session.add(self) + if commit_immediately: + db.session.commit() elif self is not match: self = db.session.merge(match) return self diff --git a/portal/models/procedure_codes.py b/portal/models/procedure_codes.py index 4e0c974540..4a19f11164 100644 --- a/portal/models/procedure_codes.py +++ b/portal/models/procedure_codes.py @@ -57,85 +57,89 @@ def __iter__(self): def RadicalProstatectomy(self): sno = Coding( system=SNOMED, code='26294005', - display='Radical prostatectomy (nerve-sparing)').add_if_not_found() + display='Radical prostatectomy (nerve-sparing)' + ).add_if_not_found(True) ichom = Coding( system=ICHOM, code='3', - display='Radical prostatectomy (nerve-sparing)').add_if_not_found() + display='Radical prostatectomy (nerve-sparing)' + ).add_if_not_found(True) return CodeableConcept( codings=[sno, ichom], text='Radical prostatectomy (nerve-sparing' - ).add_if_not_found() + ).add_if_not_found(True) @lazyprop def RadicalProstatectomyNNS(self): sno = Coding( system=SNOMED, code='26294005-nns', display='Radical prostatectomy (non-nerve-sparing)' - ).add_if_not_found() + ).add_if_not_found(True) ichom = Coding( system=ICHOM, code='3-nns', display='Radical prostatectomy (non-nerve-sparing)' - ).add_if_not_found() + ).add_if_not_found(True) return CodeableConcept( codings=[sno, ichom], - text='Radical prostatectomy (non-nerve-sparing').add_if_not_found() + text='Radical prostatectomy (non-nerve-sparing' + ).add_if_not_found(True) @lazyprop def ExternalBeamRadiationTherapy(self): sno = Coding( system=SNOMED, code='33195004', display='External beam radiation therapy' - ).add_if_not_found() + ).add_if_not_found(True) ichom = Coding( system=ICHOM, code='4', display='External beam radiation therapy' - ).add_if_not_found() + ).add_if_not_found(True) return CodeableConcept( codings=[sno, ichom], - text='External beam radiation therapy').add_if_not_found() + text='External beam radiation therapy').add_if_not_found(True) @lazyprop def Brachytherapy(self): sno = Coding( system=SNOMED, code='228748004', display='Brachytherapy' - ).add_if_not_found() + ).add_if_not_found(True) ichom = Coding( - system=ICHOM, code='5', display='Brachytherapy').add_if_not_found() + system=ICHOM, code='5', + display='Brachytherapy').add_if_not_found(True) return CodeableConcept( codings=[sno, ichom], - text='Brachytherapy').add_if_not_found() + text='Brachytherapy').add_if_not_found(True) @lazyprop def AndrogenDeprivationTherapy(self): sno = Coding( system=SNOMED, code='707266006', - display='Androgen deprivation therapy').add_if_not_found() + display='Androgen deprivation therapy').add_if_not_found(True) ichom = Coding( system=ICHOM, code='6', display='Androgen deprivation therapy' - ).add_if_not_found() + ).add_if_not_found(True) return CodeableConcept( codings=[sno, ichom], - text='Androgen deprivation therapy').add_if_not_found() + text='Androgen deprivation therapy').add_if_not_found(True) @lazyprop def FocalTherapy(self): ichom = Coding( system=ICHOM, code='7', display='Focal therapy' - ).add_if_not_found() + ).add_if_not_found(True) return CodeableConcept( codings=[ichom,], text='Focal therapy' - ).add_if_not_found() + ).add_if_not_found(True) @lazyprop def OtherProcedure(self): sno = Coding( system=SNOMED, code='118877007', - display='Procedure on prostate').add_if_not_found() + display='Procedure on prostate').add_if_not_found(True) ichom = Coding( system=ICHOM, code='888', display='Other (free text)' - ).add_if_not_found() + ).add_if_not_found(True) return CodeableConcept( codings=[sno, ichom], text='Other procedure on prostate' - ).add_if_not_found() + ).add_if_not_found(True) class TxNotStartedConstants(object): @@ -156,26 +160,27 @@ def __iter__(self): def StartedWatchfulWaiting(self): sno = Coding( system=SNOMED, code='373818007', - display='Started watchful waiting').add_if_not_found() + display='Started watchful waiting').add_if_not_found(True) ichom = Coding( system=ICHOM, code='1', display='Watchful waiting' - ).add_if_not_found() + ).add_if_not_found(True) return CodeableConcept(codings=[sno, ichom], text='Watchful waiting' - ).add_if_not_found() + ).add_if_not_found(True) @lazyprop def StartedActiveSurveillance(self): sno = Coding( system=SNOMED, code='424313000', - display='Started active surveillance').add_if_not_found() + display='Started active surveillance').add_if_not_found(True) ichom = Coding( system=ICHOM, code='2', display='Active surveillance' - ).add_if_not_found() + ).add_if_not_found(True) return CodeableConcept(codings=[sno, ichom], text='Active surveillance' - ).add_if_not_found() + ).add_if_not_found(True) @lazyprop def NoneOfTheAbove(self): tnth = Coding(system=TRUENTH_CLINICAL_CODE_SYSTEM, code='999', - display='None').add_if_not_found() - return CodeableConcept(codings=[tnth], text='None').add_if_not_found() + display='None').add_if_not_found(True) + return CodeableConcept(codings=[tnth], + text='None').add_if_not_found(True) diff --git a/portal/views/procedure.py b/portal/views/procedure.py index 5ce92f7c35..d5e3d2be42 100644 --- a/portal/views/procedure.py +++ b/portal/views/procedure.py @@ -1,4 +1,4 @@ -from flask import abort, jsonify, Blueprint, request, url_for +from flask import abort, jsonify, Blueprint, request from collections import defaultdict from ..audit import auditable_event diff --git a/tests/__init__.py b/tests/__init__.py index 4ef60a3876..1dbda4debe 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -143,8 +143,8 @@ def add_procedure(self, code='367336001', display='Chemotherapy', procedure = Procedure(audit=audit) coding = Coding(system=system, code=code, - display=display).add_if_not_found() - code = CodeableConcept(codings=[coding,]).add_if_not_found() + display=display).add_if_not_found(True) + code = CodeableConcept(codings=[coding,]).add_if_not_found(True) procedure.code = code procedure.user = db.session.merge(self.test_user) procedure.start_time = datetime.utcnow() From 49e214cd6703f35907ded4d9961639828c97bdd2 Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Thu, 22 Dec 2016 12:48:39 -0800 Subject: [PATCH 078/708] GET /api/user//consent now returns all consents with deleted and expires details. --- portal/models/user.py | 5 +++++ portal/models/user_consent.py | 2 +- portal/views/user.py | 6 ++++-- tests/test_consent.py | 6 ++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/portal/models/user.py b/portal/models/user.py index 6b4f7a186b..42ce312bed 100644 --- a/portal/models/user.py +++ b/portal/models/user.py @@ -282,6 +282,11 @@ def __setattr__(self, name, value): name, self)) return super(User, self).__setattr__(name, value) + @property + def all_consents(self): + """Access to all consents including deleted and expired""" + return self._consents + @property def valid_consents(self): """Access to consents that have neither been deleted or expired""" diff --git a/portal/models/user_consent.py b/portal/models/user_consent.py index 56f720604d..dee7b95970 100644 --- a/portal/models/user_consent.py +++ b/portal/models/user_consent.py @@ -47,7 +47,7 @@ def as_json(self): d['expires'] = FHIR_datetime.as_fhir(self.expires) d['agreement_url'] = self.agreement_url if self.deleted_id: - d['deleted'] = FHIR_datetime.as_fhir(self.deleted.timestamp) + d['deleted'] = self.deleted.as_fhir() return d diff --git a/portal/views/user.py b/portal/views/user.py index 4d01d7bb6a..7f8629d6b6 100644 --- a/portal/views/user.py +++ b/portal/views/user.py @@ -276,7 +276,9 @@ def user_consents(user_id): Returns the list of consent agreements between the requested user and the respective organizations. - NB does not include expired or deleted consents. + NB does include deleted and expired consents. Deleted consents will + include audit details regarding the deletion. The expires timestamp in UTC + is also returned for all consents. --- tags: @@ -348,7 +350,7 @@ def user_consents(user_id): user = get_user(user_id) return jsonify(consent_agreements=[c.as_json() for c in - user.valid_consents]) + user.all_consents]) @user_api.route('/user//consent', methods=('POST',)) diff --git a/tests/test_consent.py b/tests/test_consent.py index 47ffd9ca5f..85e8a18f01 100644 --- a/tests/test_consent.py +++ b/tests/test_consent.py @@ -62,6 +62,7 @@ def test_delete_user_consent(self): self.test_user = db.session.merge(self.test_user) self.assertEqual(self.test_user.valid_consents.count(), 2) self.login() + rv = self.app.delete('/api/user/{}/consent'.format(TEST_USER_ID), content_type='application/json', data=json.dumps(data)) @@ -69,3 +70,8 @@ def test_delete_user_consent(self): self.assertEqual(self.test_user.valid_consents.count(), 1) self.assertEqual(self.test_user.valid_consents[0].organization_id, org2_id) + + # We no longer omit deleted consent rows, but rather, include + # their audit data. + rv = self.app.get('/api/user/{}/consent'.format(TEST_USER_ID)) + self.assertTrue('deleted' in json.dumps(rv.json)) From 4138e346bd1d2ee23fded9dce1b40bc2908edf6d Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Thu, 22 Dec 2016 13:02:46 -0800 Subject: [PATCH 079/708] Caregiver (ROLE.PARTNER) doesn't have to supply an organization. --- portal/models/coredata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portal/models/coredata.py b/portal/models/coredata.py index 5f7223fec9..7346460956 100644 --- a/portal/models/coredata.py +++ b/portal/models/coredata.py @@ -121,7 +121,7 @@ def hasdata(self, user): Special "none of the above" org still counts. """ - if user.has_role(ROLE.PROVIDER): + if user.has_role(ROLE.PROVIDER) or user.has_role(ROLE.PARTNER): return True if user.organizations.count() > 0: return True From 25177677f10c0dc0a5f7279d5286a730624c9668 Mon Sep 17 00:00:00 2001 From: Amy Chen Date: Thu, 22 Dec 2016 13:24:15 -0800 Subject: [PATCH 080/708] remove code to update org for caregiver in initial queries --- portal/templates/initial_queries.html | 1 - 1 file changed, 1 deletion(-) diff --git a/portal/templates/initial_queries.html b/portal/templates/initial_queries.html index 930ae70737..87687e0061 100644 --- a/portal/templates/initial_queries.html +++ b/portal/templates/initial_queries.html @@ -268,7 +268,6 @@

{{ _("Thank you for registering.") }}

// If partner, skip all questions $("#patientQ").hide(); $("#bdGroup").hide(); - $("#noOrgs").click(); continueOn(); }; if (fc.allFieldsCompleted()) continueOn(); From ce0d2a660721d52a98293c1feea00f63a09cdbfc Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Thu, 22 Dec 2016 15:38:58 -0800 Subject: [PATCH 081/708] Provide backdoor for p/w change w/o challenge if user doesn't have defined values for birthdate, first_name and last_name. --- portal/views/extend_flask_user.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/portal/views/extend_flask_user.py b/portal/views/extend_flask_user.py index 74de41080b..67de86bec9 100644 --- a/portal/views/extend_flask_user.py +++ b/portal/views/extend_flask_user.py @@ -3,6 +3,7 @@ from flask_user.views import reset_password from .portal import challenge_identity +from ..models.user import get_user def reset_password_view_function(token): @@ -11,6 +12,13 @@ def reset_password_view_function(token): token, current_app.user_manager.reset_password_expiration) + # Some early users were not forced to set DOB and name fields. + # As they will fail the challenge without data to compare, provide + # a back door. + user = get_user(user_id) + if not all((user.birthdate, user.first_name, user.last_name)): + return reset_password(token) + # Once the user has passed the challenge, let the flask_user # reset_password() function to the real work verified = session.get('challenge_verified_user_id') From b5c7d194c11facf68e7ae08312ade6ce9678464d Mon Sep 17 00:00:00 2001 From: Paul Bugni Date: Thu, 22 Dec 2016 15:52:52 -0800 Subject: [PATCH 082/708] Fix for #135964191 - redirect to home if logged in user attempts /go or any sign-in / register pages. --- portal/views/auth.py | 9 ++++++++- portal/views/portal.py | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/portal/views/auth.py b/portal/views/auth.py index be9046f5cc..711ae2d5ca 100644 --- a/portal/views/auth.py +++ b/portal/views/auth.py @@ -87,7 +87,14 @@ def capture_next_view_function(real_function): real_function = real_function def capture_next(): - """Alternate view function plugged in to capture 'next' in session""" + """Alternate view function plugged in to capture 'next' in session + + NB if already logged in - this will bounce user to home + + """ + if current_user(): + return redirect(url_for('portal.home')) + if request.args.get('next'): session['next'] = request.args.get('next') current_app.logger.debug( diff --git a/portal/views/portal.py b/portal/views/portal.py index 13147a64aa..bbdc4ac81b 100644 --- a/portal/views/portal.py +++ b/portal/views/portal.py @@ -92,7 +92,12 @@ def specific_clinic_entry(): Store the clinic in the session for association with the user once registered and redirect to the standard landing page. + NB if already logged in - this will bounce user to home + """ + if current_user(): + return redirect(url_for('portal.home')) + form = ShortcutAliasForm(request.form) if not form.validate_on_submit(): From 7e98125c8c95a42b28c0226b424f93c5c1c562c4 Mon Sep 17 00:00:00 2001 From: Amy Chen Date: Thu, 22 Dec 2016 21:14:26 -0800 Subject: [PATCH 083/708] add consent history section to profile --- portal/static/css/eproms.css | 22 ++++++++++++ portal/static/css/portal.css | 26 +++++++++++++- portal/static/js/main.js | 53 ++++++++++++++++++++++++---- portal/static/less/eproms.less | 19 ++++++++++ portal/static/less/portal.less | 21 ++++++++++- portal/templates/profile_create.html | 25 ++++++++----- portal/templates/profile_macros.html | 41 ++++++++++++++++++--- 7 files changed, 187 insertions(+), 20 deletions(-) diff --git a/portal/static/css/eproms.css b/portal/static/css/eproms.css index eea35dfc49..152f2dcff9 100644 --- a/portal/static/css/eproms.css +++ b/portal/static/css/eproms.css @@ -1039,6 +1039,24 @@ sub.pointer:hover { position: relative; top: -2px; } + +#profileConsentList { + font-size: 0.95em; + max-width: 100%; + padding: 0.1em; +} +#profileConsentList .consentlist-header { + background-color: #7d826e; + color: #FFF; + font-weight: normal; + padding-left: 0.5em; + padding-right: 0.5em; +} +#profileConsentList .consentlist-cell{ + background-color: #fbf9f9; + padding-left: 0.5em; + padding-right: 0.5em; +} #identityVerificationContainer { padding: 2em 1.5em; margin: 1em auto; @@ -1163,6 +1181,10 @@ div.input-group.date { table.profile-audit-log { font-size: 0.7em; } + + #profileConsentList { + font-size: 0.7em; + } .create-account-container { padding: 3.5em 3em; width: 90%; diff --git a/portal/static/css/portal.css b/portal/static/css/portal.css index 539333ec25..6d30f59fa9 100644 --- a/portal/static/css/portal.css +++ b/portal/static/css/portal.css @@ -54,7 +54,7 @@ a.btn { #loginEmailReminderText { margin-left: 8px; - font-size: 0.87em; + font-size: 0.87em; text-align: left; padding: 0.1em auto; color: #5a5a5a; @@ -1015,6 +1015,25 @@ sub.pointer:hover { position: relative; top: -2px; } + +#profileConsentList { + font-size: 0.95em; + max-width: 100%; + padding: 0.1em; +} +#profileConsentList .consentlist-header { + background-color: #7d826e; + color: #FFF; + font-weight: normal; + padding-left: 0.5em; + padding-right: 0.5em; +} +#profileConsentList .consentlist-cell{ + background-color: #fbf9f9; + padding-left: 0.5em; + padding-right: 0.5em; +} + #identityVerificationContainer { padding: 2em 1.5em; margin: 1em auto; @@ -1153,6 +1172,11 @@ div.input-group.date { table.profile-audit-log { font-size: 0.7em; } + + #profileConsentList { + font-size: 0.7em; + } + .create-account-container { padding: 3.5em 3em; width: 90%; diff --git a/portal/static/js/main.js b/portal/static/js/main.js index b105628dc8..b2da9f7cbb 100644 --- a/portal/static/js/main.js +++ b/portal/static/js/main.js @@ -232,6 +232,41 @@ var fillContent = { $("#terms").fadeIn(); }; }, + "consentList" : function(data) { + if (data && data["consent_agreements"] && data["consent_agreements"].length > 0) { + var content = ""; + ['Organization', 'Consented', 'Agreement', 'Signed Date (GMT)', 'Expires (GMT)'].forEach(function (title) { + content += ""; + }); + var dataArray = data["consent_agreements"].reverse(); + dataArray.forEach(function(item) { + if (!(/null/.test(item.agreement_url))) { + var orgName = $("#fillOrgs input[name='organization'][parent_org='true'][value='" + item.organization_id + "']").attr("org_name"); + //console.log(item.organization_id + ": " + orgName) + var expired = tnthDates.getDateDiff(item.expires); + var consentStatus = item.deleted ? "deleted" : (expired > 0 ? "expired": "active"); + var deleteDate = item.deleted ? item.deleted["lastUpdated"]: ""; + var sDisplay = ""; + switch(consentStatus) { + case "deleted": + sDisplay = "
(withdrawn on " + deleteDate.replace("T", " ") + " GMT)"; + break; + case "expired": + sDisplay = "
(expired)" + break; + case "active": + sDisplay = ""; + break; + }; + + content += "
"; + }; + }); + content += "
" + title + "
" + (orgName != "" && orgName != undefined? orgName : item.organization_id) + "" + sDisplay + "View" + (item.signed).replace("T", " ") + "" + (item.expires).replace("T", " ") + "
"; + $("#profileConsentList").html(content); + } else $("#profileConsentList").html("

No consent found for this user.

"); + $("#profileConsentList").animate({opacity: 1}); + }, "proceduresContent": function(data,newEntry) { if (data.entry.length == 0) { $("body").find("#userProcedures").html("

You haven't entered any treatments yet.

").animate({opacity: 1}); @@ -689,6 +724,7 @@ var tnthAjax = { getSaveLoaderDiv("profileForm", "userOrgs"); $(this).attr("save-container-id", "userOrgs"); assembleContent.demo(userId,true, $(this), true); + if (typeof reloadConsentList != "undefined") reloadConsentList(); //need to delete consent after ? otherwise get unauthorized error tnthAjax.handleConsent(); //}; @@ -744,10 +780,12 @@ var tnthAjax = { }; }); }; + fillContent.consentList(data); loader(); return true; }).fail(function() { console.log("Problem retrieving data from server."); + fillContent.consentList(null); loader(); return false; }); @@ -803,14 +841,16 @@ var tnthAjax = { $.ajax ({ type: "GET", url: '/api/user/'+userId+"/consent", - async: false + async: false, + cache: false }).done(function(data) { if (data.consent_agreements) { var d = data["consent_agreements"]; d.forEach(function(item) { var orgId = item.organization_id; - - if (orgId == parentOrg) { + //console.log("expired: " + item.expires + " dateDiff: " + tnthDates.getDateDiff(item.expires)) + var expired = tnthDates.getDateDiff(item.expires); + if (orgId == parentOrg && !item.deleted && !(expired > 0)) { //console.log("consented orgid: " + orgId) consentedOrgIds.push(orgId); }; @@ -845,17 +885,18 @@ var tnthAjax = { var pOrg, prevOrg; $("#userOrgs input[name='organization']").each(function() { //console.log("in id: " + $(this).attr("id")) - if ($(this).attr("id") !== "noOrgs") { + if ($(this).attr("id") !== "noOrgs") { + //console.log("prevOg: " + prevOrg + " current org: " + $(this).attr("data-parent-id")) //remove consent for this org if (prevOrg != $(this).attr("data-parent-id")) { pOrg = $(this).attr("data-parent-id"); - if (parseInt(pOrg) > 0) { + if (pOrg && (parseInt(pOrg) > 0)) { self.deleteConsent(userId, {"org": pOrg}); }; - prevOrg = pOrg; }; }; + prevOrg = $(this).attr("data-parent-id"); }); //remove all consents //$("#consentContainer input.consent-checkbox").each(function() { diff --git a/portal/static/less/eproms.less b/portal/static/less/eproms.less index fea99f233f..033e400fb2 100644 --- a/portal/static/less/eproms.less +++ b/portal/static/less/eproms.less @@ -1166,6 +1166,22 @@ color: #777 top:-2px; } +#profileConsentList { + font-size: 0.95em; + max-width: 100%; + padding: 0.1em; + .consentlist-header { + background-color: @rowBackgroundColor; + color: #FFF; + font-weight: normal; + padding-left: 0.5em; + padding-right: 0.5em; + } + .consentlist-cell{ + background-color: #fbf9f9; + } +} + #identityVerificationContainer { padding: 2em 1.5em; margin: 1em auto; @@ -1292,6 +1308,9 @@ div.input-group.date { table.profile-audit-log { font-size: 0.7em; } + #profileConsentList { + font-size: 0.7em + } .create-account-container { padding: 3.5em 3em; width: 90%; diff --git a/portal/static/less/portal.less b/portal/static/less/portal.less index c09202d0da..fe0df28e83 100644 --- a/portal/static/less/portal.less +++ b/portal/static/less/portal.less @@ -79,7 +79,7 @@ a.btn { #loginEmailReminderText { margin-left: 8px; - font-size: 0.87em; + font-size: 0.87em; text-align: left; padding: 0.1em auto; color: #5a5a5a; @@ -1132,6 +1132,22 @@ color: #777 top:-2px; } +#profileConsentList { + font-size: 0.95em; + max-width: 100%; + padding: 0.1em; + .consentlist-header { + background-color: @rowBackgroundColor; + color: #FFF; + font-weight: normal; + padding-left: 0.5em; + padding-right: 0.5em; + } + .consentlist-cell{ + background-color: #fbf9f9; + } +} + #identityVerificationContainer { padding: 2em 1.5em; margin: 1em auto; @@ -1276,6 +1292,9 @@ div.input-group.date { table.profile-audit-log { font-size: 0.7em; } + #profileConsentList { + font-size: 0.7em; + } .create-account-container { padding: 3.5em 3em; width: 90%; diff --git a/portal/templates/profile_create.html b/portal/templates/profile_create.html index 7b7b02e022..0c5509522a 100644 --- a/portal/templates/profile_create.html +++ b/portal/templates/profile_create.html @@ -10,16 +10,25 @@ #clinicalGroupLabel { margin-bottom: 1em; } +.required { + color: #a09f9f ; +}
-
+

{{ _("New User") }}