Skip to content

Commit

Permalink
progress towards regenerating a user's trigger_states table on EMPRO …
Browse files Browse the repository at this point in the history
…consent change.
  • Loading branch information
pbugni committed Mar 13, 2024
1 parent b57f478 commit f9afc90
Show file tree
Hide file tree
Showing 4 changed files with 117 additions and 37 deletions.
13 changes: 9 additions & 4 deletions portal/models/qb_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
58 changes: 25 additions & 33 deletions portal/trigger_states/empro_states.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,10 @@
from statemachine.exceptions import TransitionNotAllowed

from .empro_domains import DomainManifold
from .empro_messages import patient_email, staff_emails
from .empro_messages import invite_email, patient_email, staff_emails
from .models import TriggerState
from ..database import db
from ..date_tools import FHIR_datetime
from ..models.app_text import MailResource, app_text
from ..models.communication import load_template_args
from ..models.message import EmailMessage
from ..models.qb_status import QB_Status
from ..models.qbd import QBD
from ..models.questionnaire_bank import QuestionnaireBank
Expand Down Expand Up @@ -81,7 +78,7 @@ class EMPRO_state(StateMachine):
next_available = resolved.to(due)


def users_trigger_state(user_id):
def users_trigger_state(user_id, as_of_date=None):
"""Obtain the latest trigger state for given user
Returns latest TriggerState row for user or creates transient if not
Expand All @@ -95,11 +92,21 @@ def users_trigger_state(user_id):
- triggered: triggers available in TriggerState.triggers attribute
"""
ts = TriggerState.query.filter(
if as_of_date is None:
as_of_date = datetime.utcnow()

rows = TriggerState.query.filter(
TriggerState.user_id == user_id).order_by(
TriggerState.timestamp.desc()).first()
TriggerState.timestamp.desc())
for ts_row in rows:
# most recent with a timestamp prior to as_of_date, in case this is a rebuild
if as_of_date < ts_row.timestamp:
continue
ts = ts_row
break

if not ts:
ts = TriggerState(user_id=user_id, state='unstarted')
ts = TriggerState(user_id=user_id, state='unstarted', timestamp=as_of_date)

return ts

Expand All @@ -115,19 +122,22 @@ def lookup_visit_month(user_id, as_of_date):
return one_index - 1


def initiate_trigger(user_id):
def initiate_trigger(user_id, as_of_date=None, rebuilding=False):
"""Call when EMPRO becomes available for user or next is due"""
if as_of_date is None:
as_of_date = datetime.utcnow()

ts = users_trigger_state(user_id)
if ts.state == 'due':
# Possible the user took no action, as in skipped the last month
# (or multiple months may have been skipped if time-warping).
# If so, the visit_month and timestamp are updated on the last
# `due` row that was found above.
visit_month = lookup_visit_month(user_id, datetime.utcnow())
visit_month = lookup_visit_month(user_id, as_of_date)
if ts.visit_month != visit_month:
current_app.logger.warn(f"{user_id} skipped EMPRO visit {ts.visit_month}")
ts.visit_month = visit_month
ts.timestamp = datetime.utcnow()
ts.timestamp = as_of_date
db.session.commit()

# Allow idempotent call - skip out if in correct state
Expand All @@ -143,7 +153,7 @@ def initiate_trigger(user_id):
next_visit = int(ts.visit_month) + 1
current_app.logger.debug(f"transition from {ts} to next due")
# generate a new ts, to leave resolved record behind
ts = TriggerState(user_id=user_id, state='unstarted')
ts = TriggerState(user_id=user_id, state='unstarted', as_of_date=as_of_date)
ts.visit_month = next_visit
current_app.logger.debug(
"persist-trigger_states-new from initiate_trigger(), "
Expand All @@ -159,27 +169,9 @@ def initiate_trigger(user_id):
"persist-trigger_states-new from initiate_trigger(),"
f"record historical clause {ts}")

