Skip to content

Commit 3a89587

Browse files
committed
add and exercise a few more api endpoints
- patient lookup by email - list data sources - filter patients by org and study - filter study by org
1 parent 0ecc9c8 commit 3a89587

File tree

6 files changed

+367
-12
lines changed

6 files changed

+367
-12
lines changed

jupyterhealth_client/_client.py

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -220,24 +220,61 @@ def get_patient(self, id: int) -> dict[str, Any]:
220220
patient = cast(dict[str, Any], self._api_request(f"patients/{id}"))
221221
return patient
222222

223+
def lookup_patient(
224+
self, *, email: str | None = None, external_id: str | None = None
225+
) -> dict[str, Any] | None:
226+
"""Lookup a patient by email or external id.
227+
228+
Raises KeyError if no patient is found, otherwise returns patient record.
229+
"""
230+
if email is None and external_id is None:
231+
raise TypeError(
232+
"Specify one of `email` or `external_id` to lookup a patient"
233+
)
234+
if email:
235+
for patient in self._api_request(
236+
"patients/global_lookup", params={"email": email}
237+
):
238+
return patient
239+
raise KeyError(
240+
f"No patient found with external identifier: {external_id!r}"
241+
)
242+
elif external_id:
243+
# TODO: this should be a single lookup, but no API in JHE yet
244+
for patient in self.list_patients():
245+
if patient["identifier"] == external_id:
246+
return patient
247+
raise KeyError(
248+
f"No patient found with external identifier: {external_id!r}"
249+
)
250+
223251
def get_patient_by_external_id(self, external_id: str) -> dict[str, Any]:
224252
"""Get a single patient by external id.
225253
226254
For looking up the JHE Patient record by an external (e.g. EHR) patient id.
227255
"""
256+
warnings.warn(
257+
f"get_patient_by_external_id is deprecated, use lookup_patient(external_id={external_id!r})",
258+
DeprecationWarning,
259+
stacklevel=2,
260+
)
261+
return self.lookup_patient(external_id=external_id)
228262

229-
# TODO: this should be a single lookup, but no API in JHE yet
230-
for patient in self.list_patients():
231-
if patient["identifier"] == external_id:
232-
return patient
233-
raise KeyError(f"No patient found with external identifier: {external_id!r}")
234-
235-
def list_patients(self) -> Generator[dict[str, dict[str, Any]]]:
263+
def list_patients(
264+
self, organization_id: int | None = None, study_id: int | None = None
265+
) -> Generator[dict[str, dict[str, Any]]]:
236266
"""Iterate over all patients.
237267
268+
Optionally filter by organization or study id.
269+
238270
Patient ids are the keys that may be passed to e.g. :meth:`list_observations`.
239271
"""
240-
yield from self._list_api_request("patients")
272+
params = {}
273+
if organization_id is not None:
274+
params["organization_id"] = organization_id
275+
if study_id is not None:
276+
params["study_id"] = study_id
277+
yield from self._list_api_request("patients", params=params)
241278

242279
def get_patient_consents(self, patient_id: int) -> dict[str, Any]:
243280
"""Return patient consent status.
@@ -330,12 +367,17 @@ def get_study(self, id: int) -> dict[str, Any]:
330367
"""
331368
return cast(dict[str, Any], self._api_request(f"studies/{id}"))
332369

333-
def list_studies(self) -> Generator[dict[str, dict[str, Any]]]:
370+
def list_studies(
371+
self, *, organization_id: int | None = None
372+
) -> Generator[dict[str, dict[str, Any]]]:
334373
"""Iterate over studies.
335374
336375
Only returns studies I have access to (i.e. owned by my organization(s)).
337376
"""
338-
return self._list_api_request("studies")
377+
params = {}
378+
if organization_id is not None:
379+
params["organization_id"] = organization_id
380+
return self._list_api_request("studies", params=params)
339381

340382
def get_organization(self, id: int) -> dict[str, Any]:
341383
"""Get a single organization by id.
@@ -366,6 +408,10 @@ def list_organizations(self) -> Generator[dict[str, dict[str, Any]]]:
366408
"""
367409
return self._list_api_request("organizations")
368410

