JupyterHealth Exchange is a Django web application that facilitates the sharing of user-consented medical data with authorized consumers through a web UI, REST and FHIR APIs.
In the context of JupyterHealth, data producers are typically study participants (FHIR Patients) using the CommonHealth Android App linked to personal devices (eg Glucose Monitors) and data consumers are typically researchers (FHIR Practitioners).
Features include:
- OAuth 2.0, OIDC and SMART on FHIR Identity Provision using django-oauth-toolkit
- FHIR R5 schema validation using fhir.resources
- REST APIs using Django Rest Framework
- Built-in, light-weight Vanilla JS SPA UI (npm not required) using oidc-clinet-ts, handlebars and bootstrap
This project is currently in a Proof of Concept stage, the project can be viewed on GitHub at the following URL:
https://github.com/orgs/the-commons-project/projects/8
Tip
If you are a user (Practitioner or Patient), please take note of the following access information for the JupyterHealth Website:
- JHE Default Invite Code is
helloworld(see here) - JHE Default Super User
[email protected]/Jhe1234!can create and edit top level organizations. Any user can create sub-organizations.
Tip
Quick start: For local development, Skip the steps 8–12 as the seed_db command will register the Django OAuth2 application. Also, Pre‑generated values of OIDC_RSA_PRIVATE_KEY, PATIENT_AUTHORIZATION_CODE_CHALLENGE, and PATIENT_AUTHORIZATION_CODE_VERIFIER are provided in dot_env_example.txt for dev/demo use only.
Note
Due to browser security restrictions and the oidc-client-ts used for authentication, the web app must be accessed over HTTPS for any hostname other than localhost - see Running in Production below.
-
Set up your Python environment and install dependencies from
Pipfile- this project uses Django version 5.2 which requires python 3.10, 3.11, 3.12 or 3.13- NB: If using pipenv it is recommended to run
pipenv syncagainst the lock file to match package versions
- NB: If using pipenv it is recommended to run
-
Create a new Postgres DB (currently only Postgres is supported because of json functions)
-
Copy
dot_env_example.txtto.envand update theDB_*parameters to match (2) and generate a new value forSECRET_KEY, e.g. withopenssl rand -base64 32. -
Ensure the
.envis loaded into your Python environment, eg for pipenv run$ pipenv shell -
Run the Django migration
$ python manage.py migrateto create the database tables. -
Seed the database by running the Django management command
$ python manage.py seed -
Start the server with
$ python manage.py runserverSkip steps 8-12 below if doing Quick start above
-
Browse to http://localhost:8000/admin and enter the credentials
[email protected]Jhe1234! -
Under Django OAuth Toolkit > Applications you should already see the seeded OAuth2 application (redirects include
http://localhost:8000/auth/callback). Create a new application only if you need a custom client for testing or multi-tenant scenarios. -
Create an RS256 Private Key (step by step here)
-
Create a new static PKCE verifier - a random alphanumeric string 44 chars long, and then create the challenge here.
-
Return to the
.envfile- Update the
OIDC_RSA_PRIVATE_KEYwith the newly created Private Key - Update
PATIENT_AUTHORIZATION_CODE_CHALLENGEandPATIENT_AUTHORIZATION_CODE_VERIFIERwith PKCE static values generated above - Restart the python environment and Django server
- Update the
-
Browse to http://localhost:8000/ and log in with the credentials
[email protected]Jhe1234!and you should be directed to the/portal/organizationspath with some example Organizations is the dropdown -
Before the each commit always make sure to execute
pre-commit run --all-filesto make sure the PEP8 standards. -
Git hook for the pre-commit can also be installed
pre-commit installto automate the process. -
If a hook fails, fix the issues, stage the changes, and commit again — the commit only succeeds when hooks pass.
Warning
The OIDC_RSA_PRIVATE_KEY, PATIENT_AUTHORIZATION_CODE_CHALLENGE, and PATIENT_AUTHORIZATION_CODE_VERIFIER provided in dot_env_example.txt are public demo keys for development only and must not be used in production.
Issue: Developers running Django on Windows (Visual Studio Code, Git Bash, etc.) might hit a blank screen after login even though authentication succeeds. That usually happens because the OIDC settings in settings.py get polluted with local paths (e.g. OIDC_CLIENT_REDIRECT_URI: http://localhost:8000C:/Program Files/Git/auth/callback).
Cause: The environment loader injects the shell’s current working directory or other path fragments into the OIDC URLs, producing malformed authorization/redirect addresses.
Solution: Explicitly define the two OIDC URLs in settings.py rather than relying on environment interpolation. For example:
OIDC_CLIENT_REDIRECT_URI = 'http://localhost:8000/auth/callback'
OIDC_CLIENT_AUTHORITY = 'http://localhost:8000/o/'By hardcoding the values you prevent the path injection and keep the SPA from seeing broken URLs, which resolves the blank screen after login on Windows hosts.
-
Control log verbosity with DJANGO_LOG_LEVEL – The backend now reads the
DJANGO_LOG_LEVELenvironment variable (default: INFO) for both the root logger and the django logger. ForFly.iodeployments, setDJANGO_LOG_LEVEL=DEBUGinfly.tomland runfly deploy. For local development, setDJANGO_LOG_LEVELusing the appropriate environment configuration for your operating system. -
Don’t forget the SPA debug banner – temporarily enabling Django
DEBUGsurfaces server-side tracebacks in the portal so you can correlate HTTP logs with richer context. Turn it off afterward to avoid exposing stack traces.
Note:
DJANGO_LOG_LEVELis a configuration flag, not a secret. Keepfly secretsand your.envstrictly for secrets. This keeps local CI environments aligned with the same value.
- Any user accessing the Web UI is a Practitioner (data consumer) by default
- Patient users (data producers) are registered by Practitioners and sent a link to authenticate and upload data
- The same OAuth 2.0 strategy is used for both Practitioners and Patients, the only difference being that the authorization code is provided out-of-band for Patients (invitation link)
- An Organization is a group of Practitioners, Patients and Studies
- An Organization is typically hierarchical with sub-Organizations eg Institution, Department, Lab etc
- A Practitioner belongs to one or more Organization
- A Patient belongs to one or more Organization
- A Study belongs to one single Organization
- A Study is a Group of Patients and belongs to a single Organization
- A Study has one or more Data Sources and one or more Scope Requests
- When a Patient is added to a Study, they must explicitly consent to sharing the requested Scopes before any data (Observations) can be uploaded or shared
- An Observation is Patient data and belongs to a single Patient
- An Observation must reference a Patient ID as the subject and a Data Source ID as the device
- Personal device data is expected to be in the Open mHealth (JSON) format however the system can be easily extended to support any binary data attachments or discrete Observation records
- Observation data is stored as a valueAttachment in Base 64 encoded JSON binary
- Authorization to view Observations depends on the relationship of Organization, Study and Consents as described above
- A Data Source is anything that produces Observations (typically a device app eg iHealth)
- A Data Source supports one or more Scopes (types) of Observations (eg Blood Glucose)
- An Observation references a Data Source ID in the device field
- The vanilla JavaScript SPA pulls runtime settings via
core/templates/client/client_settings.js, which exposes a globalCONSTANTSobject. CONSTANTSis populated by the Django context processorcore/context_processors.py, so any new value you expose there becomes available both to the template and tocore/static/js/client.js.- Keep secrets and URLs on the Django side (settings or database-backed values) and let the context processor synthesize them, to prevent duplication and to keep the SPA reusable across environments.
- Sign up as a new user from the Web UI
- Create a new Organization (your user is automatically added to the Organization with a Manager role)
- Create a new Study for the Organization (View Organization > Studies+)
- Create a new Patient for the Organization using a different email than (1) (Patients > Add Patient)
- Add Data Sources and Scopes to the Study (View Study > Data Sources+, Scope Requests+)
- Add the Patient to the Study (Patients > check box > Add Patient(s) to Study)
- Create an Invitation Link for the Patient (View Patient > Generate Invitation Link)
- Use the code in the invitation link with the Auth API to swap it for an access token
- Upload Observations using the FHIR API and access token
- View the Observations from the web UI
Whether or not a Practitioner user can view particular Patients, Studies and Observations depends on membership of the Organization that the Patients/Studies/Observations belong to.
Whether or not a Practitioner user can edit the Data Sources, Organization, Organization's Patients or Studies depends on the role they are assigned to for that Organization at the time of being added. When a user create a new Organization they are automatically added as a Manager of the Organization. Permissions for roles are outlined in the table below.
| Permission | Super User | Manager | Member | Viewer |
|---|---|---|---|---|
| Edit Data Sources | âś… | |||
| Edit Top Level Organizations | âś… | |||
| Edit Organization | âś… | âś… | ||
| Edit Patients | âś… | âś… | âś… | |
| Edit Studies | âś… | âś… | âś… | |
| View All | âś… | âś… | âś… | âś… |
If Observations are sent with data attached in the Open mHealth format (eg Observation.code=omh:blood-glucose:4.0) JSON Schema validation is used to check the payload. Example values can be found at data/omh/examples/data-points and the JSON schemas can be found at data/omh/json-schemas. See Open mHealth for more information.
- The initial Database is seeded with a minimal set of records to provide an example of the different entity relationships, see the diagram below.
flowchart TD
sam("SuperUser Sam<br/><small>sam\@example.com</small>")
style sam fill:#CFC
%% berkeley
ucb("Organization:<br/>University of California Berkeley") --> ccdss("Organization:<br/>College of Computing, Data Science and Society")
ccdss --> bids("Organization:<br/>Berkeley Institute for Data Science (BIDS)")
%% berkeley users
ucb --Manager--> mary("ManagerMary<br/><small>mary\@example.com</small>")
style mary fill:#CFC
ccdss --Manager--> mary
bids --Manager--> mary
bids --Memeber--> megan("MemberMegan<br/><small>megan\@example.com</small>")
style megan fill:#CFC
bids --Viewer--> victor("ViewerVictor<br/><small>victor\@example.com</small>")
style victor fill:#CFC
tom("ThreeOrgTom<br/><small>tom\@example.com</small>")
bids --Viewer--> tom
style tom fill:#CFC
%% berkeley studies
bids --> bidsStudyOnBPHR("BIDS Study on BP & HR<br/><small>Blood Pressure<br/>Heart Rate</small>")
style bidsStudyOnBPHR fill:#CFF
bids --> bidsStudyOnBP("BIDS Study on BP<br/><small>Blood Pressure </small>")
style bidsStudyOnBP fill:#CFF
%% berkeley patients
bids --> peter("BidsPatientPeter<br/><small>peter\@example.com</small>")
style peter fill:#FCC
peter --Consented--> bidsStudyOnBPHR
peter --Requested--> bidsStudyOnBP
pamela("BidsPatientPamela<br/><small>pamela\@example.com</small>")
style pamela fill:#FCC
bids --> pamela
pamela --Consented--> bidsStudyOnBPHR
pamela --Consented--> bidsStudyOnBP
%% ucsf
ucsf("Organization:<br/>University of California San Francisco") --> med("Organization:<br/>Department of Medicine")
med --> cardio("Organization:<br/>Cardiology")
cardio --> moslehi("Organization:<br/>Moslehi Lab")
cardio --> olgin("Organization:<br/>Olgin Lab")
%% ucsf users
ucsf --Manager-->mark("ManagerMark<br/><small>mark\@example.com</small>")
style mark fill:#CFC
med --Manager--> mark
cardio --Manager--> mark
moslehi --Member--> tom
moslehi --Manager-->mark
olgin --Manager--> tom
%% ucsf studies
cardio --> cardioStudyOnRR("Cardio Study on RR<br/><small>Respiratory rate</small>")
style cardioStudyOnRR fill:#CFF
moslehi --> moslehiStudyOnBT("Moslehi Study on BT<br/><small>Body Temperature</small>")
style moslehiStudyOnBT fill:#CFF
olgin --> olginStudyOnO2("Olgin Study on O2<br/><small>Oxygen Saturation</small>")
style olginStudyOnO2 fill:#CFF
%% ucsf patients
moslehi --> percy("MoslehiPatientPercy<br/><small>percy\@example.com</small>")
style percy fill:#FCC
percy --Consented--> moslehiStudyOnBT
olgin --> paul("OlginPatientPaul<br/><small>paul\@example.com</small>")
style paul fill:#FCC
paul --Consented--> olginStudyOnO2
cardio --> pat("CardioOlginPatientPat<br/><small>pat\@example.com</small>")
style pat fill:#FCC
pat --Consented--> cardioStudyOnRR
pat --Consented--> olginStudyOnO2
olgin --> pat
- Additional test data from the iglu project can be seeded by running the following command (please note this can take 10-20 minutes to run)
$ python manage.py iglu - This creates a new study under the "Berkeley Institute for Data Science (BIDS)" Organization with 19 mock patients and 1745 real Observation data points
- The OAuth 2.0 Authorization Code grant flow with PKCE is used to issue Access, Refresh and ID tokens for both Practitioners (web login) and Patients (secret invitation link)
- The Patient authorization code is generated by the server and then shared with the user out-of-band as a secret invitation link
- OAuth is configured from the Django Admin page (See Getting Started above)
- Endpoints and configuration details can be discovered from the OIDC metadata endpoint:
/o/.well-known/openid-configuration - The returned Access Token should be included in the
Authorizationheader for all API requests with the prefixBearer - Because the Patient authorization code is generated by the server, the PKCE code challenge and code verifier for Patient auth must be static values and set by the env vars (example below). The Patient client then sends this
code_verifieralong with the authorization code to obtain tokens. Theredirect_uriserves no purpose (as the initial authorization code has already been issued) but is required per OAuth spec.
PATIENT_AUTHORIZATION_CODE_CHALLENGE = '-2FUJ5UCa7NK9hZWS0bc0W9uJ-Zr_-Pngd4on69oxpU'
PATIENT_AUTHORIZATION_CODE_VERIFIER = 'f28984eaebcf41d881223399fc8eab27eaa374a9a8134eb3a900a3b7c0e6feab5b427479f3284ebe9c15b698849b0de2'
Client POST
Content-Type: application/x-www-form-urlencoded
code=4AWKhgaaomTSf9PfwxN4ExnXjdSEqh&grant_type=authorization_code&redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Fcallback
&client_id=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
&code_verifier=f28984eaebcf41d881223399fc8eab27eaa374a9a8134eb3a900a3b7c0e6feab5b427479f3284ebe9c15b698849b0de2
Note
It is understood using static values for PKCE runs against best practises however this is only used for the Patient client auth and not the Practitioner Web UI or API auth. The Patient client authorization code is generated by the server and shared out of band and therefore dynamic PKCE can not be used unless it is passed along with the invitation secret link, which would defeat the purpose of an additional check.
An invitation link may look something like below
https://play.google.com/store/apps/details?id=org.thecommonsproject.android.phr.dev&referrer=cloud_sharing=jhe.fly.dev|LhS05iR1rOnpS4JWfP6GeVUIhaRcRh
- The purpose of the prefix URL (eg
https://play.google.com/store/apps/details?id=org.thecommonsproject.android.phr.dev&referrer=cloud_sharing=) is for the device to know which app to launch. In this case, the CommonHealth app is launched via Google Play so that if the user does not yet have the CommonHealth app installed they can install it within the flow - The prefix URL component may be more simple, for example
https://carex.ai/?invitation=to launch the CareX app - The suffix of the link contains the hostname (optional) followed by a pipe character and the OAuth2 Authorization Code, for example
jhe.fly.dev|LhS05iR1rOnpS4JWfP6GeVUIhaRcRh - The purpose of the suffix is to provide the app with information on what host to talk to (as there may be many JHEs configured for the one Patient) as well as the Authorization Code that can be swapped for an Access Token to use the API (see above)
- The prefix URL is configured in the
.env(anddot_env_example.txt) asCH_INVITATION_LINK_PREFIX; the example value points to the CommonHealth Play Store deep link shown above. - The host name is included by default but can optionally be removed from the link (if there will only ever be one host for the app) by configuring the
.envwithCH_INVITATION_LINK_EXCLUDE_HOST=True - So in the example of
https://carex.ai/?invitation=jhe.fly.dev|LhS05iR1rOnpS4JWfP6GeVUIhaRcRh- The CareX app is launched with the URL
- The CareX app parses the
invitationparameter - The CareX app gets the token endpoint from the invitation host
https://jhe.fly.dev/o/.well-known/openid-configuration - The CareX app posts
LhS05iR1rOnpS4JWfP6GeVUIhaRcRhto get an access token - The CareX app uses the API below to set consents
- The CareX app uses the API below to upload data
The django-saml2-auth library is included to support SSO with SAML2.
Example SAML2 Flow with mocksaml.com
Modify the .env to match below
# Default: SSO disabled. Change to 1 to enable.
SAML2_ENABLED=1
# Comma-separated list matches email domains permitted to sign in via SSO
SSO_VALID_DOMAINS=example.com,example.org
# MockSAML metadata URL (used to auto-configure IdP endpoints & certificate)
IDENTITY_PROVIDER_METADATA_URL=https://mocksaml.com/api/saml/metadataAdd the below to ./settings.py
# settings.py (demo only)
DEBUG = TrueWarning
Use Debug for testing only, switch off Debug for Production.
When DEBUG is enabled the SPA debug page now summarizes server errors (including HTML tracebacks), auto-scrolls the banner into view, and auto-hides after a few seconds so developers can quickly see the actionable message.
-
Visit https://mocksaml.com/saml/login and enter the fields below:
-
ACS URL:
http://localhost:8000/sso/acs/(or substitute your hostname) End with a trailing slash -
Audience:
http://localhost:8000/sso/acs/
-
-
Enter any email name
@example.com -
Enter any password
-
Click Sign in
-
The JHE portal should be displayed with the user in the matching user name in the bottom left hand corner
- The JHE app reads IdP configuration from
IDENTITY_PROVIDER_METADATA_URL - Only users with email addresses on the
SSO_VALID_DOMAINSare permitted
- The Admin API is used by the Web UI SPA for Practitioner/Patient/Organization/Study management and Patient data provider apps/clients to manage Patient consents.
- The
profileendpoint returns the current user details.
// GET /api/v1/users/profile
{
"id": 10001,
"email": "[email protected]",
"firstName": "Peter",
"lastName": "ThePatient",
"patient": {
"id": 40001,
...
}
}- The
consentsendpoint returns the studies that are pending and consented for the specified Patient. In this example, the Patient has been invited to Demo Study 2 and has already consented to sharing blood glucose data with Demo Study 1.
// GET /api/v1/patients/40001/consents
{
"patient": {
"id": 40001,
//...
},
"consolidatedConsentedScopes": [
{
"id": 50002,
"codingSystem": "https://w3id.org/openmhealth",
"codingCode": "omh:blood-pressure:4.0",
"text": "Blood pressure"
}
],
"studiesPendingConsent": [
{
"id": 30002,
"name": "Demo Study 2",
"organization": { ... }
"dataSources": [ ... ],
"pendingScopeConsents": [
{
"code": {
"id": 50002,
"codingSystem": "https://w3id.org/openmhealth",
"codingCode": "omh:blood-pressure:4.0",
"text": "Blood pressure"
},
"consented": null
}
]
}
],
"studies": [
{
"id": 30001,
"name": "Demo Study 1",
"organization": { ... },
"dataSources": [ ... ],
"scopeConsents": [
{
"code": {
"id": 50001,
"codingSystem": "https://w3id.org/openmhealth",
"codingCode": "omh:blood-glucose:4.0",
"text": "Blood glucose"
},
"consented": true
}
]
}
]
}- To respond to requested consents, a POST is sent to the same
consentsendpoint with the scope and theconsentedboolean.
// POST /api/v1/patients/40001/consents
{
"studyScopeConsents": [
{
"studyId": 30002,
"scopeConsents": [
{
"codingSystem": "https://w3id.org/openmhealth",
"codingCode": "omh:blood-pressure:4.0",
"consented": true
}
]
}
]
}
- A
PATCHrequest can be sent with the same payload to update an existing Consent - A
DELETErequest can be sen with the same payload excludingscopeConsents.consentedto delete the Consent
- The
FHIR Patientendpoint returns a list of Patients as a FHIR Bundle for a given Study ID passed as query parameter_has:Group:member:_idor alternatively a single Patient matching the query parameteridentifier=<system>|<value>
| Query Parameter | Example | Description |
|---|---|---|
_has:Group:member:_id |
30001 |
Filter by Patients that are in the Study with ID 30001 |
identifier |
`http://ehr.example.com | abc123` |
// GET /fhir/r5/Patient?_has:Group:member:_id=30001
{
"resourceType": "Bundle",
"type": "searchset",
"entry": [
{
"resource": {
"resourceType": "Patient",
"id": "40001",
"meta": {
"lastUpdated": "2024-10-23T12:35:25.142027+00:00"
},
"identifier": [
{
"value": "fhir-1234",
"system": "http://ehr.example.com"
}
],
"name": [
{
"given": [
"Peter"
],
"family": "ThePatient"
}
],
"birthDate": "1980-01-01",
"telecom": [
{
"value": "[email protected]",
"system": "email"
},
{
"value": "347-111-1111",
"system": "phone"
}
]
}
},
...- The
FHIR Observationendpoint returns a list of Observations as a FHIR Bundle - At least one of Study ID, passed as
patient._has:Group:member:_idor Patient ID, passed aspatientor Patient Identifier passed aspatient.identifier=<system>|<value>query parameters are required subject.referencereferences a Patient IDdevice.referencereferences a Data Source IDvalueAttachmentis Base 64 Encoded Binary JSON
| Query Parameter | Example | Description |
|---|---|---|
patient._has:Group:member:_id |
30001 |
Filter by Patients that are in the Study with ID 30001 |
patient |
40001 |
Filter by single Patient with ID 40001 |
patient.identifier |
`http://ehr.example.com | abc123` |
code |
`https://w3id.org/openmhealth | omh:blood-pressure:4.0` |
// GET /fhir/r5/Observation?patient._has:Group:member:_id=30001&patient=40001&code=https://w3id.org/openmhealth|omh:blood-pressure:4.0
{
"resourceType": "Bundle",
"type": "searchset",
"entry": [
{
"resource": {
"resourceType": "Observation",
"id": "63416",
"meta": {
"lastUpdated": "2024-10-25T21:14:02.871132+00:00"
},
"identifier": [
{
"value": "6e3db887-4a20-3222-9998-2972af6fb091",
"system": "https://ehr.example.com"
}
],
"status": "final",
"subject": {
"reference": "Patient/40001"
},
"device": {
"reference": "Device/70001"
},
"code": {
"coding": [
{
"code": "omh:blood-pressure:4.0",
"system": "https://w3id.org/openmhealth"
}
]
},
"valueAttachment": {
"data": "eyJib2R5IjogeyJlZmZlY3RpdmVfdGltZV9mcmFtZSI6IHsiZGF0ZV90aW1lIjogIjIwMjQtMDUt\nMDJUMDc6MjE6MDAtMDc6MDAifSwgInN5c3RvbGljX2Jsb29kX3ByZXNzdXJlIjogeyJ1bml0Ijog\nIm1tSGciLCAidmFsdWUiOiAxMjJ9LCAiZGlhc3RvbGljX2Jsb29kX3ByZXNzdXJlIjogeyJ1bml0\nIjogIm1tSGciLCAidmFsdWUiOiA3N319LCAiaGVhZGVyIjogeyJ1dWlkIjogIjZlM2RiODg3LTRh\nMjAtMzIyMi05OTk4LTI5NzJhZjZmYjA5MSIsICJtb2RhbGl0eSI6ICJzZW5zZWQiLCAic2NoZW1h\nX2lkIjogeyJuYW1lIjogImJsb29kLXByZXNzdXJlIiwgInZlcnNpb24iOiAiMy4xIiwgIm5hbWVz\ncGFjZSI6ICJvbWgifSwgImNyZWF0aW9uX2RhdGVfdGltZSI6ICIyMDI0LTEwLTI1VDIxOjEzOjMx\nLjQzOFoiLCAiZXh0ZXJuYWxfZGF0YXNoZWV0cyI6IFt7ImRhdGFzaGVldF90eXBlIjogIm1hbnVm\nYWN0dXJlciIsICJkYXRhc2hlZXRfcmVmZXJlbmNlIjogImh0dHBzOi8vaWhlYWx0aGxhYnMuY29t\nL3Byb2R1Y3RzIn1dLCAic291cmNlX2RhdGFfcG9pbnRfaWQiOiAiZTZjMTliMDQyOGM4NWJiYjdj\nMTk4MGNiOTRkZDE3N2YiLCAic291cmNlX2NyZWF0aW9uX2RhdGVfdGltZSI6ICIyMDI0LTA1LTAy\nVDA3OjIxOjAwLTA3OjAwIn19",
"contentType": "application/json"
}
}
},
...- Observations are uploaded as FHIR Batch bundles sent as a POST to the root endpoint
// POST /fhir/r5/
{
"resourceType": "Bundle",
"type": "batch",
"entry": [
{
"resource": {
"resourceType": "Observation",
"status": "final",
"code": {
"coding": [
{
"system": "https://w3id.org/openmhealth",
"code": "omh:blood-pressure:4.0"
}
]
},
"subject": {
"reference": "Patient/40001"
},
"device": {
"reference": "Device/70001"
},
"identifier": [
{
"value": "6e3db887-4a20-3222-9998-2972af6fb091",
"system": "https://ehr.example.com"
}
],
"valueAttachment": {
"contentType": "application/json",
"data": "eyJzeXN0b2xpY19ibG9vZF9wcmVzc3VyZSI6eyJ2YWx1ZSI6MTQyLCJ1bml0IjoibW1IZyJ9LCJkaWFzdG9saWNfYmxvb2RfcHJlc3N1cmUiOnsidmFsdWUiOjg5LCJ1bml0IjoibW1IZyJ9LCJlZmZlY3RpdmVfdGltZV9mcmFtZSI6eyJkYXRlX3RpbWUiOiIyMDIxLTAzLTE0VDA5OjI1OjAwLTA3OjAwIn19"
}
},
"request": {
"method": "POST",
"url": "Observation"
}
},
...- The database must already be seeded
- The Django server must be running, i.e., https://jhe.fly.dev or http://localhost:
- The target Practitioner, Organization, and Study records already exist
-
Select Parent Organization Choose University of California, Berkeley.
-
Open Sub-Organization Click View for Berkeley Institute for Data Science (BIDS).
-
Create a New Study
- Under BIDS, create a study.
- Add the iHealth data source.
- Set the data scope to blood glucose.
-
Record the Study ID Open the newly created study and copy its Study ID (e.g.
30006). -
Run the Practitioner Upload Script Replace
<study_id>with the ID from step 4:python resources/practitioner_fhir_obs_upload.py \ --email [email protected] \ --study-id <study_id>
python resources/practitioner_fhir_obs_upload.py \
[--email <practitioner_email>] \
[--password <practitioner_password>] \
[--org-id <organization_id>] \
[--study-id <study_id>] \
[--patient-email <patient_email>]| Flag | Description | Default |
|---|---|---|
--email |
Practitioner login email | [email protected] |
--password |
Practitioner password | Jhe1234! |
--org-id |
Target organization ID | 20003 |
--study-id |
Target study ID (uses iHealth data source + blood-glucose scope) | 30006 |
--patient-email |
Patient email (lookup / create / enroll) | [email protected] |
If omitted, the defaults will be used. You may override any or all flags.
With all defaults:
python practitioner_fhir_obs_upload.pySpecifying all arguments:
python practitioner_fhir_obs_upload.py \
--email [email protected] \
--password "Sup3r$ecret!" \
--org-id 42 \
--study-id 99 \
--patient-email [email protected]The Django development server should not be used for production - more information is available at the official Django Deployment docs. One option included with JHE is the gunicorn server with WhiteNoise for static files, which can be run with the commands below.
$ python manage.py collectstatic --no-input
$ gunicorn --bind :8000 --workers 2 jhe.wsgiDue to browser security restrictions and the oidc-client-ts used for authentication, the web app must be accessed over HTTPS for any hostname other than localhost. Below is an example of serving the app over HTTPS using an NGINX reverse proxy.
$ sudo apt update
$ sudo apt install -y nginx certbot$ DOMAIN=YOUR_DOMAIN
$ sudo mkdir -p /var/www/certbot
$ sudo tee /etc/nginx/sites-available/jhe.conf >/dev/null <<NGINX
upstream jhe_app { server 127.0.0.1:8000; keepalive 16; }
server {
listen 80;
server_name ${DOMAIN};
# ACME HTTP-01
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# everything else HTTPS
location / { return 301 https://\$host\$request_uri; }
}
server {
listen 443 ssl http2;
server_name ${DOMAIN};
# temp self-signed while we fetch LE cert (optional if you already have none)
ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
client_max_body_size 25m;
location / {
proxy_pass http://jhe_app;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
NGINX
$ sudo ln -sf /etc/nginx/sites-available/jhe.conf /etc/nginx/sites-enabled/jhe.conf
$ sudo rm -f /etc/nginx/sites-enabled/default
$ sudo nginx -t && (sudo nginx -s reload || sudo nginx)$ sudo certbot certonly --webroot -w /var/www/certbot -d "$DOMAIN" --agree-tos -m admin@"$DOMAIN" --no-eff-email$ sudo sed -i "s#ssl-cert-snakeoil.pem#/etc/letsencrypt/live/$DOMAIN/fullchain.pem#g; s#ssl-cert-snakeoil.key#/etc/letsencrypt/live/$DOMAIN/privkey.pem#g" /etc/nginx/sites-available/jhe.conf
sudo nginx -t && sudo nginx -s reloadecho '0 3 * * * root certbot renew --quiet --post-hook "nginx -s reload"' | sudo tee /etc/cron.d/certbot_renew >/dev/null$ DOMAIN=YOUR_DOMAIN
# issue with manual DNS challenge
$ sudo certbot certonly --manual --preferred-challenges dns -d "$DOMAIN" --agree-tos -m admin@"$DOMAIN" --no-eff-email
# follow prompts to add the _acme-challenge TXT record, wait for DNS to propagate, then continue$ sudo tee /etc/nginx/sites-available/jhe.conf >/dev/null <<NGINX
upstream jhe_app { server 127.0.0.1:8000; keepalive 16; }
server {
listen 80;
server_name ${DOMAIN};
return 301 https://\$host\$request_uri;
}
server {
listen 443 ssl http2;
server_name ${DOMAIN};
ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
client_max_body_size 25m;
location / {
proxy_pass http://jhe_app;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
NGINX
$ sudo ln -sf /etc/nginx/sites-available/jhe.conf /etc/nginx/sites-enabled/jhe.conf
$ sudo rm -f /etc/nginx/sites-enabled/default
$ sudo nginx -t && (sudo nginx -s reload || sudo nginx)For hands-off renewals with DNS-01, use a Certbot DNS plugin for your provider (e.g., python3-certbot-dns-cloudflare, python3-certbot-dns-route53) and issue with that plugin once then renewals become automatic. An example for Cloudflare is below.
$ sudo apt install -y python3-certbot-dns-cloudflare
$ sudo tee /root/cf.ini >/dev/null <<EOF
dns_cloudflare_api_token = <TOKEN_WITH_DNS_EDIT>
EOF
$ sudo chmod 600 /root/cf.ini
$ sudo certbot certonly --dns-cloudflare --dns-cloudflare-credentials /root/cf.ini -d "$DOMAIN"Django is a mature and well-supported web framework but was specifically chosen due to resourcing requirements. There are a few accommodations that had to be made for Django to support FHIR as described below.
- FHIR uses camelCase whereas Django uses snake_case.
- The djangorestframework-camel-case library is used to support camelCase but the conversion happens downstream whereas the schema validation happens upstream, so manually calling
humpsis also required in parts.
-
The Django Rest Framework uses the concept of Serializers to validate schemas, whereas the FHIR validator uses Pydantic.
-
It is not reasonable to re-write the entire validation in the Serializer, so instead a combination of the two are used:
- Top-level fields (most importantly the
idof a record) are managed by the Serializer. - Nested fields (for example
code{}.coding[].systemabove) are configured as a JSON field in the Serializer (so the top level field is this example iscode) and then Pydantic is used to validate the whole schema including nested JSON.
- Top-level fields (most importantly the
-
There is a library that may allow Pydantic to be used as a Serializer but this needs to be explored further
- Postgres has rich JSON support allowing responses to be built directly from a raw Django SQL queries rather than using another layer of transforming logic.
A hard requirement was to avoid additional servers and frameworks (eg npm, react, etc) for the front end Web UI. Django supports traditional server-side templating but a modern Single Page App is better suited to this use case of interacting with the Admin REST API. For these reasons, a simple Vanilla JS SPA has been developed using handlebars to render client side views from static HTML served using Django templates. The only other additional dependencies are oidc-clinet-ts for auth and bootstrap for styling.
erDiagram
"JheUser (FHIR Person)" {
int id PK
string email UK
boolean email_is_verified
string identifier
string user_type
}
"Organization (FHIR Organization)" {
int id PK
string name
string type
int part_of_id FK
}
"Practitioner (FHIR Practitioner)" {
int id PK
int jhe_user_id FK
string identifier
string name_family
string name_given
date birth_date
string telecom_phone
datetime last_updated
}
"Patient (FHIR Patient)" {
int id PK
int jhe_user_id FK
string identifier
string name_family
string name_given
date birth_date
string telecom_phone
datetime last_updated
}
PractitionerOrganization {
int id PK
int practitioner_id FK
int organization_id FK
string role
}
PatientOrganization {
int id PK
int patient_id FK
int organization_id FK
}
"CodeableConcept (FHIR CodeableConcept)" {
int id PK
string coding_system
string coding_code
string text
}
"Study (FHIR Group)" {
int id PK
string name
string description
int organization_id FK
string icon_url
}
StudyPatient {
int id PK
int study_id FK
int patient_id FK
}
StudyPatientScopeConsent {
int id PK
int study_patient_id FK
string scope_actions
int scope_code_id FK
boolean consented
datetime consented_time
}
StudyScopeRequest {
int id PK
int study_id FK
string scope_actions
int scope_code_id FK
}
DataSource {
int id PK
string name
string type
}
DataSourceSupportedScope {
int id PK
int data_source_id FK
int scope_code_id FK
}
StudyDataSource {
int id PK
int study_id FK
int data_source_id FK
}
"Observation (FHIR Observation)" {
int id PK
int subject_patient_id FK
int codeable_concept_id FK
int data_source_id FK
string value_attachment_data
datetime last_updated
string status
}
ObservationIdentifier {
int id PK
int observation_id FK
string system
string value
}
"Organization (FHIR Organization)" ||--o{ "Organization (FHIR Organization)" : _
"JheUser (FHIR Person)" ||--|| "Practitioner (FHIR Practitioner)" : _
"JheUser (FHIR Person)" ||--|| "Patient (FHIR Patient)" : _
"Practitioner (FHIR Practitioner)" ||--o{ PractitionerOrganization : _
"Organization (FHIR Organization)" ||--o{ PractitionerOrganization : _
"Patient (FHIR Patient)" ||--o{ PatientOrganization : _
"Organization (FHIR Organization)" ||--o{ PatientOrganization : _
"Organization (FHIR Organization)" ||--o{ "Study (FHIR Group)" : _
"Study (FHIR Group)" ||--o{ StudyPatient : _
"Patient (FHIR Patient)" ||--o{ StudyPatient : _
StudyPatient ||--o{ StudyPatientScopeConsent : _
"CodeableConcept (FHIR CodeableConcept)" ||--o{ StudyPatientScopeConsent : _
"Study (FHIR Group)" ||--o{ StudyScopeRequest : _
"CodeableConcept (FHIR CodeableConcept)" ||--o{ StudyScopeRequest : _
DataSource ||--o{ DataSourceSupportedScope : _
"CodeableConcept (FHIR CodeableConcept)" ||--o{ DataSourceSupportedScope : _
"Study (FHIR Group)" ||--o{ StudyDataSource : _
DataSource ||--o{ StudyDataSource : _
"Patient (FHIR Patient)" ||--o{ "Observation (FHIR Observation)" : _
"CodeableConcept (FHIR CodeableConcept)" ||--o{ "Observation (FHIR Observation)" : _
DataSource ||--o{ "Observation (FHIR Observation)" : _
"Observation (FHIR Observation)" ||--o{ ObservationIdentifier : _
For deployment options and a comprehensive guide take a look at the official Django Deployment docs
An example Dockerfile is included to deploy the app using gunicorn and WhiteNoise for static files.
This image is published to ghcr.io/jupyterhealth/jupyterhealth-exchange.
- Create a new empty Postgres database
- Copy
dot_env_example.txtto.envand update theDB_*parameters from (1) and generate a new value forSECRET_KEY, e.g. withopenssl rand -base64 32. - start a container with the image, mounting your
.envfile, withTAG=sha-abc1234 docker run --rm -it -v$PWD/.env:/code/.env ghcr.io/jupyterhealth/jupyterhealth-exchange:$TAG bash - Migrate the DB by running
python manage.py migrate - Seed the database by running the Django management command
python manage.py seed_db - exit your setup container and launch a new container running the JupyterHealth Exchange
docker run -v$PWD/.env:/code/.env -p8000:8000 ghcr.io/jupyterhealth/jupyterhealth-exchange:$TAG