# TN-2863 auto send invite when first available
if ts.visit_month == 0:
user = User.query.get(user_id)
args = load_template_args(user=user)
item = MailResource(
app_text("patient invite email IRONMAN EMPRO Study"),
locale_code=user.locale_code,
variables=args)
msg = EmailMessage(
subject=item.subject,
body=item.body,
recipients=user.email,
sender=current_app.config['MAIL_DEFAULT_SENDER'],
user_id=user_id)
try:
msg.send_message()
except SMTPRecipientsRefused as exc:
current_app.logger.error(
"Error sending EMPRO Invite to %s: %s",
user.email, exc)
db.session.add(msg)
# TN-2863 auto send invite when first available, unless rebuilding
if ts.visit_month == 0 and not rebuilding:
invite_email(User.query.get(user_id))

db.session.commit()
return ts
Expand Down
77 changes: 77 additions & 0 deletions portal/trigger_states/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import datetime, timedelta
from flask import current_app
from sqlalchemy.dialects.postgresql import ENUM, JSONB
from sqlalchemy.orm import make_transient

Expand Down Expand Up @@ -254,3 +255,79 @@ def soft_triggers_for_visit(self, visit_month):
ts = self.latest_by_visit[visit_month]
if ts:
return ts.soft_trigger_list()


def rebuild_trigger_states(patient):
"""If a user's consent moves, need to re-build the trigger states for user
Especially messy process, as much of the data lives in the trigger_states
table alone, and a consent change may modify start eligibility, etc.
"""
from .empro_states import initiate_trigger
from ..models.overall_status import OverallStatus
from ..models.qb_status import patient_research_study_status
from ..models.qb_timeline import QBT, update_users_QBT
from ..models.research_study import BASE_RS_ID, EMPRO_RS_ID

# Use the timeline data for accurate start dates, etc.
update_users_QBT(user_id=patient.id, research_study_id=EMPRO_RS_ID)
tl_query = QBT.query.filter(QBT.user_id == patient.id).filter(
QBT.research_study_id == EMPRO_RS_ID).order_by(QBT.id)
if not tl_query.count():
# User has no timeline data for EMPRO, likely not eligible
if TriggerState.query.filter(TriggerState.user_id == patient.id).count():
current_app.logging.error(f"no EMPRO timeline, yet trigger_states rows for {patient.id}")
return

# Capture state in memory for potential reuse when rebuilding
data = []
for row in TriggerState.query.filter(
TriggerState.user_id == patient.id).order_by(TriggerState.id):
data.append({
'id': row.id,
'state': row.state,
'timestamp': row.timestamp,
'questionnaire_response_id': row.questionnaire_response_id,
'triggers': row.triggers,
'visit_month': row.visit_month,
})

if not data:
# no trigger state data to move, no problem.
return

# purge rows and rebuild below
# TODO TriggerState.delete and rebuild
raise NotImplemented(f"can not adjust trigger_states for {patient.id}")

if len([c for c in patient.clinicians]) == 0:
# No valid trigger states without a clinician
return

visit_month = -1
for row in tl_query:
if row.status == OverallStatus.due:
# reset any state for next visit:
visit_month += 1
conclude_as_expired = False

# 'due' row starts when they became eligible
as_of_date = row.at
study_status = patient_research_study_status(
patient=patient, as_of_date=as_of_date, skip_initiate=True)

if study_status[BASE_RS_ID]['ready']:
# user had unfinished global work at said point in time.
# check if they complete before the current visit expires
basestudy_query = QBT.query.filter(QBT.user_id == patient.id).filter(
QBT.research_study_id == BASE_RS_ID).filter(
QBT.at.between(as_of_date, as_of_date+timedelta(days=30))).filter(
QBT.status == OverallStatus.completed).first()
if basestudy_query:
# user finished global work on time, start the visit then
as_of_date = basestudy_query.as_of_date
else:
# user was never able to submit visit for given empro month.
# stick with initial start date and let it resolve as expired
conclude_as_expired = True
initiate_trigger(patient.id, as_of_date=as_of_date, rebuilding=True)
6 changes: 6 additions & 0 deletions portal/views/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,12 @@ def set_user_consents(user_id):
# status - invalidate this user's data at this time.
invalidate_users_QBT(
user_id=user.id, research_study_id=consent.research_study_id)

# If user has submitted EMPRO - those must also be recalculated
if consent.research_study_id == EMPRO_RS_ID:
from ..trigger_states.models import rebuild_trigger_states
rebuild_trigger_states(user)

except ValueError as e:
abort(400, str(e))

Expand Down

0 comments on commit f9afc90

Please sign in to comment.