This email was sent to you because you are a patient at (clinic name) and consented to participate in the Prostate Cancer Outcomes - (parent org) Registry Study.
This is an invitation to use the TrueNTH website, where you will report on your health. Your participation will help us collectively improve the care that men receive during their prostate cancer journey.
To complete your first questionnaire, please first verify your account.
{{_("To help address any issues, we've informed your care team and they'll be in contact with you soon.")}}
{% if organization %}
{{_("In the meantime, if you have any questions or need assistance, please contact your team at %(organization)s directly. They're happy to help.", organization=organization)}}
+ {{_("About your preference to be contacted by your care team for these ongoing issues.")}}
+
+
+
+
{{_("We’ve noticed you’re continuing to experience challenges with the issues listed below.")}}
+
+ {{_("If you prefer not to be contacted by your care team for any (or all) of these issues, please check the box(es) below.")}}
+
+
+ {{_("Your care team will continue to contact you as usual for other identified issues.")}}
+ {{_("If you have any questions or need assistance, please contact your team directly at %(organization)s. They'll be happy to help.", organization=user.organizations[0].name if user else "")}}
+
+
+
{{_("Please do not contact me about:")}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+{%- endmacro -%}
{%- macro empro_script() -%}
{%- endmacro -%}
diff --git a/portal/migrations/versions/80c3b1e96c45_.py b/portal/migrations/versions/80c3b1e96c45_.py
index fee8ec0787..6591c0874a 100644
--- a/portal/migrations/versions/80c3b1e96c45_.py
+++ b/portal/migrations/versions/80c3b1e96c45_.py
@@ -1,7 +1,7 @@
"""Add sequential hard trigger count to EMPRO trigger_states.triggers domains.
Revision ID: 80c3b1e96c45
-Revises: 2e9b9e696bb8
+Revises: 5caf794c70a7
Create Date: 2023-07-24 17:08:35.128975
"""
@@ -9,6 +9,8 @@
from copy import deepcopy
from alembic import op
from io import StringIO
+from flask import current_app
+import logging
from sqlalchemy.orm import sessionmaker
from portal.database import db
from portal.trigger_states.empro_domains import (
@@ -19,12 +21,48 @@
# revision identifiers, used by Alembic.
revision = '80c3b1e96c45'
-down_revision = '2e9b9e696bb8'
+down_revision = '5caf794c70a7'
Session = sessionmaker()
+log = logging.getLogger("alembic.runtime.migration")
+log.setLevel(logging.DEBUG)
+
+
+def validate_users_trigger_states(session, patient_id):
+ """Confirm user has sequential visits in trigger states table.
+
+ Due to allowance of moving EMPRO consents and no previous checks,
+ some users on test have invalid overlapping trigger states rows.
+ """
+ ts_rows = session.query(TriggerState).filter(
+ TriggerState.user_id == patient_id).order_by(TriggerState.id)
+ month_counter = -1
+ for row in ts_rows:
+ if row.state == 'due':
+ # skipping months is okay, but every due should be sequentially greater than previous
+ if month_counter >= row.visit_month:
+ raise ValueError(f"{patient_id} expected month > {month_counter}, got {row.visit_month}")
+ month_counter = row.visit_month
+ else:
+ # states other than 'due' should be grouped together with same visit_month
+ if month_counter != row.visit_month:
+ raise ValueError(f"{patient_id} expected month {month_counter}, got {row.visit_month}")
+
+def purge_trigger_states(session, patient_id):
+ """Clean up test system problems from moving consent dates"""
+ log.info(f"Purging trigger states for {patient_id}")
+ session.query(TriggerState).filter(TriggerState.user_id == patient_id).delete()
+
def upgrade():
+ # Add sequential counts to appropriate trigger_states rows.
+
+ # this migration was applied once before, but the code wasn't correctly
+ # maintaining the sequential counts. start by removing all for a clean
+ # slate via the same `downgrade()` step
+ downgrade()
+
# for each active EMPRO patient with at least 1 hard triggered domain,
# walk through their monthly reports, adding the sequential count for
# the opt-out feature.
@@ -42,21 +80,31 @@ def upgrade():
# can't just send through current process, as it'll attempt to
# insert undesired rows in the trigger_states table. need to
# add the sequential count to existing rows.
+ try:
+ validate_users_trigger_states(session, pid)
+ except ValueError as e:
+ if current_app.config.get('SYSTEM_TYPE') in ('development', 'testing'):
+ purge_trigger_states(session, pid)
+ continue
+ else:
+ raise e
+
output.write(f"\n\nPatient: {pid} storing all zeros for sequential hard triggers except:\n")
output.write(" (visit month : domain : # hard sequential)\n")
sequential_by_domain = defaultdict(list)
trigger_states = db.session.query(TriggerState).filter(
TriggerState.user_id == pid).filter(
- TriggerState.state == "resolved").order_by(
+ TriggerState.state.in_(("resolved", "triggered", "processed"))).order_by(
TriggerState.timestamp.asc())
for ts in trigger_states:
improved_triggers = deepcopy(ts.triggers)
for d in EMPRO_DOMAINS:
sequential_hard_for_this_domain = 0
if d not in improved_triggers["domain"]:
- # only seen on test, fill in the missing domain
- print(f"missing {d} in {pid}:{ts.visit_month}?")
- improved_triggers["domain"][d] = {}
+ # shouldn't happen, SDC typically includes all domains
+ # but a few records are lacking
+ log.warning(f"{pid} missing domain {d} in {ts.visit_month} response")
+ continue
if any(v == "hard" for v in improved_triggers["domain"][d].values()):
sequential_by_domain[d].append(ts.visit_month)
@@ -77,8 +125,40 @@ def upgrade():
output.write(f"{k}: {v}; ")
db.session.commit()
- print(output.getvalue())
+ # print(output.getvalue()) # useful for debugging, too noisy
def downgrade():
- pass # no value in removing
+ # for each active EMPRO patient with at least 1 hard triggered domain,
+ # remove any sequential counts found
+ bind = op.get_bind()
+ session = Session(bind=bind)
+
+ patient_ids = []
+ for patient_id in session.execute(
+ "SELECT DISTINCT(user_id) FROM trigger_states JOIN users"
+ " ON users.id = user_id WHERE deleted_id IS NULL"):
+ patient_ids.append(patient_id[0])
+
+ output = StringIO()
+ for pid in patient_ids:
+ output.write(f"\n\nPatient: {pid}\n")
+ trigger_states = db.session.query(TriggerState).filter(
+ TriggerState.user_id == pid).filter(
+ TriggerState.state.in_(("resolved", "triggered", "processed"))).order_by(
+ TriggerState.timestamp.asc())
+ for ts in trigger_states:
+ improved_triggers = deepcopy(ts.triggers)
+ for d in EMPRO_DOMAINS:
+ if d not in improved_triggers["domain"]:
+ log.warning(f"{d} missing from {ts.id}(month: {ts.visit_month}) for {pid}")
+ continue
+ if sequential_hard_trigger_count_key in improved_triggers["domain"][d]:
+ del improved_triggers["domain"][d][sequential_hard_trigger_count_key]
+ output.write(f" removed sequential from {ts.visit_month}:{d} {improved_triggers['domain'][d]}\n")
+
+ # retain triggers now containing sequential counts
+ ts.triggers = improved_triggers
+
+ db.session.commit()
+ # print(output.getvalue()) # useful for debugging, too noisy
diff --git a/portal/migrations/versions/cf586ed4f043_.py b/portal/migrations/versions/cf586ed4f043_.py
new file mode 100644
index 0000000000..d64d90e52b
--- /dev/null
+++ b/portal/migrations/versions/cf586ed4f043_.py
@@ -0,0 +1,26 @@
+"""add missing constraint to trigger_states table
+
+Revision ID: cf586ed4f043
+Revises: 80c3b1e96c45
+Create Date: 2024-03-20 17:17:39.403806
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'cf586ed4f043'
+down_revision = '80c3b1e96c45'
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_unique_constraint('_trigger_states_user_state_visit_month', 'trigger_states', ['user_id', 'state', 'visit_month'])
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_constraint('_trigger_states_user_state_visit_month', 'trigger_states', type_='unique')
+ # ### end Alembic commands ###
diff --git a/portal/migrations/versions/d1f3ed8d16ef_.py b/portal/migrations/versions/d1f3ed8d16ef_.py
index 133639bf08..69d49e0d36 100644
--- a/portal/migrations/versions/d1f3ed8d16ef_.py
+++ b/portal/migrations/versions/d1f3ed8d16ef_.py
@@ -1,7 +1,7 @@
"""remove Zulu locale
Revision ID: d1f3ed8d16ef
-Revises: 80c3b1e96c45
+Revises: 2e9b9e696bb8
Create Date: 2023-12-05 14:09:10.442328
"""
@@ -11,7 +11,7 @@
# revision identifiers, used by Alembic.
revision = 'd1f3ed8d16ef'
-down_revision = '80c3b1e96c45'
+down_revision = '2e9b9e696bb8'
def upgrade():
diff --git a/portal/models/qb_status.py b/portal/models/qb_status.py
index 733093226e..f69dfb3ba1 100644
--- a/portal/models/qb_status.py
+++ b/portal/models/qb_status.py
@@ -520,7 +520,8 @@ def warn_on_duplicate_request(self, requested_set):
f" {requested_indef} already!")
-def patient_research_study_status(patient, ignore_QB_status=False):
+def patient_research_study_status(
+ patient, ignore_QB_status=False, as_of_date=None, skip_initiate=False):
"""Returns details regarding patient readiness for available studies
Wraps complexity of checking multiple QB_Status and ResearchStudy
@@ -532,6 +533,8 @@ def patient_research_study_status(patient, ignore_QB_status=False):
:param patient: subject to check
:param ignore_QB_status: set to prevent recursive call, if used during
process of evaluating QB_status. Will restrict results to eligible
+ :param as_of_date: set to check status at alternative time
+ :param skip_initiate: set only when rebuilding to avoid state change
:returns: dictionary of applicable studies keyed by research_study_id.
Each contains a dictionary with keys:
- eligible: set True if assigned to research study and pre-requisites
@@ -546,7 +549,8 @@ def patient_research_study_status(patient, ignore_QB_status=False):
"""
from datetime import datetime
from .research_study import EMPRO_RS_ID, ResearchStudy
- as_of_date = datetime.utcnow()
+ if as_of_date is None:
+ as_of_date = datetime.utcnow()
results = {}
# check studies in required order - first found with pending work
@@ -589,7 +593,7 @@ def patient_research_study_status(patient, ignore_QB_status=False):
rs_status['ready'] = True
# Apply business rules specific to EMPRO
- if rs == EMPRO_RS_ID:
+ if rs == EMPRO_RS_ID and 0 in results:
if results[0]['ready']:
# Clear ready status when base has pending work
rs_status['ready'] = False
@@ -601,7 +605,8 @@ def patient_research_study_status(patient, ignore_QB_status=False):
elif rs_status['ready']:
# As user may have just entered ready status on EMPRO
# move trigger_states.state to due
- from ..trigger_states.empro_states import initiate_trigger
- initiate_trigger(patient.id)
+ if not skip_initiate:
+ from ..trigger_states.empro_states import initiate_trigger
+ initiate_trigger(patient.id)
return results
diff --git a/portal/models/qb_timeline.py b/portal/models/qb_timeline.py
index e75954ec53..3287f77829 100644
--- a/portal/models/qb_timeline.py
+++ b/portal/models/qb_timeline.py
@@ -268,7 +268,7 @@ def calc_and_adjust_start(user, research_study_id, qbd, initial_trigger):
delta = users_trigger - initial_trigger
# this case should no longer be possible; raise the alarm
- raise RuntimeError("found initial trigger to differ by: %s", str(delta))
+ raise RuntimeError("found user(%d) initial trigger to differ by: %s", user.id, str(delta))
current_app.logger.debug("calc_and_adjust_start delta: %s", str(delta))
return qbd.relative_start + delta
@@ -374,7 +374,8 @@ def qbds_for_rp(rp, classification, trigger_date):
)
if curRPD.retired == nextRPD.retired:
raise ValueError(
- "Invalid state: multiple RPs w/ same retire date")
+ "Invalid state: multiple RPs w/ same retire date: "
+ f"{next_rp} : {curRPD.retired}")
else:
nextRPD = None
yield curRPD, nextRPD
@@ -812,7 +813,11 @@ def int_or_none(value):
for row in qbt_rows:
# Confirm expected order
if last_at:
- assert row.at >= last_at
+ if last_at > row.at:
+ raise ValueError(
+ f"patient {row.user_id} has overlapping qb_timeline rows"
+ f" {last_at} and {row.at}"
+ )
key = f"{row.qb_id}:{row.qb_iteration}"
if previous_key and previous_key != key:
@@ -975,11 +980,9 @@ def attempt_update(user_id, research_study_id, invalidate_existing):
if (
pending_qbts[j].qb_id != remove_qb_id or
pending_qbts[j].qb_iteration != remove_iteration):
- # To qualify for this special case,
- # having worked back to previous QB, if
- # at > start, take action
- if pending_qbts[j].at > start:
- unwanted_count = len(pending_qbts)-j-1
+ # unwanted_count represents all rows from
+ # overlapped, unwanted visit
+ unwanted_count = len(pending_qbts)-j-1
break
# keep a lookout for work done in old RP
diff --git a/portal/models/questionnaire_response.py b/portal/models/questionnaire_response.py
index 0353ffa6f8..faaa0a2f38 100644
--- a/portal/models/questionnaire_response.py
+++ b/portal/models/questionnaire_response.py
@@ -31,7 +31,7 @@
trigger_date,
visit_name,
)
-from .research_study import research_study_id_from_questionnaire
+from .research_study import EMPRO_RS_ID, research_study_id_from_questionnaire
from .reference import Reference
from .user import User, current_user, patients_query
from .user_consent import consent_withdrawal_dates
@@ -110,6 +110,7 @@ def assign_qb_relationship(self, acting_user_id, qbd_accessor=None):
# special case for the EMPRO Staff QB
from ..trigger_states.empro_states import empro_staff_qbd_accessor
qbd_accessor = empro_staff_qbd_accessor(self)
+ research_study_id = EMPRO_RS_ID
elif qbd_accessor is None:
from .qb_status import QB_Status # avoid cycle
if self.questionnaire_bank is not None:
diff --git a/portal/models/reporting.py b/portal/models/reporting.py
index 6292f383e8..eb916b27a3 100644
--- a/portal/models/reporting.py
+++ b/portal/models/reporting.py
@@ -48,6 +48,11 @@ def single_patient_adherence_data(patient_id, research_study_id):
:returns: number of added rows
"""
+ # ignore non-patient requests
+ patient = User.query.get(patient_id)
+ if not patient.has_role(ROLE.PATIENT.value):
+ return
+
as_of_date = datetime.utcnow()
cache_moderation = CacheModeration(key=ADHERENCE_DATA_KEY.format(
patient_id=patient_id,
@@ -134,15 +139,19 @@ def empro_row_detail(row, ts_reporting):
report_format(
ts_reporting.resolution_authored_from_visit(visit_month))
or "")
+ row['delayed_by_holiday'] = (
+ ts_reporting.resolution_delayed_by_holiday(visit_month) or ""
+ )
ht = ts_reporting.hard_triggers_for_visit(visit_month)
row['hard_trigger_domains'] = ', '.join(ht) if ht else ""
+ oo = ts_reporting.opted_out_domains_for_visit(visit_month)
+ row['opted_out_domains'] = ', '.join(oo) if oo else ""
st = ts_reporting.soft_triggers_for_visit(visit_month)
row['soft_trigger_domains'] = ', '.join(st) if st else ""
da = ts_reporting.domains_accessed(visit_month)
row['content_domains_accessed'] = ', '.join(da) if da else ""
added_rows = 0
- patient = User.query.get(patient_id)
qb_stats = QB_Status(
user=patient,
research_study_id=research_study_id,
@@ -443,10 +452,12 @@ def patient_generator():
'EMPRO_questionnaire_completion_date',
'soft_trigger_domains',
'hard_trigger_domains',
+ 'opted_out_domains',
'content_domains_accessed',
'clinician',
'clinician_status',
'clinician_survey_completion_date',
+ 'delayed_by_holiday',
]
return results
diff --git a/portal/models/research_study.py b/portal/models/research_study.py
index bea98b14c6..3b56a37529 100644
--- a/portal/models/research_study.py
+++ b/portal/models/research_study.py
@@ -1,3 +1,4 @@
+from datetime import datetime
from sqlalchemy.dialects.postgresql import ENUM
from ..cache import TWO_HOURS, cache
@@ -122,3 +123,17 @@ def add_static_research_studies():
rs = ResearchStudy.from_fhir(base)
if ResearchStudy.query.get(rs.id) is None:
db.session.add(rs)
+
+
+def withdrawn_from_research_study(patient_id, research_study_id):
+ """Check for withdrawn row in patients timeline
+
+ :returns: If withdrawn row found, returns withdrawal date, else None
+ """
+ from .qb_timeline import QBT
+ from .overall_status import OverallStatus
+
+ withdrawn = QBT.query.filter(QBT.user_id == patient_id).filter(
+ QBT.research_study_id == research_study_id).filter(
+ QBT.status == OverallStatus.withdrawn).first()
+ return withdrawn.at if withdrawn else None
diff --git a/portal/models/user.py b/portal/models/user.py
index 517c33ebc6..47591843e2 100644
--- a/portal/models/user.py
+++ b/portal/models/user.py
@@ -142,6 +142,7 @@ def permanently_delete_user(
"Contradicting username and user_id values given")
def purge_user(user, acting_user):
+ from ..trigger_states.models import TriggerState
if not user:
raise ValueError("No such user: {}".format(username))
if acting_user.id == user.id:
@@ -158,6 +159,17 @@ def purge_user(user, acting_user):
for t in tous:
db.session.delete(t)
+ TriggerState.query.filter(TriggerState.user_id == user.id).delete()
+
+ # possible this user generated a temp user for auth flows - that
+ # user's deleted audit record holds a key to the user being purged.
+ # update to that user id
+ auds = Audit.query.filter(Audit.user_id == user.id).filter(
+ Audit.subject_id != user.id)
+ for a in auds:
+ if a.comment and a.comment.startswith("marking deleted user"):
+ a.user_id = a.subject_id
+
# the rest should die on cascade rules
db.session.delete(user)
db.session.commit()
diff --git a/portal/static/js/src/components/LongitudinalReport.vue b/portal/static/js/src/components/LongitudinalReport.vue
index 66d4284c02..7ce578c225 100644
--- a/portal/static/js/src/components/LongitudinalReport.vue
+++ b/portal/static/js/src/components/LongitudinalReport.vue
@@ -4,34 +4,44 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ <
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{item.code[0].display}}
+ {{item.text}}
+
+
+
+
+
+
+
+
- <
- >
-
-
-
-
-
-
-
-
-
-
-
- {{item.code[0].display}}
- {{item.text}}
-
-
-
-
-
-
-
-
@@ -39,7 +49,11 @@
import AssessmentReportData from "../data/common/AssessmentReportData.js";
import tnthDates from "../modules/TnthDate.js";
import SYSTEM_IDENTIFIER_ENUM from "../modules/SYSTEM_IDENTIFIER_ENUM";
- import {EMPRO_TRIGGER_PROCCESSED_STATES} from "../data/common/consts.js";
+ import {
+ EMPRO_TRIGGER_STATE_OPTOUT_KEY,
+ EMPRO_TRIGGER_PROCCESSED_STATES
+ } from "../data/common/consts.js";
+ let resizeVisIntervalId = 0;
export default {
data () {
return {
@@ -99,9 +113,7 @@
/*
* display column(s) responsively based on viewport width
*/
- if (bodyWidth >= 1400) {
- this.maxToShow = 4;
- } else if (bodyWidth >= 992) {
+ if (bodyWidth >= 992) {
this.maxToShow = 3;
} else if (bodyWidth >= 699) {
this.maxToShow = 2;
@@ -110,12 +122,15 @@
}
return;
},
+ shouldHideNav() {
+ return this.questionnaireDates.length <= 1;
+ },
setNavIndexes() {
/*
* set initial indexes for start and end navigation buttons
*/
- this.navEndIndex = this.maxToShow >= this.questionnaireDates.length ? this.questionnaireDates.length: this.maxToShow;
- this.navStartIndex = 1;
+ this.navEndIndex = this.questionnaireDates.length > 0 ? this.questionnaireDates.length : 1;
+ this.navStartIndex = this.navEndIndex - this.maxToShow + 1;
},
setGoForward() {
/*
@@ -201,6 +216,7 @@
if (!Object.keys(item.triggers.domain[domain]).length) {
continue;
}
+ const hasOptOut = item.triggers.domain[domain][EMPRO_TRIGGER_STATE_OPTOUT_KEY];
for (let q in item.triggers.domain[domain]) {
if (!item.triggers.source || !item.triggers.source.authored) {
continue;
@@ -211,7 +227,8 @@
if (item.triggers.domain[domain][q] === "hard") {
self.triggerData.hardTriggers.push({
"authored": item.triggers.source.authored,
- "questionLinkId": q
+ "questionLinkId": q,
+ "optOut": hasOptOut
});
}
/*
@@ -226,6 +243,7 @@
}
}
});
+ console.log("trigger data: ", self.triggerData);
},
hasTriggers() {
return this.hasSoftTriggers() || this.hasHardTriggers();
@@ -236,6 +254,9 @@
hasHardTriggers() {
return this.triggerData.hardTriggers.length;
},
+ hasOptOutTriggers() {
+ return this.triggerData.hardTriggers.find((item) => item.optOut);
+ },
hasInProgressData() {
return this.assessmentData.filter(item => {
return String(item.status).toLowerCase() === "in-progress";
@@ -279,7 +300,7 @@
let hardTriggers = $.grep(this.triggerData.hardTriggers, subitem => {
let timeStampComparison = new Date(subitem.authored).toLocaleString() === new Date(authoredDate).toLocaleString();
let linkIdComparison = subitem.questionLinkId === entry.linkId;
- return timeStampComparison && linkIdComparison
+ return !subitem.optOut && timeStampComparison && linkIdComparison
});
let softTriggers = $.grep(this.triggerData.softTriggers, subitem => {
@@ -287,6 +308,12 @@
let linkIdComparison = subitem.questionLinkId === entry.linkId;
return timeStampComparison && linkIdComparison;
});
+
+ let optedOutTriggers = $.grep(this.triggerData.hardTriggers, subitem => {
+ let timeStampComparison = new Date(subitem.authored).toLocaleString() === new Date(authoredDate).toLocaleString();
+ let linkIdComparison = subitem.questionLinkId === entry.linkId;
+ return subitem.optOut && timeStampComparison && linkIdComparison
+ });
/*
* using valueCoding.code for answer and linkId for question if BOTH question and answer are empty strings
@@ -301,15 +328,17 @@
let optionsLength = this.getQuestionOptions(entry.linkId);
let answerObj = {
q: q,
- a: a + (hardTriggers.length?" **": (softTriggers.length?" *": "")),
+ a: a + (hardTriggers.length?" **": ((optedOutTriggers.length || softTriggers.length)?" *": (optedOutTriggers.length? " ⓘ":""))),
linkId: entry.linkId,
value: answerValue,
- cssClass:
- //last
- answerValue >= optionsLength.length ? "darkest" :
- //penultimate
- (answerValue >= optionsLength.length - 1 ? "darker":
- (answerValue <= 1 ? "no-value": ""))
+ cssClass: (
+ answerValue >= optionsLength.length ?
+ "darkest" :
+ //penultimate
+ answerValue >= optionsLength.length - 1 ?
+ "darker" :
+ (answerValue <= 1 ? "no-value": "")
+ )
};
this.data[index].data.push(answerObj);
let currentDomain = "";
@@ -340,7 +369,11 @@
}
$(window).on("resize", () => {
window.requestAnimationFrame(() => {
- this.setInitVis();
+ if (this.shouldHideNav()) return;
+ clearTimeout(resizeVisIntervalId);
+ resizeVisIntervalId = setTimeout(() => {
+ this.setInitVis();
+ }, 250);
});
});
},
diff --git a/portal/static/js/src/data/common/consts.js b/portal/static/js/src/data/common/consts.js
index 189d53fd98..f12a690c6b 100644
--- a/portal/static/js/src/data/common/consts.js
+++ b/portal/static/js/src/data/common/consts.js
@@ -12,6 +12,9 @@ export var EMPRO_TRIGGER_UNPROCCESSED_STATES = [
"inprocess",
"unstarted",
];
+export var EMPRO_TRIGGER_STATE_OPTOUT_KEY = "_opt_out_this_visit";
+export var EMPRO_TRIGGER_IN_PROCESS_STATE = "inprocess"; // see /trigger_states/empro_states.py for explanation of different trigger states
+export var EMPRO_TRIGGER_WITHDRAWN_STATE = "withdrawn"; // patient withdrawn
export var EPROMS_SUBSTUDY_QUESTIONNAIRE_IDENTIFIER = "ironman_ss";
export var EMPRO_POST_TX_QUESTIONNAIRE_IDENTIFIER = "ironman_ss_post_tx";
//pre-existing translated text
diff --git a/portal/static/js/src/data/common/test/SubStudyQuestionnaireTestData.json b/portal/static/js/src/data/common/test/SubStudyQuestionnaireTestData.json
new file mode 100644
index 0000000000..2b2ff3b0f2
--- /dev/null
+++ b/portal/static/js/src/data/common/test/SubStudyQuestionnaireTestData.json
@@ -0,0 +1,371 @@
+{
+ "entry": [
+ {
+ "author": {
+ "display": "patient demographics",
+ "reference": "https://eproms-test.cirg.washington.edu/api/demographics/4118"
+ },
+ "authored": "2024-03-15T23:16:54Z",
+ "extension": [
+ {
+ "url": "http://us.truenth.org/identity-codes/visit-name",
+ "visit_name": "Month 1"
+ }
+ ],
+ "group": {
+ "question": [
+ {
+ "answer": [
+ {
+ "valueString": "Frequently"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.1.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Frequently"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.1",
+ "text": "In the last 7 days, how OFTEN did you have pain?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Severe"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.2.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Severe"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.2",
+ "text": "In the last 7 days, what was the SEVERITY of your PAIN at its WORST?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Quite a bit"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.3.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Quite a bit"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.3",
+ "text": "In the last 7 days, how much did PAIN INTERFERE with your usual or daily activities?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Frequently"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.4.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Frequently"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.4",
+ "text": "In the last 7 days, how OFTEN did you have ACHING JOINTS (SUCH AS ELBOWS, KNEES, SHOULDERS)?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Severe"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.5.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Severe"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.5",
+ "text": "In the last 7 days, what was the SEVERITY of your ACHING JOINTS (SUCH AS ELBOWS, KNEES, SHOULDERS) at their WORST?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Quite a bit"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.6.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Quite a bit"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.6",
+ "text": "In the last 7 days, how much did ACHING JOINTS (SUCH AS ELBOWS, KNEES, SHOULDERS) INTERFERE with your usual or daily activities?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Severe"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.7.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Severe"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.7",
+ "text": "In the last 7 days, what was the SEVERITY of your INSOMNIA (INCLUDING DIFFICULTY FALLING ASLEEP, STAYING ASLEEP, OR WAKING UP EARLY) at its worst?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Quite a bit"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.8.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Quite a bit"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.8",
+ "text": "In the last 7 days, how much did INSOMNIA (INCLUDING DIFFICULTY FALLING ASLEEP, STAYING ASLEEP, OR WAKING UP EARLY) INTERFERE with your usual or daily activities?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Severe"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.9.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Severe"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.9",
+ "text": "In the last 7 days, what was the SEVERITY of your FATIGUE, TIREDNESS, OR LACK OF ENERGY at its WORST?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Quite a bit"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.10.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Quite a bit"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.10",
+ "text": "In the last 7 days, how much did FATIGUE, TIREDNESS, OR LACK OF ENERGY INTERFERE with your usual or daily activities?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Frequently"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.11.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Frequently"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.11",
+ "text": "In the last 7 days, how OFTEN did you feel ANXIETY?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Severe"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.12.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Severe"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.12",
+ "text": "In the last 7 days, what was the SEVERITY of your ANXIETY at its WORST?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Quite a bit"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.13.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Quite a bit"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.13",
+ "text": "In the last 7 days, how much did ANXIETY INTERFERE with your usual or daily activities?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Frequently"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.14.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Frequently"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.14",
+ "text": "In the last 7 days, how OFTEN did you FEEL THAT NOTHING COULD CHEER YOU UP?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Severe"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.15.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Severe"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.15",
+ "text": "In the last 7 days, what was the SEVERITY of your FEELINGS THAT NOTHING COULD CHEER YOU UP at THEIR WORST?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Quite a bit"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.16.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Quite a bit"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.16",
+ "text": "In the last 7 days, how much did THE FEELING THAT NOTHING COULD CHEER YOU UP INTERFERE with your usual or daily activities?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Frequently"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.17.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Frequently"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.17",
+ "text": "In the last 7 days, how OFTEN did you have SAD OR UNHAPPY FEELINGS?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Severe"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.18.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Severe"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.18",
+ "text": "In the last 7 days, what was the SEVERITY of your SAD OR UNHAPPY FEELINGS at their WORST?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Quite a bit"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.19.4",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Quite a bit"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.19",
+ "text": "In the last 7 days, how much did SAD OR UNHAPPY FEELINGS INTERFERE with your usual or daily activities?"
+ },
+ {
+ "answer": [
+ {
+ "valueString": "Quite a bit"
+ },
+ {
+ "valueCoding": {
+ "code": "ironman_ss.20.3",
+ "system": "https://eproms-test.cirg.washington.edu/api/codings/assessment",
+ "text": "Quite a bit"
+ }
+ }
+ ],
+ "linkId": "ironman_ss.20",
+ "text": "During the past week: Has your physical condition or medical treatment interfered with your social activities?"
+ }
+ ]
+ },
+ "identifier": {
+ "label": "cPRO survey session ID",
+ "system": "https://ae-eproms-test.cirg.washington.edu",
+ "use": "official",
+ "value": "3706.0"
+ },
+ "questionnaire": {
+ "display": "Treatment symptoms and side effects",
+ "reference": "https://eproms-test.cirg.washington.edu/api/questionnaires/ironman_ss"
+ },
+ "resourceType": "QuestionnaireResponse",
+ "source": {
+ "display": "patient demographics",
+ "reference": "https://eproms-test.cirg.washington.edu/api/demographics/4118"
+ },
+ "status": "completed",
+ "subject": {
+ "display": "patient demographics",
+ "reference": "https://eproms-test.cirg.washington.edu/api/demographics/4118"
+ }
+ }
+ ],
+ "link": [
+ {
+ "href": "https://eproms-test.cirg.washington.edu/api/patient/4118/assessment/ironman_ss",
+ "rel": "self"
+ }
+ ],
+ "resourceType": "Bundle",
+ "total": 1,
+ "type": "searchset",
+ "updated": "2023-07-12T22:20:17.897095Z"
+}
diff --git a/portal/static/js/src/data/common/test/TestTriggersData.json b/portal/static/js/src/data/common/test/TestTriggersData.json
new file mode 100644
index 0000000000..6b8d17f966
--- /dev/null
+++ b/portal/static/js/src/data/common/test/TestTriggersData.json
@@ -0,0 +1,111 @@
+{
+ "state": "resolved",
+ "timestamp": "2023-07-31T23:17:03+00:00",
+ "triggers": {
+ "action_state": "completed",
+ "actions": {
+ "email": [
+ {
+ "context": "patient thank you",
+ "email_message_id": 147757,
+ "timestamp": "2023-07-10T23:17:04.336385Z"
+ },
+ {
+ "context": "initial staff alert",
+ "email_message_id": 147758,
+ "timestamp": "2023-07-10T23:17:04.767591Z"
+ },
+ {
+ "context": "initial staff alert",
+ "email_message_id": 147759,
+ "timestamp": "2023-07-10T23:17:04.981972Z"
+ },
+ {
+ "context": "initial staff alert",
+ "email_message_id": 147760,
+ "timestamp": "2023-07-10T23:17:05.197306Z"
+ },
+ {
+ "context": "initial staff alert",
+ "email_message_id": 147761,
+ "timestamp": "2023-07-10T23:17:05.413215Z"
+ },
+ {
+ "context": "initial staff alert",
+ "email_message_id": 147762,
+ "timestamp": "2023-07-10T23:17:05.657467Z"
+ },
+ {
+ "context": "initial staff alert",
+ "email_message_id": 147763,
+ "timestamp": "2023-07-10T23:17:05.879160Z"
+ },
+ {
+ "context": "initial staff alert",
+ "email_message_id": 147764,
+ "timestamp": "2023-07-10T23:17:06.088223Z"
+ },
+ {
+ "context": "initial staff alert",
+ "email_message_id": 147765,
+ "timestamp": "2023-07-10T23:17:06.284333Z"
+ }
+ ]
+ },
+ "domain": {
+ "anxious": {
+ "ironman_ss.11": "hard",
+ "ironman_ss.12": "hard",
+ "ironman_ss.13": "hard",
+ "_sequential_hard_trigger_count": 2
+ },
+ "discouraged": {
+ "ironman_ss.14": "hard",
+ "ironman_ss.15": "hard",
+ "ironman_ss.16": "hard",
+ "_sequential_hard_trigger_count": 2
+ },
+ "fatigue": {
+ "ironman_ss.10": "hard",
+ "ironman_ss.9": "hard",
+ "_sequential_hard_trigger_count": 3
+ },
+ "general_pain": {
+ "ironman_ss.1": "hard",
+ "ironman_ss.2": "hard",
+ "ironman_ss.3": "hard",
+ "_sequential_hard_trigger_count": 3
+ },
+ "insomnia": { "ironman_ss.7": "hard", "ironman_ss.8": "hard" },
+ "joint_pain": {
+ "ironman_ss.4": "hard",
+ "ironman_ss.5": "hard",
+ "ironman_ss.6": "hard",
+ "_sequential_hard_trigger_count": 3
+ },
+ "sad": {
+ "ironman_ss.17": "hard",
+ "ironman_ss.18": "hard",
+ "ironman_ss.19": "hard",
+ "_sequential_hard_trigger_count": 3
+ },
+ "social_isolation": {
+ "ironman_ss.20": "hard",
+ "_sequential_hard_trigger_count": 3
+ }
+ },
+ "resolution": {
+ "authored": "2023-07-25T16:21:24Z",
+ "qb_iteration": null,
+ "qnr_id": 4162
+ },
+ "source": {
+ "authored": "2023-07-25T23:16:54Z",
+ "qb_id": 114,
+ "qb_iteration": null,
+ "qnr_id": 4161
+ }
+ },
+ "user_id": 4118,
+ "visit_month": 1
+}
diff --git a/portal/static/js/src/empro.js b/portal/static/js/src/empro.js
index b2122845cf..b9e4a324ed 100644
--- a/portal/static/js/src/empro.js
+++ b/portal/static/js/src/empro.js
@@ -1,186 +1,685 @@
import EMPRO_DOMAIN_MAPPINGS from "./data/common/empro_domain_mappings.json";
-import {EPROMS_SUBSTUDY_ID, EPROMS_SUBSTUDY_QUESTIONNAIRE_IDENTIFIER} from "./data/common/consts.js";
+import {
+ EPROMS_MAIN_STUDY_ID,
+ EPROMS_SUBSTUDY_ID,
+ EPROMS_SUBSTUDY_QUESTIONNAIRE_IDENTIFIER,
+ EMPRO_TRIGGER_STATE_OPTOUT_KEY,
+} from "./data/common/consts.js";
import tnthAjax from "./modules/TnthAjax.js";
import tnthDate from "./modules/TnthDate.js";
+import { CurrentUserObj } from "./mixins/CurrentUser.js";
+import TestResponsesJson from "./data/common/test/SubStudyQuestionnaireTestData.json";
+import TestTriggersJson from "./data/common/test/TestTriggersData.json";
+import { getUrlParameter } from "./modules/Utility";
-var emproObj = function() {
- this.domains = [];
- this.mappedDomains = [];
- this.hardTriggerDomains = [];
- this.softTriggerDomains = [];
- this.hasHardTrigger = false;
- this.hasSoftTrigger = false;
- this.userId = 0;
-};
-emproObj.prototype.populateDomainDisplay = function() {
- this.mappedDomains.forEach(domain => {
- $("#emproModal .triggersButtonsContainer").append(
- `
- ${i18next.t("{domain} Tips").replace("{domain}", domain.replace(/\_/g, " "))}
- `
- );
+var emproObj = function () {
+ this.domains = [];
+ this.mappedDomains = [];
+ this.hardTriggerDomains = [];
+ this.softTriggerDomains = [];
+ this.optOutDomains = [];
+ this.selectedOptOutDomains = [];
+ this.submittedOptOutDomains = [];
+ this.optOutSubmitData = null;
+ this.hasHardTrigger = false;
+ this.hasSoftTrigger = false;
+ this.userId = 0;
+ this.userOrgs = [];
+ this.visitMonth = 0;
+ this.authorDate = null;
+ this.cachedAccessKey = null;
+ this.optOutNotAllowed = false;
+};
+emproObj.prototype.getDomainDisplay = function (domain) {
+ if (!domain) return "";
+ return domain.replace(/_/g, " ");
+};
+emproObj.prototype.populateDomainDisplay = function () {
+ var triggerButtonsContainerElement = $(
+ "#emproModal .triggersButtonsContainer"
+ );
+ if (!triggerButtonsContainerElement.hasClass("added")) {
+ this.mappedDomains.forEach((domain) => {
+ triggerButtonsContainerElement.append(
+ `
+ ${i18next
+ .t("{domain} Tips")
+ .replace("{domain}", this.getDomainDisplay(domain))}
+ `
+ );
});
- this.hardTriggerDomains.forEach(domain => {
- $("#emproModal .hardTriggersDisplayList").append(`