-
-
Notifications
You must be signed in to change notification settings - Fork 218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Rc/test epic integration #35125
base: master
Are you sure you want to change the base?
Rc/test epic integration #35125
Changes from 18 commits
19ba9a6
30fe0d0
234bc5c
25db683
01281d5
b764511
c6be7af
0ed677b
de2bac9
173c017
9dd9c75
f148de9
9d0c563
442e112
9fe01a6
461a163
cd705bc
e132919
4b57d2c
4a30572
6651007
2409a2e
1d51e47
92ba265
5a90a7f
7d14e11
7955b1c
36a1884
ccbf366
b7e0699
5001b16
829cbdc
1f7bf89
ac65877
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
@@ -1,3 +1,6 @@ | ||||||||||
import dateutil, requests, jwt, time, uuid | ||||||||||
from datetime import datetime | ||||||||||
|
||||||||||
from collections import namedtuple | ||||||||||
from typing import Generator, List | ||||||||||
from uuid import uuid4 | ||||||||||
|
@@ -12,8 +15,9 @@ | |||||||||
from corehq import toggles | ||||||||||
from corehq.apps.celery import periodic_task, task | ||||||||||
from corehq.apps.hqcase.utils import submit_case_blocks | ||||||||||
from corehq.apps.hqcase.case_helper import CaseHelper | ||||||||||
from corehq.form_processor.exceptions import CaseNotFound | ||||||||||
from corehq.form_processor.models import CommCareCase | ||||||||||
from corehq.form_processor.models import CommCareCase, CommCareCaseIndex | ||||||||||
from corehq.motech.const import ( | ||||||||||
IMPORT_FREQUENCY_DAILY, | ||||||||||
IMPORT_FREQUENCY_MONTHLY, | ||||||||||
|
@@ -394,5 +398,252 @@ | |||||||||
) | ||||||||||
|
||||||||||
|
||||||||||
def generate_epic_jwt(): | ||||||||||
key = settings.EPIC_PRIVATE_KEY | ||||||||||
# token will expire in 4 mins | ||||||||||
exp = int(time.time()) + 240 | ||||||||||
jti = str(uuid.uuid4()) | ||||||||||
header = { | ||||||||||
"alg": "RS256", | ||||||||||
"typ": "JWT", | ||||||||||
} | ||||||||||
payload = { | ||||||||||
"iss": settings.EPIC_CLIENT_ID, | ||||||||||
"sub": settings.EPIC_CLIENT_ID, | ||||||||||
"aud": "https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token", | ||||||||||
"jti": jti, | ||||||||||
"exp": exp | ||||||||||
} | ||||||||||
token = jwt.encode(payload, key, algorithm="RS256", headers=header) | ||||||||||
return token | ||||||||||
|
||||||||||
|
||||||||||
def request_epic_access_token(): | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handling authentication and authorization inside the |
||||||||||
headers = { | ||||||||||
"Content_Type": "application/x-www-form-urlencoded", | ||||||||||
} | ||||||||||
data = { | ||||||||||
"grant_type": "client_credentials", | ||||||||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", | ||||||||||
"client_assertion": generate_epic_jwt() | ||||||||||
} | ||||||||||
url = "https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token" | ||||||||||
response = requests.post(url, data=data, headers=headers) | ||||||||||
if response.status_code == 200: | ||||||||||
return response.json().get('access_token') | ||||||||||
elif response.status_code >= 400: | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what happens for 3xx and 4xx status codes? |
||||||||||
return response.raise_for_status() | ||||||||||
|
||||||||||
|
||||||||||
def get_patient_fhir_id(given_name, family_name, birthdate, access_token): | ||||||||||
url = f"https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4/Patient?birthdate={birthdate}&family={family_name}&given={given_name}&_format=json" | ||||||||||
Robert-Costello marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||
headers = { | ||||||||||
'authorization': 'Bearer %s' % access_token, | ||||||||||
Robert-Costello marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||
} | ||||||||||
response = requests.get(url, headers=headers) | ||||||||||
if response.status_code == 200: | ||||||||||
response_json = response.json() | ||||||||||
fhir_id = None | ||||||||||
entry = response_json.get('entry')[0] | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be useful to have clearer docs available for what the response format is. It's hard to tell if this is correct. It seems like you expect it to be a list with at least 1 item in it but that item might be falsy (empty list, empty dict, null etc). This code will error if There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From the open epic docs entry is a required property, but may be an empty list. I added a check here 2409a2e
Name Description Is Optional Is Array There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Bundles can be paginated. You can find code for iterating resources inside paginated bundles here. |
||||||||||
if entry: | ||||||||||
resource = entry.get('resource') | ||||||||||
if resource: | ||||||||||
fhir_id = resource.get('id') | ||||||||||
return fhir_id | ||||||||||
elif response.status_code >= 400: | ||||||||||
response.raise_for_status() | ||||||||||
|
||||||||||
|
||||||||||
# TODO add time param 12 weeks from study start date | ||||||||||
def get_epic_appointments_for_patient(fhir_id, access_token): | ||||||||||
appointments = [] | ||||||||||
headers = { | ||||||||||
'authorization': 'Bearer %s' % access_token, | ||||||||||
} | ||||||||||
url = f"https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4/Appointment?&patient={fhir_id}&service-category=appointment&_format=json" | ||||||||||
response = requests.get(url, headers=headers) | ||||||||||
if response.status_code == 200: | ||||||||||
json_response = response.json() | ||||||||||
entries = json_response['entry'] | ||||||||||
for entry in entries: | ||||||||||
appointments.append(entry) | ||||||||||
elif response.status_code >= 400: | ||||||||||
snopoke marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||
response.raise_for_status() | ||||||||||
return appointments | ||||||||||
|
||||||||||
|
||||||||||
def convert_date_and_time_to_utc_timestamp(date, time): | ||||||||||
date_time = date + "T" + time | ||||||||||
utc_zone = dateutil.tz.gettz('UTC') | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: you can use the UTC constant: https://docs.python.org/3/library/datetime.html#datetime.timezone.utc
Suggested change
|
||||||||||
# Hardcoded for MGH study | ||||||||||
local_zone = dateutil.tz.gettz('America/New_York') | ||||||||||
local_datetime = datetime.fromisoformat(date_time) | ||||||||||
local_datetime = local_datetime.replace(tzinfo=local_zone) | ||||||||||
utc_datetime = local_datetime.astimezone(utc_zone) | ||||||||||
Comment on lines
+503
to
+506
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can be handled by out timezone utils: utc_datetime = UserTime(date_time, tzinfo=local_zone).server_time().done() |
||||||||||
utc_iso_format = utc_datetime.strftime('%Y-%m-%dT%H:%M:%SZ') | ||||||||||
|
||||||||||
return utc_iso_format | ||||||||||
|
||||||||||
|
||||||||||
def convert_utc_timestamp_to_date_and_time(utc_timestamp): | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. similar comment to above re TZ conversion using utilities |
||||||||||
utc_zone = dateutil.tz.gettz('UTC') | ||||||||||
local_zone = dateutil.tz.gettz('America/New_York') | ||||||||||
utc_datetime = datetime.fromisoformat(utc_timestamp.replace('Z', '')) | ||||||||||
utc_datetime = utc_datetime.replace(tzinfo=utc_zone) | ||||||||||
local_datetime = utc_datetime.astimezone(local_zone) | ||||||||||
date = local_datetime.strftime('%Y-%m-%d') | ||||||||||
time = local_datetime.strftime('%H:%M') | ||||||||||
|
||||||||||
return date, time | ||||||||||
|
||||||||||
|
||||||||||
def sync_all_appointments_domain(domain): | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you know the kinds of scale we expect (# patient / appointment cases). I'm just wondering if this should be done in batches. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I did give some thought to batching. I'm not sure about the number of patients, but I'm estimating that the number of appointments per patient will be relatively low for the timeframe of 12 weeks (still need to add the time constraint to the appointment query). One appointment per week seems like the upper end to me. If you double that it's still only 24 per patient. I'll talk to Chantal and see if she thinks those numbers are low and also get a sense of the number of patients. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. from Chantal: |
||||||||||
try: | ||||||||||
access_token = request_epic_access_token() | ||||||||||
except Exception: | ||||||||||
return None | ||||||||||
# get all patient case ids for domain | ||||||||||
patient_case_ids = CommCareCase.objects.get_open_case_ids_in_domain_by_type(domain, 'patient') | ||||||||||
# get all patient cases | ||||||||||
patient_cases = CommCareCase.objects.get_cases(patient_case_ids) | ||||||||||
|
||||||||||
# get extension (appointment) cases ids, for all patients | ||||||||||
appointment_case_ids = CommCareCaseIndex.objects.get_extension_case_ids( | ||||||||||
domain, patient_case_ids, False, 'appointment') | ||||||||||
# get appointment cases in commcare | ||||||||||
appointment_cases = CommCareCase.objects.get_cases(appointment_case_ids) | ||||||||||
|
||||||||||
# get fhir ids for appointments currently in commcare | ||||||||||
appointment_fhir_ids = [ | ||||||||||
appointment_case.get_case_property('fhir_id') for appointment_case in appointment_cases | ||||||||||
] | ||||||||||
|
||||||||||
for patient in patient_cases: | ||||||||||
patient_helper = CaseHelper(case=patient, domain=domain) | ||||||||||
patient_fhir_id = patient.get_case_property('patient_fhir_id') | ||||||||||
if not patient_fhir_id: | ||||||||||
given = patient.get_case_property('given_name') | ||||||||||
family = patient.get_case_property('family_name') | ||||||||||
birthdate = patient.get_case_property('birthdate') | ||||||||||
patient_fhir_id = get_patient_fhir_id(given, family, birthdate, access_token) | ||||||||||
|
||||||||||
if patient_fhir_id is not None: | ||||||||||
patient_helper.update({'properties': { | ||||||||||
'patient_fhir_id': patient_fhir_id, | ||||||||||
}}) | ||||||||||
Robert-Costello marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||
|
||||||||||
epic_appointments_to_add = [] | ||||||||||
epic_appointments_to_update = [] | ||||||||||
# Get all appointments for patient from epic | ||||||||||
epic_appointment_records = get_epic_appointments_for_patient(patient_fhir_id, access_token) | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Did you look at using Would it be a lot of effort to extend it to allow syncing appointments for this project?
... and maybe it's a lot more complicated than that. |
||||||||||
for appointment in epic_appointment_records: | ||||||||||
appointment_resource = appointment.get('resource') | ||||||||||
appointment_id = appointment_resource.get('id') | ||||||||||
if appointment_id and appointment_id not in appointment_fhir_ids: | ||||||||||
epic_appointments_to_add.append(appointment) | ||||||||||
elif appointment_id: | ||||||||||
epic_appointments_to_update.append(appointment) | ||||||||||
|
||||||||||
# Add new appointments to commcare | ||||||||||
for appointment in epic_appointments_to_add: | ||||||||||
appointment_create_helper = CaseHelper(domain=domain) | ||||||||||
appointment_resource = appointment.get('resource') | ||||||||||
if appointment_resource is not None: | ||||||||||
appointment_description = appointment_resource.get('description') or 'NO DESCRIPTION LISTED' | ||||||||||
appointment_fhir_timestamp = appointment_resource.get('start') | ||||||||||
appointment_date, appointment_time = convert_utc_timestamp_to_date_and_time( | ||||||||||
appointment_fhir_timestamp) | ||||||||||
appointment_fhir_id = appointment_resource.get('id') | ||||||||||
reason = None | ||||||||||
practitioner = None | ||||||||||
for p in appointment_resource.get('participant'): | ||||||||||
actor = p.get('actor') | ||||||||||
if actor and 'Practitioner' in actor.get('reference'): | ||||||||||
practitioner = actor.get('display') | ||||||||||
break | ||||||||||
reason_code = appointment_resource.get('reasonCode') | ||||||||||
if reason_code and reason_code[0] is not None: | ||||||||||
reason = reason_code[0].get('text') | ||||||||||
host_case_id = patient.get_case_property('case_id') | ||||||||||
appointment_case_data = { | ||||||||||
'case_name': appointment_fhir_timestamp + appointment_description, | ||||||||||
Robert-Costello marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||
'case_type': 'appointment', | ||||||||||
'indices': { | ||||||||||
'patient': { | ||||||||||
'case_id': host_case_id, | ||||||||||
'case_type': 'patient', | ||||||||||
'relationship': 'extension', | ||||||||||
} | ||||||||||
}, | ||||||||||
'properties': { | ||||||||||
'appointment_description': appointment_description, | ||||||||||
'appointment_fhir_timestamp': appointment_fhir_timestamp, | ||||||||||
'appointment_date': appointment_date, | ||||||||||
'appointment_time': appointment_time, | ||||||||||
'patient_fhir_id': patient_fhir_id, | ||||||||||
'fhir_id': appointment_fhir_id, | ||||||||||
'reason': reason, | ||||||||||
'practitioner': practitioner | ||||||||||
} | ||||||||||
} | ||||||||||
appointment_create_helper.create_case(appointment_case_data) | ||||||||||
|
||||||||||
# Update existing appointments in commcare if properties have changed in epic | ||||||||||
for appointment in epic_appointments_to_update: | ||||||||||
epic_properties_map = {} | ||||||||||
appointment_resource = appointment.get('resource') | ||||||||||
if appointment_resource is not None: | ||||||||||
appointment_description = appointment_resource.get('description') or 'NO DESCRIPTION LISTED' | ||||||||||
appointment_fhir_timestamp = appointment_resource.get('start') | ||||||||||
appointment_date, appointment_time = convert_utc_timestamp_to_date_and_time( | ||||||||||
appointment_fhir_timestamp) | ||||||||||
appointment_fhir_id = appointment_resource.get('id') | ||||||||||
reason = None | ||||||||||
practitioner = None | ||||||||||
for p in appointment_resource.get('participant'): | ||||||||||
actor = p.get('actor') | ||||||||||
if actor and 'Practitioner' in actor.get('reference'): | ||||||||||
practitioner = actor.get('display') | ||||||||||
break | ||||||||||
reason_code = appointment_resource.get('reasonCode') | ||||||||||
if reason_code and reason_code[0] is not None: | ||||||||||
reason = reason_code[0].get('text') | ||||||||||
epic_properties_map.update({ | ||||||||||
'appointment_description': appointment_description, | ||||||||||
'appointment_fhir_timestamp': appointment_fhir_timestamp, | ||||||||||
'practitioner': practitioner, | ||||||||||
'reason': reason | ||||||||||
}) | ||||||||||
appointment_case = None | ||||||||||
for case in appointment_cases: | ||||||||||
Robert-Costello marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||
if case.get_case_property('fhir_id') == appointment_fhir_id: | ||||||||||
appointment_case = case | ||||||||||
break | ||||||||||
appointment_update_helper = CaseHelper(case=appointment_case, domain=domain) | ||||||||||
case_properties_to_update = {} | ||||||||||
changes = False | ||||||||||
# check for changes and add to case_properties_to_update | ||||||||||
for k, v in epic_properties_map.items(): | ||||||||||
current_value = appointment_case.get_case_property(k) | ||||||||||
if current_value != v: | ||||||||||
changes = True | ||||||||||
case_properties_to_update.update({k: v}) | ||||||||||
if k == 'appointment_fhir_timestamp': | ||||||||||
appointment_date, appointment_time = convert_utc_timestamp_to_date_and_time(v) | ||||||||||
case_properties_to_update.update({ | ||||||||||
'appointment_date': appointment_date, | ||||||||||
'appointment_time': appointment_time, | ||||||||||
}) | ||||||||||
|
||||||||||
if changes: | ||||||||||
appointment_update_helper.update({'properties': case_properties_to_update}) | ||||||||||
|
||||||||||
|
||||||||||
@periodic_task(run_every=crontab(hour="*", minute=1), queue=settings.CELERY_PERIODIC_QUEUE) | ||||||||||
def sync_all_epic_appointments(): | ||||||||||
for domain in toggles.MGH_EPIC_STUDY.get_enabled_domains(): | ||||||||||
sync_all_appointments_domain(domain) | ||||||||||
|
||||||||||
|
||||||||||
class ServiceRequestNotActive(Exception): | ||||||||||
pass |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2240,6 +2240,13 @@ def _commtrackify(domain_name, toggle_is_enabled): | |
help_link="https://confluence.dimagi.com/display/GS/FHIR+API+Documentation", | ||
) | ||
|
||
MGH_EPIC_STUDY = StaticToggle( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (Note to self: Read all the code before you start to comment.) Instead of using a feature flag, I'd advocate moving this code into There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for taking a look, Norman. This has been in a holding pattern for a while, but I'm coming back to it to do some more work now. I can move this to a custom module. |
||
'mgh_epic_study', | ||
'Enable updating/creating extension cases through open epic API', | ||
TAG_CUSTOM, | ||
namespaces=[NAMESPACE_DOMAIN], | ||
) | ||
|
||
AUTO_DEACTIVATE_MOBILE_WORKERS = StaticToggle( | ||
'auto_deactivate_mobile_workers', | ||
'Development flag for auto-deactivation of mobile workers. To be replaced ' | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did you consider using
ConnectionSettings
for storing (and encrypting) API auth secrets?