JupyterHealth Exchange is a Django web application that facilitates sharing patient-consented medical data with authorized users via a web UI and REST, MCP, and FHIR APIs.
Researchers create studies and recruit patients, who consent and submit observations via client applications, and the data is then stored in JupyterHealth Exchange and queried by researchers using Jupyter Notebooks or other systems.
Typical Data Flow
Users manage the system via the Web UI, and data producers receive invitation credentials by email, manage consents through the Admin API, and upload data to JupyterHealth Exchange using the FHIR API. Data consumers such as Jupyter Notebooks or other systems then query and download the data through REST and MCP APIs.
Features include:
- OAuth 2.0, OIDC using django-oauth-toolkit and grafana-django-saml2-auth for SAML/SSO
- Simple Role Based Access Control
- FHIR R5 validation using fhir.resources
- Open mHealth validation using JSON schema
- 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
See CONTRIBUTING.md for test requirements, coding standards, and PR checklist.
See doc/TESTING.md for the full testing guide — unit tests, integration tests, smoke tests, and CI pipelines.
- v0.0.8 → v0.0.9 — DB-backed settings, multi-client support, breaking API changes
-
Set up your Python environment and install dependencies from
Pipfile- this project uses Django version 5.2 which requires python 3.12- 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)
-
Copy
dot_env_example.txtto.envand update theDB_*parameters to match (2) above.- Optionally you can add a Django
SECRET_KEYby running the command below or you can leave this for now to use a randomly generated value at runtime (this will not work with more than one worker)$ openssl rand -base64 32
- Optionally you can add a Django
-
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 runserver -
Browse to http://localhost:8000/admin and enter the credentials
sam@example.comJhe1234! -
Under Django OAuth Toolkit > Applications you should see the seeded OAuth2 application named
JHE Admin UIwith a Redirect URI forhttp://localhost:8000/auth/callback- this is used for the Web UI OAuth 2.0 login. -
Click the LOG OUT button at the top
-
Finally, we need to create an RS256 Private Key for signing the JWT
-
Run
openssl genrsa -out oidc.key 4096 -
Run
awk '{printf "%s%s", (NR==1?"":"\\n"), $0}' oidc.keyto remove line breaks Note: some python environments and OS combinations do not handle the "\n" so you may need to include line breaks in the.envfile. -
Return to the
.envfile and update theOIDC_RSA_PRIVATE_KEY -
Keep the
oidc.keysomewhere safe
-
-
Browse to http://localhost:8000/ and log in with the credentials
mary@example.comJhe1234!and you should be directed to the/portal/organizationspath with some example Organizations is the dropdown -
New users can be signed up from the base URL (eg http://localhost:8000/) with the default invitation code "jhe" which is set from the
REGISTRATION_INVITE_CODEin.env
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.
-
Control log verbosity – the backend reads the
DJANGO_LOG_LEVELenv with a default ifINFOfor both the root logger and the Django logger. -
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.
Entities are based on the HL7 FHIR model.
- 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 (ie invitation links) and has a longer expiration date (2 weeks).
- An Organization is a group of Practitioners, Patients and Studies (FHIR Groups).
- 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 Clients, one or more matching 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 personal 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.
- A Study has one or more associated Data Sources.
- Clients are apps that talk to JupyterHealth Exchange.
- Each Client has its own OAuth 2.0 Client ID.
- A Client has one or more associated Data Sources.
- In some cases a single app may be both a Data Source and a Client, in which case a record is created for each and both are added to the Study.
- A Study has one or more associated Clients.
- 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.
---
config:
flowchart:
nodeSpacing: 50
rankSpacing: 60
---
flowchart TD
%% node definitions
sam("SuperUser Sam<br/><small>sam@example.com</small>")
eri("Organization:<br/>Example Research Institute")
esds("Organization:<br/>Example School of Data Science")
eglab("Organization:<br/>Example Lab")
victor("ViewerVictor<br/><small>victor@example.com</small>")
megan("MemberMegan<br/><small>megan@example.com</small>")
mary("ManagerMary<br/><small>mary@example.com</small>")
tom("ThreeOrgTom<br/><small>tom@example.com</small>")
exStudyOnBPHR("Example Study on BP & HR<br/><small>Blood Pressure<br/>Heart Rate</small>")
exStudyOnBP("Example Study on BP<br/><small>Blood Pressure</small>")
peter("LabPatientPeter<br/><small>peter@example.com</small>")
pamela("LabPatientPamela<br/><small>pamela@example.com</small>")
eguni("Organization:<br/>Example University")
med("Organization:<br/>Example Department")
cardio("Organization:<br/>Heart Research Division")
mosl("Organization:<br/>Example Lab Alpha")
beta("Organization:<br/>Example Lab Beta")
mark("ManagerMark<br/><small>mark@example.com</small>")
exStudyOnRR("Example Study on RR<br/><small>Respiratory rate</small>")
exStudyOnBT("Example Study on BT<br/><small>Body Temperature</small>")
exStudyOnO2("Example Study on O2<br/><small>Oxygen Saturation</small>")
percy("AlphaPatientPercy<br/><small>percy@example.com</small>")
paul("BetaPatientPaul<br/><small>paul@example.com</small>")
pat("HeartBetaPatientPat<br/><small>pat@example.com</small>")
%% styles
style sam fill:#CFC
style mary fill:#CFC
style megan fill:#CFC
style victor fill:#CFC
style tom fill:#CFC
style mark fill:#CFC
style exStudyOnBPHR fill:#CFF
style exStudyOnBP fill:#CFF
style exStudyOnRR fill:#CFF
style exStudyOnBT fill:#CFF
style exStudyOnO2 fill:#CFF
style peter fill:#FCC
style pamela fill:#FCC
style percy fill:#FCC
style paul fill:#FCC
style pat fill:#FCC
%% example institute org hierarchy
eri --> esds
esds --> eglab
%% example institute user roles
eri -- Manager --> mary
esds -- Manager --> mary
eglab -- Viewer --> victor
eglab -- Member --> megan
eglab -- Viewer --> tom
eglab -- Manager --> mary
%% example institute studies & patients
eglab --> peter
eglab --> pamela
eglab --> exStudyOnBPHR
eglab --> exStudyOnBP
peter -- Consented --> exStudyOnBPHR
peter -- Requested --> exStudyOnBP
pamela -- Consented --> exStudyOnBPHR
pamela -- Consented --> exStudyOnBP
%% example university org hierarchy
eguni --> med
med --> cardio
cardio --> mosl
cardio --> beta
%% example university user roles
eguni -- Manager --> mark
med -- Manager --> mark
cardio -- Manager --> mark
mosl -- Manager --> mark
mosl -- Member --> tom
beta -- Manager --> tom
%% example university studies & patients
cardio --> exStudyOnRR
mosl --> exStudyOnBT
beta --> exStudyOnO2
mosl --> percy
beta --> paul
cardio --> pat
beta --> pat
percy -- Consented --> exStudyOnBT
paul -- Consented --> exStudyOnO2
pat -- Consented --> exStudyOnRR
pat -- Consented --> exStudyOnO2
- 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 "Example Research Institute" 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 and refresh and OIDC for ID tokens for both Practitioners (web login) and Patients (JHE Clients via secret invitation link)
-
Endpoints and configuration details can be discovered from the OIDC metadata endpoint:
/o/.well-known/openid-configuration -
Separate OAuth clients are created for the Web UI (Practitioner) and for individual JHE (Patient) Clients
-
In an effort to utilize the standard libraries (Django OAuth Toolkit) without too much modification, the JHE Client Authorization Code grant flow is divided into three parts:
- The first part of the flow used to generate the authorization code is instigated by the JHE server with no interaction from the client
- The authorization code is then shared with the client out-of-band (E-mail or SMS) as a secret invitation link
- The JHE Client at a later date then swaps the authorization code for an access token
-
Because the Patient authorization code is instigated by the server (Step 1 above) rather than a web browser, the PKCE code challenge and code verifier must be static values and travel with the authorization code. The Patient JHE Client then sends this
code_verifieralong with theauthorization_codeto obtain tokens. Theredirect_uriserves no purpose (as the initial authorization code has already been issued) but is required per OAuth spec so is defined as a static path (see below). -
Bringing this altogether, the JHE Client requires 4 values to the invitation code used in the link
host_with_port- The host name (and optionally:port) of the JHE to connect with, eg example.comclient_id- The OAuth 2.0 Client IDauthorization_code- The OAuth 2.0 authorization codecode_verifier- The OAuth 2.0 code verifier
https://app.tcp.org/invitation/jhe.tcp.org~G7lkfTooTemBHzfya2wpGOZZSIbbtPH6joiVZvCF~psJUkrTx9ZJ2KHyG5Iz9F2NzrKdCL3~7sMHvAWzSEKIj2tSifAIFTruBmfLYriljxVBI5NyrQ invitation_code = "jhe.tcp.org~G7lkfTooTemBHzfya2wpGOZZSIbbtPH6joiVZvCF~psJUkrTx9ZJ2KHyG5Iz9F2NzrKdCL3~7sMHvAWzSEKIj2tSifAIFTruBmfLYriljxVBI5NyrQ" host_with_port, client_id, authorization_code, code_verifier = invitation_code.split("~") post_url = parse(f"https://{host_with_port}/o/.well-known/openid-configuration").token_endpoint redirect_url = f"https://{host_with_port}/auth/callback" # Doesn't do anything but required by spec POST https://jhe.tcp.org/o/token/ Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&redirect_uri=https%3A%2F%2Fjhe.tcp.org%2Fauth%2Fcallback&client_id=G7lkfTooTemBHzfya2wpGOZZSIbbtPH6joiVZvCF&code=psJUkrTx9ZJ2KHyG5Iz9F2NzrKdCL3&code_verifier=7sMHvAWzSEKIj2tSifAIFTruBmfLYriljxVBI5NyrQ RESPONSE { "access_token":"1CxKTwNOK5vr0gnO6LY0ZfJF70prfy", "expires_in": 1209600, "token_type": "Bearer", "scope": "openid", "refresh_token": "DkZRFWWHB9Qndbv1WQn4UVOvKZbOz", "id_token": "eyJ0e..." } -
The returned
access_tokenshould be included in theAuthorizationheader for all subsequent API requests with the prefixBearer
Note
It is understood using static values for PKCE defeats the intended purpose but because the JHE Client authorization code generation is instigated by the server and shared out of band (rather than a web browser) dynamic PKCE can not be used.
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": "peter@example.com",
"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": "peter@example.com",
"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 Example Research Institute.
-
Open Sub-Organization Navigate to Example School of Data Science, then click View for Example Lab.
-
Create a New Study
- Under Example Lab, 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 mary@example.com \ --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 | obs-upload@example.com |
--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) | obs-upload-pat1@example.com |
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 practitioner@example.com \
--password "Sup3r$ecret!" \
--org-id 42 \
--study-id 99 \
--patient-email patient2@example.comThe 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
- 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.
- 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.