411+
def list_data_sources(self) -> Generator[dict[str, dict[str, Any]]]:
412+
"""List all registered data sources"""
413+
return self._list_api_request("data_sources")
414+
369415
def list_observations(
370416
self,
371417
patient_id: int | None = None,

jupyterhealth_client/_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def flatten_dict(d: dict | list, prefix: str = "") -> dict:
3333
if prefix:
3434
key = f"{prefix}_{key}"
3535

36-
if isinstance(value, (dict, list)):
36+
if isinstance(value, dict | list):
3737
for sub_key, sub_value in flatten_dict(value, prefix=key).items():
3838
flat_dict[sub_key] = sub_value
3939
else:

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ Issues = "https://github.com/jupyterhealth/jupyterhealth-client/issues"
2727
Source = "https://github.com/jupyterhealth/jupyterhealth-client"
2828

2929
[project.optional-dependencies]
30-
test = ["pytest"]
30+
test = [
31+
"pytest",
32+
"pytest-cov",
33+
"responses",
34+
]
3135

3236
[build-system]
3337
requires = ["hatchling"]

tests/conftest.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
import yaml
5+
from responses import RequestsMock
6+
7+
from jupyterhealth_client import JupyterHealthClient
8+
9+
HOST = "https://jhe.local"
10+
11+
# load mocked responses from a file
12+
_response_yaml = Path(__file__).parent / "responses.yaml"
13+
with _response_yaml.open() as f:
14+
response_list = yaml.safe_load(f)
15+
16+
for response in response_list:
17+
response.setdefault("method", "GET")
18+
response.setdefault("status", 200)
19+
if "://" not in response["url"]:
20+
assert response["url"].startswith("/")
21+
response["url"] = HOST + response["url"]
22+
23+
24+
@pytest.fixture
25+
def responses():
26+
with RequestsMock(assert_all_requests_are_fired=False) as responses:
27+
for response in response_list:
28+
responses.add(**response)
29+
yield responses
30+
31+
32+
@pytest.fixture
33+
def jh_client(responses):
34+
return JupyterHealthClient(url=HOST, token="abc123")

tests/responses.yaml

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# mocked responses
2+
# optional:
3+
# - method (default: GET)
4+
# - status (default: 200)
5+
# required:
6+
# - url
7+
# - body OR json
8+
- method: GET
9+
url: /api/v1/users/profile
10+
status: 200
11+
json:
12+
id: 10008
13+
email: fake@email.com
14+
firstName: first
15+
lastName: last
16+
patient:
17+
userType: practitioner
18+
isSuperUser: false
19+
- url: /api/v1/organizations/20013
20+
json:
21+
children: []
22+
currentUserRole: manager
23+
id: 20013
24+
name: Department
25+
partOf: 20011
26+
type: edu
27+
- url: /api/v1/organizations/404
28+
status: 404
29+
- url: /api/v1/organizations
30+
json:
31+
count: 4
32+
next:
33+
previous:
34+
results:
35+
- children: []
36+
currentUserRole: manager
37+
id: 20013
38+
name: Department
39+
partOf: 20011
40+
type: edu
41+
- children: []
42+
currentUserRole:
43+
id: 0
44+
name: ROOT
45+
partOf:
46+
type: root
47+
- children: []
48+
currentUserRole:
49+
id: 20011
50+
name: University A
51+
partOf: 0
52+
type: edu
53+
- children: []
54+
currentUserRole:
55+
id: 20001
56+
name: University B
57+
partOf: 0
58+
type: edu
59+
60+
count: 8
61+
- url: /api/v1/studies
62+
json:
63+
count: 2
64+
next:
65+
previous:
66+
results:
67+
- description: Study description
68+
iconUrl:
69+
id: 30001
70+
name: ACME Health CGM Study
71+
organization:
72+
id: 20013
73+
name: Department
74+
type: edu
75+
- description: Another study
76+
iconUrl:
77+
id: 30012
78+
name: Study 2
79+
organization:
80+
id: 20001
81+
name: University B
82+
type: edu
83+
- url: /api/v1/studies?organization_id=20013
84+
json:
85+
count: 1
86+
next:
87+
previous:
88+
results:
89+
- description: Study description
90+
iconUrl:
91+
id: 30001
92+
name: ACME Health CGM Study
93+
organization:
94+
id: 20013
95+
name: Department
96+
type: edu
97+
- url: /api/v1/studies/30001
98+
json:
99+
description: Study description
100+
iconUrl:
101+
id: 30001
102+
name: ACME Health CGM Study
103+
organization:
104+
id: 20013
105+
name: Department
106+
type: edu
107+
- url: /api/v1/studies/404
108+
status: 404
109+
- url: /api/v1/studies?organization_id=404
110+
json:
111+
count: 0
112+
next:
113+
previous:
114+
results: []
115+
- url: /api/v1/patients
116+
json:
117+
count: 2
118+
next:
119+
previous:
120+
results:
121+
- birthDate: '1955-01-01'
122+
id: 40001
123+
identifier: demouser-min
124+
jheUserId: 10007
125+
nameFamily: Patient
126+
nameGiven: Demo
127+
organizations:
128+
- children: []
129+
currentUserRole:
130+
id: 20013
131+
name: Department
132+
partOf: 20011
133+
type: edu
134+
telecomEmail: demouser@jupyterhealth.example.org
135+
telecomPhone:
136+
- birthDate: '1980-01-01'
137+
id: 40002
138+
identifier: demouser-simon
139+
jheUserId: 10011
140+
nameFamily: Patient
141+
nameGiven: Simon
142+
organizations:
143+
- children: []
144+
currentUserRole:
145+
id: 20013
146+
name: Department
147+
partOf: 20011
148+
type: edu
149+
telecomEmail: simondemo@example.com
150+
telecomPhone:
151+
- url: /api/v1/patients/40001
152+
json:
153+
birthDate: '1955-01-01'
154+
id: 40001
155+
identifier: demouser-min
156+
jheUserId: 10007
157+
nameFamily: Patient
158+
nameGiven: Demo
159+
organizations:
160+
- children: []
161+
currentUserRole:
162+
id: 20013
163+
name: Department
164+
partOf: 20011
165+
type: edu
166+
telecomEmail: demouser@jupyterhealth.example.org
167+
telecomPhone:
168+
- url: /api/v1/patients/404
169+
status: 404
170+
json:
171+
detail: No patient matches the given query
172+
- url: /api/v1/patients/global_lookup?email=demouser%40jupyterhealth.example.org
173+
json:
174+
- id: 40001
175+
identifier: demouser-min
176+
jheUserId: 10007
177+
- url: /api/v1/patients/global_lookup?email=nosuchuser%40jupyterhealth.example.org
178+
json: []
179+
- url: /api/v1/data_sources
180+
json:
181+
count: 2
182+
next:
183+
previous:
184+
results:
185+
- id: 70002
186+
name: Another Device
187+
supportedScopes: []
188+
type: personal_device
189+
- id: 70001
190+
name: Some Device
191+
supportedScopes: []
192+
type: personal_device

0 commit comments

Comments
 (0)