From 919ce3a80765ebb62748f4330599a8fd3e33878e Mon Sep 17 00:00:00 2001 From: Amy Chen Date: Tue, 15 Oct 2024 17:10:27 -0700 Subject: [PATCH 1/6] Fixup/table preference (#4411) Address a stream of 400 errors (CSRF token is missing) related to saving patientlist table preferences. Cause: I think the error stemmed from when an ajax request was first made to retrieve patient data, which is precedes by a call to save table preferences, the document DOM was not ready (which includes the value of CSRF token in an hidden element) - hence the missing CSRF token error. Fix: To add check for DOM readiness before the request to save table preferences is made. --------- Co-authored-by: Amy Chen --- portal/static/js/src/admin.js | 3 ++- portal/static/js/src/modules/TnthAjax.js | 10 ++++++++++ portal/templates/admin/admin_base.html | 2 ++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/portal/static/js/src/admin.js b/portal/static/js/src/admin.js index 8d757c186..391a5f218 100644 --- a/portal/static/js/src/admin.js +++ b/portal/static/js/src/admin.js @@ -1323,7 +1323,8 @@ let requestTimerId = 0; ) { var tnthAjax = this.getDependency("tnthAjax"); tableName = tableName || this.tableIdentifier; - if (!tableName) { + if (!tableName || !document.querySelector("#adminTable")) { + if (callback) callback(); return false; } userId = userId || this.userId; diff --git a/portal/static/js/src/modules/TnthAjax.js b/portal/static/js/src/modules/TnthAjax.js index f6cbad11d..72b46ff42 100644 --- a/portal/static/js/src/modules/TnthAjax.js +++ b/portal/static/js/src/modules/TnthAjax.js @@ -93,6 +93,16 @@ export default { /*global $ */ $.ajax("/api/me").done( function() { console.log("user authorized"); + if ((typeof CsrfTokenChecker !== "undefined") && + !CsrfTokenChecker.checkTokenValidity()) { + //if CSRF Token not valid, return error + if (callback) { + callback({"error": DEFAULT_SERVER_DATA_ERROR}); + fieldHelper.showError(targetField); + } + return; + } + ajaxCall(); } ).fail(function() { diff --git a/portal/templates/admin/admin_base.html b/portal/templates/admin/admin_base.html index 4d4db9f8a..94d851c85 100644 --- a/portal/templates/admin/admin_base.html +++ b/portal/templates/admin/admin_base.html @@ -93,6 +93,8 @@ // custom ajax request here function patientDataAjaxRequest(params) { loadIntervalId = setInterval(() => { + //document DOM not ready, don't make ajax call yet + if (!document.querySelector("#adminTable")) return; if (typeof window.AdminObj === "undefined") return; window.AdminObj.getRemotePatientListData(params); clearInterval(loadIntervalId); From 94843e6fde3ae346d9f1ab702b3dc7c1a6f98d32 Mon Sep 17 00:00:00 2001 From: Amy Chen Date: Tue, 22 Oct 2024 14:11:43 -0700 Subject: [PATCH 2/6] fix frontend error handing of patient list ajax error (#4415) accidentally merged [this](https://github.com/uwcirg/truenth-portal/pull/4413) into Master, reverted that. Will merge this into the `develop` branch. Co-authored-by: Amy Chen --- portal/static/js/src/admin.js | 20 ++++++++++++++++++-- portal/templates/admin/patients_by_org.html | 3 ++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/portal/static/js/src/admin.js b/portal/static/js/src/admin.js index 391a5f218..718b1d8a4 100644 --- a/portal/static/js/src/admin.js +++ b/portal/static/js/src/admin.js @@ -143,6 +143,12 @@ let requestTimerId = 0; errorElement.innerHTML = errorMessage; } }, + clearError: function() { + var errorElement = document.getElementById("admin-table-error-message"); + if (errorElement) { + errorElement.innerHTML = ""; + } + }, injectDependencies: function () { var self = this; window.portalModules = @@ -200,6 +206,8 @@ let requestTimerId = 0; ); return; } + //reset error + this.clearError(); this.patientDataAjaxRequest(params); }, patientDataAjaxRequest: function (params) { @@ -219,6 +227,12 @@ let requestTimerId = 0; } self.accessed = true; params.success(results); + }).fail(function(xhr, status) { + console.log("Error ", xhr); + console.log("status", status); + self.setError("Error occurred loading data."); + params.success([]); + self.accessed = true; }); }, handleCurrentUser: function () { @@ -1229,11 +1243,12 @@ let requestTimerId = 0; sync: true, }, function (data) { + prefData = data || self.getDefaultTablePreference(); + self.currentTablePreference = prefData; + if (!data || data.error) { return false; } - prefData = data || self.getDefaultTablePreference(); - self.currentTablePreference = prefData; if (setFilter) { //set filter values @@ -1303,6 +1318,7 @@ let requestTimerId = 0; for (var item in prefData.filters) { fname = "#adminTable .bootstrap-table-filter-control-" + item; if ($(fname).length === 0) { + prefData.filters[item] = null; continue; } //note this is based on the trigger event for filtering specify in the plugin diff --git a/portal/templates/admin/patients_by_org.html b/portal/templates/admin/patients_by_org.html index 6c0514d51..2465f98f5 100644 --- a/portal/templates/admin/patients_by_org.html +++ b/portal/templates/admin/patients_by_org.html @@ -65,7 +65,8 @@

{{_("Patient List")}}

-
+
+
{{ExportPopover()}} {{ajaxDataScript(research_study_id=0)}} From 07f5949d1848f85009de5ced38ab3b6ebe808062 Mon Sep 17 00:00:00 2001 From: pbugni Date: Thu, 24 Oct 2024 09:44:30 -0700 Subject: [PATCH 3/6] robust handling of unsortable patient list attributes (develop target) (#4419) Same PR as #4414, but this one correctly merges into develop. From d2c319dc7b1f7b60c329d047f324a86e6d28b6fa Mon Sep 17 00:00:00 2001 From: pbugni Date: Tue, 3 Dec 2024 12:13:53 -0800 Subject: [PATCH 4/6] IRONN-264 visit_month was being cached in translated form. (#4424) looking up visit_month now always returns english version, which may be cached. `translate_visit_month()` used by front end where needed. --------- Co-authored-by: Amy Chen --- portal/migrations/versions/4f5daa2b48db_.py | 24 +++++++++++++++++++ portal/models/qb_timeline.py | 4 ++-- portal/models/questionnaire_bank.py | 22 +++++++++++++---- portal/models/questionnaire_response.py | 3 ++- portal/static/js/src/admin.js | 3 ++- portal/templates/admin/admin_base.html | 1 + portal/templates/admin/patients_by_org.html | 2 +- portal/templates/admin/patients_substudy.html | 2 +- portal/views/patient.py | 4 ++-- portal/views/patients.py | 19 +++++++++++++-- 10 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 portal/migrations/versions/4f5daa2b48db_.py diff --git a/portal/migrations/versions/4f5daa2b48db_.py b/portal/migrations/versions/4f5daa2b48db_.py new file mode 100644 index 000000000..cef95eb30 --- /dev/null +++ b/portal/migrations/versions/4f5daa2b48db_.py @@ -0,0 +1,24 @@ +"""remove non english rows from report data (IRONN-264) + +Revision ID: 4f5daa2b48db +Revises: 5a300be640fb +Create Date: 2024-11-25 13:48:01.321510 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = '4f5daa2b48db' +down_revision = '5a300be640fb' + + +def upgrade(): + connection = op.get_bind() + connection.execute("DELETE FROM research_data WHERE NOT (data->>'timepoint' ~ '^(Baseline|Month)');") + # next run of `cache_research_data()` will pick up those just deleted. + + +def downgrade(): + # no point in bringing those back + pass diff --git a/portal/models/qb_timeline.py b/portal/models/qb_timeline.py index a35d04635..81c129222 100644 --- a/portal/models/qb_timeline.py +++ b/portal/models/qb_timeline.py @@ -1316,8 +1316,8 @@ def qb_status_visit_name(user_id, research_study_id, as_of_date): :returns: dictionary with key/values for: status: string like 'expired' - visit_name: for the period, i.e. '3 months' - action_state: 'not applicable', or status of follow up action + visit_name: for the period, i.e. '3 months'. ALWAYS in english, clients must translate + action_state: 'not applicable', or status of follow-up action """ from .research_study import EMPRO_RS_ID diff --git a/portal/models/questionnaire_bank.py b/portal/models/questionnaire_bank.py index c964f3c82..6a0e2822b 100644 --- a/portal/models/questionnaire_bank.py +++ b/portal/models/questionnaire_bank.py @@ -592,6 +592,10 @@ def qbs_by_rp(rp_id, classification): def visit_name(qbd): + """returns string repr of visit, i.e. 'Month 3' or 'Baseline' + + NB - only returns english version. See `translate_visit_name()` + """ from .research_study import ( EMPRO_RS_ID, research_study_id_from_questionnaire, @@ -617,12 +621,22 @@ def visit_name(qbd): clm += (clrd.years * 12) if clrd.years else 0 total = clm * qbd.iteration + sm if rs_id == EMPRO_RS_ID: - return _('Month %(month_total)d', month_total=total+1) - return _('Month %(month_total)d', month_total=total) + return f'Month {total+1}' + return f'Month {total}' if rs_id == EMPRO_RS_ID: - return _('Month %(month_total)d', month_total=1) - return _(qbd.questionnaire_bank.classification.title()) + return 'Month 1' + return qbd.questionnaire_bank.classification.title() + + +def translate_visit_name(visit_name): + """parse the english version of visit name for front end translation needs""" + if not visit_name: + return visit_name + if visit_name.startswith('Month '): + number = int(visit_name[6:]) + return _('Month %(month_total)d', month_total=number) + return _(visit_name) def add_static_questionnaire_bank(): diff --git a/portal/models/questionnaire_response.py b/portal/models/questionnaire_response.py index bf52df00b..85bfe34fd 100644 --- a/portal/models/questionnaire_response.py +++ b/portal/models/questionnaire_response.py @@ -28,6 +28,7 @@ QuestionnaireBank, QuestionnaireBankQuestionnaire, trigger_date, + translate_visit_name, visit_name, ) from .research_data import ResearchData @@ -401,7 +402,7 @@ def extensions(self): relative_start=None, iteration=self.qb_iteration, recur_id=recur_id, qb_id=self.questionnaire_bank_id) results.append({ - 'visit_name': visit_name(qbd), + 'visit_name': translate_visit_name(visit_name(qbd)), 'url': TRUENTH_VISIT_NAME_EXTENSION}) expires_at = expires(self.subject_id, qbd) diff --git a/portal/static/js/src/admin.js b/portal/static/js/src/admin.js index 718b1d8a4..f2704e6f5 100644 --- a/portal/static/js/src/admin.js +++ b/portal/static/js/src/admin.js @@ -608,6 +608,7 @@ let requestTimerId = 0; this.filterOptionsList.forEach((o) => { for (const [key, values] of Object.entries(o)) { values.forEach((value) => { + if (!value[1]) return true; if ( $( `#adminTable .bootstrap-table-filter-control-${key} option[value='${value[0]}']` @@ -1412,7 +1413,7 @@ let requestTimerId = 0; max_attempts: 1, }, function (result) { - if (!result?.error) self.currentTablePreference = data; + if (result && !result.error) self.currentTablePreference = data; if (callback) callback(); } ); diff --git a/portal/templates/admin/admin_base.html b/portal/templates/admin/admin_base.html index 94d851c85..2c8375ccd 100644 --- a/portal/templates/admin/admin_base.html +++ b/portal/templates/admin/admin_base.html @@ -107,5 +107,6 @@ // placeholder variables var qStatusFilterOptions = {}; var clinicianActionStateFilterOptions = {}; + var visitOptions = {}; {%- endmacro -%} diff --git a/portal/templates/admin/patients_by_org.html b/portal/templates/admin/patients_by_org.html index 2465f98f5..a86c496bb 100644 --- a/portal/templates/admin/patients_by_org.html +++ b/portal/templates/admin/patients_by_org.html @@ -56,7 +56,7 @@

{{_("Patient List")}}

{{ _("Email") }} {% if 'status' in config.PATIENT_LIST_ADDL_FIELDS %} {{ _("Questionnaire Status") }} - {{ _("Visit") }} + {{ _("Visit") }} {% endif %} {% if 'study_id' in config.PATIENT_LIST_ADDL_FIELDS %}{{ _("Study ID") }}{% endif %} {{ _("Study Consent Date") }} diff --git a/portal/templates/admin/patients_substudy.html b/portal/templates/admin/patients_substudy.html index aa54240fc..3d6ab43b9 100644 --- a/portal/templates/admin/patients_substudy.html +++ b/portal/templates/admin/patients_substudy.html @@ -59,7 +59,7 @@

{{list_title}}

{{ _("Date of Birth") }} {{ _("Treating Clinician") }} {{_("EMPRO Questionnaire Status")}} - {{ _("Visit") }} + {{ _("Visit") }} {{ _("Clinician Action Status") }} {{ _("Study ID") }} {{ _("Study Consent Date") }} diff --git a/portal/views/patient.py b/portal/views/patient.py index 5d43fdbbe..48c4e3e84 100644 --- a/portal/views/patient.py +++ b/portal/views/patient.py @@ -328,7 +328,7 @@ def patient_timeline(patient_id): ) from ..models.qbd import QBD from ..models.qb_status import QB_Status - from ..models.questionnaire_bank import visit_name + from ..models.questionnaire_bank import translate_visit_name, visit_name from ..models.questionnaire_response import aggregate_responses from ..models.research_protocol import ResearchProtocol from ..tasks import cache_single_patient_adherence_data @@ -392,7 +392,7 @@ def patient_timeline(patient_id): 'at': FHIR_datetime.as_fhir(qbt.at), 'qb (id, iteration)': "{} ({}, {})".format( qbd.questionnaire_bank.name, qbd.qb_id, qbd.iteration), - 'visit': visit_name(qbd)} + 'visit': translate_visit_name(visit_name(qbd))} if qbt.status == OverallStatus.due: data['questionnaires'] = ','.join( [q.name for q in qbd.questionnaire_bank.questionnaires]) diff --git a/portal/views/patients.py b/portal/views/patients.py index 3d1d97b4e..5a83f29ed 100644 --- a/portal/views/patients.py +++ b/portal/views/patients.py @@ -17,6 +17,7 @@ from ..models.intervention import Intervention from ..models.organization import Organization, OrgTree from ..models.patient_list import PatientList +from ..models.questionnaire_bank import translate_visit_name from ..models.qb_status import patient_research_study_status from ..models.role import ROLE from ..models.research_study import EMPRO_RS_ID, ResearchStudy @@ -181,11 +182,25 @@ def requested_orgs(user, research_study_id): distinct_action = PatientList.query.distinct(PatientList.action_state).with_entities( PatientList.action_state) options.append({"action_state": [(state[0], _(state[0])) for state in distinct_action]}) + distinct_visits = PatientList.query.distinct(PatientList.empro_visit).with_entities( + PatientList.empro_visit) + sorted_visits = sorted( + [v[0] for v in distinct_visits if v[0]], + key=lambda x: (0 if not x.split()[-1].isdigit() else int(x.split()[-1])) + ) + options.append({"empro_visit": [(visit, translate_visit_name(visit)) for visit in sorted_visits]}) else: distinct_status = PatientList.query.distinct( PatientList.questionnaire_status).with_entities(PatientList.questionnaire_status) options.append( {"questionnaire_status": [(status[0], _(status[0])) for status in distinct_status]}) + distinct_visits = PatientList.query.distinct(PatientList.visit).with_entities( + PatientList.visit) + sorted_visits = sorted( + [v[0] for v in distinct_visits if v[0]], + key=lambda x: (0 if not x.split()[-1].isdigit() else int(x.split()[-1])) + ) + options.append({"visit": [(visit, translate_visit_name(visit)) for visit in sorted_visits]}) viewable_orgs = requested_orgs(user, research_study_id) query = PatientList.query.filter(PatientList.org_id.in_(viewable_orgs)) @@ -222,8 +237,8 @@ def requested_orgs(user, research_study_id): "questionnaire_status": _(row.questionnaire_status), "empro_status": _(row.empro_status), "action_state": _(row.action_state), - "visit": row.visit, - "empro_visit": row.empro_visit, + "visit": translate_visit_name(row.visit), + "empro_visit": translate_visit_name(row.empro_visit), "study_id": row.study_id, "consentdate": row.consentdate, "empro_consentdate": row.empro_consentdate, From 55bd1c8e9fa77da9f953f12d571c1507826bef32 Mon Sep 17 00:00:00 2001 From: pbugni Date: Thu, 12 Dec 2024 12:15:15 -0800 Subject: [PATCH 5/6] IRONN-242 update adherence cache on any patient demographics changes. (#4427) Updates to a user's study-id via a PUT to /demographics would leave old adherence data rows with the old study-id. We now force a recalculation of a user's adherence data when PUT /demographics is called. --- portal/models/reporting.py | 16 ++++++++++++++-- portal/views/demographics.py | 8 ++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/portal/models/reporting.py b/portal/models/reporting.py index 9db76b46b..d42f7319c 100644 --- a/portal/models/reporting.py +++ b/portal/models/reporting.py @@ -6,7 +6,6 @@ from flask import current_app from flask_babel import force_locale -from flask_login import login_manager from werkzeug.exceptions import Unauthorized from ..audit import auditable_event @@ -30,12 +29,25 @@ qnr_csv_column_headers, generate_qnr_csv, ) -from .research_study import BASE_RS_ID, EMPRO_RS_ID +from .research_study import BASE_RS_ID, EMPRO_RS_ID, ResearchStudy from .role import ROLE, Role from .user import User, UserRoles, patients_query from .user_consent import consent_withdrawal_dates +def update_patient_adherence_data(patient_id): + """Cache invalidation and force rebuild for given patient's adherence data + + NB - any timeline or questionnaire response data changes are invalidated and + updated as part of `invalidate_users_QBT()`. This function is for edge cases + such as changing a user's study-id. + """ + patient = User.query.get(patient_id) + AdherenceData.query.filter(AdherenceData.patient_id==patient_id).delete() + for rs_id in ResearchStudy.assigned_to(patient): + single_patient_adherence_data(patient_id=patient_id, research_study_id=rs_id) + + def single_patient_adherence_data(patient_id, research_study_id): """Update any missing (from cache) adherence data for patient diff --git a/portal/views/demographics.py b/portal/views/demographics.py index 962d52062..640a43f54 100644 --- a/portal/views/demographics.py +++ b/portal/views/demographics.py @@ -7,7 +7,9 @@ from ..database import db from ..extensions import oauth from ..models.reference import MissingReference +from ..models.reporting import update_patient_adherence_data from ..models.user import current_user, get_user +from ..models.role import ROLE from .crossdomain import crossdomain demographics_api = Blueprint('demographics_api', __name__, url_prefix='/api') @@ -176,7 +178,9 @@ def demographics_set(patient_id): patient_id, json.dumps(request.json)), user_id=current_user().id, subject_id=patient_id, context='user') - # update the patient_table cache with any change from above - patient_list_update_patient(patient_id) + # update the respective cache tables with any change from above + if patient.has_role(ROLE.PATIENT.value): + patient_list_update_patient(patient_id) + update_patient_adherence_data(patient_id) return jsonify(patient.as_fhir(include_empties=False)) From dd7fc3a55aa06cd9337672ebbfd04f031a04f369 Mon Sep 17 00:00:00 2001 From: pbugni Date: Thu, 12 Dec 2024 12:16:10 -0800 Subject: [PATCH 6/6] TN-2747 look for indefinite QNR in both v3 and v5. (#4426) Fresh error log details lead to discovering this long outstanding symptom. Users leaving an unfinished, `in-progress` indefinite QNR from an older research protocol, then returning after their organization has transitioned protocols would raise an exception, as the lookup was too specific. --- portal/models/questionnaire_response.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/portal/models/questionnaire_response.py b/portal/models/questionnaire_response.py index 85bfe34fd..e24ad500d 100644 --- a/portal/models/questionnaire_response.py +++ b/portal/models/questionnaire_response.py @@ -910,11 +910,16 @@ def qnr_document_id( QuestionnaireResponse.subject_id == subject_id).filter( QuestionnaireResponse.document[ ('questionnaire', 'reference') - ].astext.endswith(questionnaire_name)).filter( - QuestionnaireResponse.questionnaire_bank_id == - questionnaire_bank_id).with_entities( + ].astext.endswith(questionnaire_name)).with_entities( QuestionnaireResponse.document[( 'identifier', 'value')]) + if questionnaire_name != 'irondemog_v3': + # Another special indefinite workaround. irondemog_v3 happens to live + # in multiple questionnaire banks, thus the lookup will fail when + # restricted by QB.id, should the org have transitioned since the user + # left work incomplete from the previous protocol (TN-2747) + qnr = qnr.filter(QuestionnaireResponse.questionnaire_bank_id == questionnaire_bank_id) + if iteration is not None: qnr = qnr.filter(QuestionnaireResponse.qb_iteration == iteration) else: