Skip to content

Commit 9f6ca3a

Browse files
aweilandrcmerlofarmerpaulmbanting
authored
Release/2025.06.6 (#1910)
* feat: Enhances OneUp Health integration for document retrieval (M2-8883) (#1879) * Enhances OneUp Health integration for document retrieval Improves the OneUp Health integration by adding functionality to download and store patient documents associated with EHR data. This change introduces the ability to fetch documents referenced in DocumentReference resources, store them in a zip file, and upload the zip to the EHR storage. It also introduces generic file uploading and listing capabilities in the EHR storage, along with helper methods. * Adds metadata to EHR answers Adds metadata to the EHR answers table to store information about the uploaded zip files, including their names and sizes. This allows for better tracking and management of the EHR data stored in the system. * Adds assertion to task ingest user data Adds an assertion to check that the result of the `ingest_user_data` function is not None. This ensures that the task completes successfully and returns a valid result. * Update src/apps/integrations/oneup_health/service/oneup_health.py Co-authored-by: Farmer Paul <paul.hh@metalab.com> * Refactors EHR metadata handling Updates EHR metadata to use a dedicated data model. This change introduces `EHRFileMetadata` and `EHRFileTypeEnum` to provide more structured and type-safe handling of EHR file metadata, replacing the previous use of dictionaries. This improves code clarity and maintainability. * Adds provider name to EHR document filename Ensures that the EHR document filename includes the healthcare provider's name (or ID if name unavailable) to improve identification and organization. * Adds buckets for answers and operations Extends MinIO bucket creation to include buckets for answers and operations, in addition to the existing media bucket. This allows for a more organized storage structure within MinIO and improves separation of concerns. * Adds a TODO item to optimize zip file creation. Adds a TODO item to address potential memory issues when creating zip files, especially when dealing with large documents or numerous files. * Refactors EHR file upload process Streamlines the EHR file upload process by moving the base path generation logic into the `EHRStorage.upload_file` method. This change improves code maintainability and reduces redundancy. --------- Co-authored-by: Farmer Paul <paul.hh@metalab.com> * fix: Refactors ehr zip download to use storage path (M2-9443) (#1893) Simplifies the ehr zip download process by directly using the storage path. This eliminates redundant path construction and improves code readability. * chore: Added worker configuration to uvicorn startup (#1896) * fix: Optimizes schedule event retrieval for performance (#1897) * Optimizes event retrieval for schedule app Improves performance by fetching events and associated notifications/reminders in bulk using applet IDs. This reduces database queries and speeds up schedule loading. * Optimizes event data retrieval Reduces the number of database queries required to fetch event notifications and reminders by retrieving them in bulk. This change improves performance when fetching schedule events, especially when retrieving events for a large number of users. * fix: Temp fix for workers (#1902) * Fix: Enhances EHR exports to filter only allowed subjects answers (M2-9434) (#1894) * Adjusts retry delay for task processing errors Modifies the retry mechanism to use a linear delay after the first failed attempt. This avoids excessive wait times when processing tasks that encounter errors. * Filters EHR answers by allowed subjects Ensures that exported EHR answers are filtered based on the subjects the user is authorized to access. If a list of allowed subjects is available, it's added as a filter when querying EHR answers. * Refines subject ID filtering for EHR answers Ensures that the target subject IDs for EHR answer exports are filtered to only include IDs that are allowed for the user. This prevents unauthorized access to data associated with subjects outside the user's permitted scope. * Adds reviewer role to EHR data endpoint test Extends the EHR data endpoint test to include scenarios for users with the reviewer role. This ensures that reviewers can properly access and download EHR data relevant to their responsibilities. --------- Co-authored-by: Rodrigo Colao Merlo <rodrigo.merlo@metalab.com> Co-authored-by: Farmer Paul <paul.hh@metalab.com> Co-authored-by: Marty <mbanting@yahoo.com>
1 parent 978867d commit 9f6ca3a

14 files changed

Lines changed: 434 additions & 70 deletions

File tree

compose/minio/create_bucket.sh

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,24 @@
77

88
/usr/bin/mc config host add local http://minio:9000 minioaccess miniosecret;
99
#/usr/bin/mc rm -r --force local/${CDN__BUCKET};
10+
#media bucket
1011
/usr/bin/mc mb -p local/${CDN__BUCKET};
1112
/usr/bin/mc policy set download local/${CDN__BUCKET};
1213
/usr/bin/mc policy set public local/${CDN__BUCKET};
1314
/usr/bin/mc anonymous set upload local/${CDN__BUCKET};
1415
/usr/bin/mc anonymous set download local/${CDN__BUCKET};
1516
/usr/bin/mc anonymous set public local/${CDN__BUCKET};
17+
#answer bucket
18+
/usr/bin/mc mb -p local/${CDN__BUCKET_ANSWER};
19+
/usr/bin/mc policy set download local/${CDN__BUCKET_ANSWER};
20+
/usr/bin/mc policy set public local/${CDN__BUCKET_ANSWER};
21+
/usr/bin/mc anonymous set upload local/${CDN__BUCKET_ANSWER};
22+
/usr/bin/mc anonymous set download local/${CDN__BUCKET_ANSWER};
23+
#operations bucket
24+
/usr/bin/mc mb -p local/${CDN__BUCKET_OPERATIONS};
25+
/usr/bin/mc policy set download local/${CDN__BUCKET_OPERATIONS};
26+
/usr/bin/mc policy set public local/${CDN__BUCKET_OPERATIONS};
27+
/usr/bin/mc anonymous set upload local/${CDN__BUCKET_OPERATIONS};
28+
/usr/bin/mc anonymous set download local/${CDN__BUCKET_OPERATIONS};
1629

17-
exit 0;
30+
exit 0;

docker-compose.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ services:
118118
image: minio/mc
119119
container_name: mindlogger_minio_mc
120120
environment:
121-
CDN__BUCKET: "${CDN__BUCKET:-media}"
121+
CDN__BUCKET: "${CDN__BUCKET:-cmi-media-local}"
122+
CDN__BUCKET_ANSWER: "${CDN__BUCKET_ANSWER:-cmi-answer-local}"
123+
CDN__BUCKET_OPERATIONS: "${CDN__BUCKET_OPERATIONS:-cmi-ops-local}"
122124
depends_on:
123125
- minio
124126
volumes:

src/apps/answers/db/schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,4 @@ class AnswerEHRSchema(Base):
107107
activity_id = Column(UUID(as_uuid=True), index=True)
108108
ehr_storage_uri = Column(Text())
109109
ehr_ingestion_status = Column(Text())
110+
meta = Column(JSONB())

src/apps/answers/domain/answers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from apps.activity_flows.domain.flow_full import FlowFull, FlowHistoryWithActivityFlat, FlowHistoryWithActivityFull
1919
from apps.answers.domain.answer_items import AnswerItem, ItemAnswerCreate
2020
from apps.applets.domain.base import AppletBaseInfo
21+
from apps.integrations.oneup_health.service.domain import EHRMetadata
2122
from apps.integrations.prolific.domain import ProlificParamsActivityAnswer
2223
from apps.shared.domain import InternalModel, PublicModel, Response
2324
from apps.shared.domain.custom_validations import datetime_from_ms
@@ -748,6 +749,7 @@ class AnswerEHR(InternalModel):
748749
ehr_ingestion_status: EHRIngestionStatus
749750
activity_id: uuid.UUID
750751
ehr_storage_uri: str | None
752+
meta: EHRMetadata | None
751753

752754

753755
class AnswerEHRFull(AnswerEHR):

src/apps/answers/service.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2049,7 +2049,16 @@ async def get_submissions_metadata_by_subject(self, subject_id: uuid.UUID) -> Su
20492049
return submissions_activity_metadata
20502050

20512051
async def export_ehr_answers(self, applet_id: uuid.UUID, query_params: QueryParams) -> list[AnswerEHRFull]:
2052-
ehr_answers = await AnswersEHRCRUD(self.answer_session).export_ehr_answers(applet_id, **query_params.filters)
2052+
allowed_subjects = await self._get_allowed_subjects(applet_id)
2053+
2054+
filters = query_params.filters
2055+
if allowed_subjects is not None:
2056+
if target_subject_ids := filters.get("target_subject_ids"):
2057+
filters["target_subject_ids"] = list(set(allowed_subjects).intersection(set(target_subject_ids)))
2058+
else:
2059+
filters["target_subject_ids"] = allowed_subjects
2060+
2061+
ehr_answers = await AnswersEHRCRUD(self.answer_session).export_ehr_answers(applet_id, **filters)
20532062

20542063
return [AnswerEHRFull(**ehr_answer) for ehr_answer in ehr_answers]
20552064

src/apps/answers/tests/test_answers.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,9 @@ async def applet_one_sam_subject(session: AsyncSession, applet_one: AppletFull,
114114

115115

116116
@pytest.fixture
117-
async def bob_reviewer_in_applet_with_reviewable_activity(session, tom, bob, applet_with_reviewable_activity) -> User:
117+
async def bob_reviewer_in_applet_with_reviewable_activity(
118+
session: AsyncSession, tom, bob, applet_with_reviewable_activity
119+
) -> User:
118120
tom_subject = await SubjectsService(session, tom.id).get_by_user_and_applet(
119121
tom.id, applet_with_reviewable_activity.id
120122
)
@@ -281,6 +283,35 @@ async def tom_answer_on_reviewable_applet(
281283
)
282284

283285

286+
@pytest.fixture
287+
async def lucy_answer_on_reviewable_applet(
288+
session: AsyncSession,
289+
lucy_manager_in_applet_with_reviewable_activity: User,
290+
applet_with_reviewable_activity: AppletFull,
291+
) -> AnswerSchema:
292+
await UserAppletAccessService(
293+
session, lucy_manager_in_applet_with_reviewable_activity.id, applet_with_reviewable_activity.id
294+
).add_role(lucy_manager_in_applet_with_reviewable_activity.id, Role.RESPONDENT)
295+
296+
answer_service = AnswerService(session, lucy_manager_in_applet_with_reviewable_activity.id)
297+
return await answer_service.create_answer(
298+
AppletAnswerCreate(
299+
applet_id=applet_with_reviewable_activity.id,
300+
version=applet_with_reviewable_activity.version,
301+
submit_id=uuid.uuid4(),
302+
activity_id=applet_with_reviewable_activity.activities[0].id,
303+
answer=ItemAnswerCreate(
304+
item_ids=[applet_with_reviewable_activity.activities[0].items[0].id],
305+
start_time=datetime.datetime.now(datetime.UTC),
306+
end_time=datetime.datetime.now(datetime.UTC),
307+
user_public_key=str(lucy_manager_in_applet_with_reviewable_activity.id),
308+
),
309+
client=ClientMeta(app_id=f"{uuid.uuid4()}", app_version="1.1", width=984, height=623),
310+
consent_to_share=False,
311+
)
312+
)
313+
314+
284315
@pytest.fixture
285316
async def lucy_answer(session: AsyncSession, lucy: User, applet: AppletFull) -> AnswerSchema:
286317
answer_service = AnswerService(session, lucy.id)
@@ -4518,6 +4549,83 @@ def _mock_download_ehr_zip(storage_path, data, file_buffer):
45184549
assert file_list == file_names
45194550
assert str(tom_answer_activity_flow.submit_id) in file_names[0]
45204551

4552+
@pytest.mark.parametrize(
4553+
"user_fixture,role",
4554+
(
4555+
("tom", Role.OWNER),
4556+
("bob", Role.REVIEWER),
4557+
),
4558+
)
4559+
async def test_applet_ehr_data_endpoint_reviewer(
4560+
self,
4561+
client,
4562+
session,
4563+
tom,
4564+
bob_reviewer_in_applet_with_reviewable_activity,
4565+
lucy_answer_on_reviewable_applet,
4566+
tom_answer_on_reviewable_applet,
4567+
user_fixture,
4568+
role,
4569+
request,
4570+
):
4571+
# Create EHR record
4572+
answer_ehr = AnswerEHR(
4573+
submit_id=lucy_answer_on_reviewable_applet.submit_id,
4574+
ehr_ingestion_status=EHRIngestionStatus.COMPLETED,
4575+
ehr_storage_uri="fake/ehr/storage/uri",
4576+
activity_id=uuid.UUID(lucy_answer_on_reviewable_applet.activity_history_id.split("_")[0]),
4577+
)
4578+
await AnswersEHRCRUD(session=session).upsert(answer_ehr)
4579+
4580+
# Create EHR record
4581+
answer_ehr = AnswerEHR(
4582+
submit_id=tom_answer_on_reviewable_applet.submit_id,
4583+
ehr_ingestion_status=EHRIngestionStatus.COMPLETED,
4584+
ehr_storage_uri="tom/fake/ehr/storage/uri",
4585+
activity_id=uuid.UUID(tom_answer_on_reviewable_applet.activity_history_id.split("_")[0]),
4586+
)
4587+
await AnswersEHRCRUD(session=session).upsert(answer_ehr)
4588+
4589+
file_names = []
4590+
4591+
def _mock_download_ehr_zip(storage_path, data, file_buffer):
4592+
file_buffer.write(b"mocked EHR zip data")
4593+
file_buffer.seek(0)
4594+
4595+
file_name = EHRStorage.ehr_zip_filename(data)
4596+
file_names.append(file_name)
4597+
4598+
return file_name
4599+
4600+
with patch(
4601+
"apps.integrations.oneup_health.service.ehr_storage.EHRStorage.download_ehr_zip"
4602+
) as download_ehr_zip:
4603+
download_ehr_zip.side_effect = _mock_download_ehr_zip
4604+
4605+
login_user = request.getfixturevalue(user_fixture)
4606+
client.login(login_user)
4607+
4608+
# Request EHR data
4609+
response = await client.get(
4610+
self.applet_ehr_answers_export_url.format(applet_id=lucy_answer_on_reviewable_applet.applet_id)
4611+
)
4612+
assert response.status_code == 200
4613+
assert response.headers["Content-Type"] == "application/zip"
4614+
assert response.headers["Content-Disposition"] == "attachment; filename=EHR.zip"
4615+
4616+
response_body = response.read()
4617+
with zipfile.ZipFile(io.BytesIO(response_body), "r") as zip_file:
4618+
file_list = zip_file.namelist()
4619+
if role == Role.REVIEWER:
4620+
assert len(file_list) == 1
4621+
assert file_list == file_names
4622+
assert str(tom_answer_on_reviewable_applet.submit_id) in file_names[0]
4623+
else:
4624+
assert len(file_list) == 2
4625+
assert file_list == file_names
4626+
assert str(tom_answer_on_reviewable_applet.submit_id) in file_names[0]
4627+
assert str(lucy_answer_on_reviewable_applet.submit_id) in file_names[1]
4628+
45214629
@pytest.mark.asyncio
45224630
async def test_applet_ehr_data_endpoint_without_ehr(self, client, session, tom, tom_answer):
45234631
# Create EHR record

src/apps/integrations/oneup_health/service/domain.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import uuid
22
from datetime import datetime
3+
from enum import StrEnum
34

45
from pydantic import Field
56

@@ -15,3 +16,19 @@ class EHRData(InternalModel):
1516
activity_id: uuid.UUID
1617
target_subject_id: uuid.UUID
1718
user_id: uuid.UUID
19+
20+
21+
class EHRFileTypeEnum(StrEnum):
22+
DOCS = "DOCS"
23+
EHR = "EHR"
24+
25+
26+
class EHRFileMetadata(InternalModel):
27+
name: str
28+
size: int
29+
type: EHRFileTypeEnum
30+
31+
32+
class EHRMetadata(InternalModel):
33+
zip_files: list[EHRFileMetadata]
34+
storage_path: str

src/apps/integrations/oneup_health/service/ehr_storage.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@ def ehr_zip_filename(data: EHRData) -> str:
2727
)
2828
return filename
2929

30+
@staticmethod
31+
def docs_zip_filename(data: EHRData) -> str:
32+
provider_name = (
33+
slugify(data.healthcare_provider_name, separator="-")
34+
if data.healthcare_provider_name
35+
else data.healthcare_provider_id
36+
)
37+
filename = (
38+
f"{data.target_subject_id}_{data.activity_id}_"
39+
f"{data.submit_id}_{data.date.strftime('%Y%m%d')}_{provider_name}_DOCS.zip"
40+
)
41+
return filename
42+
3043
def _get_storage_path(self, base_path: str, key: str) -> str:
3144
index = key.find(base_path)
3245
if index == -1: # substring not found
@@ -59,7 +72,18 @@ async def upload_resources(self, data: EHRData) -> tuple[str, str]:
5972

6073
return self._get_storage_path(base_path, key), filename
6174

62-
async def upload_ehr_zip(self, resources_files: list[str], data: EHRData) -> str:
75+
async def upload_file(self, data: EHRData, filename: str, content: bytes) -> None:
76+
base_path = self._get_base_path(data)
77+
key = self._cdn_client.generate_key(FileScopeEnum.EHR, base_path, filename)
78+
79+
file_buffer = io.BytesIO(content)
80+
file_buffer.seek(0)
81+
82+
await self._cdn_client.upload(key, file_buffer)
83+
84+
file_buffer.close()
85+
86+
async def upload_ehr_zip(self, resources_files: list[str], data: EHRData) -> tuple[str, int]:
6387
base_path = self._get_base_path(data)
6488
filename = EHRStorage.ehr_zip_filename(data)
6589
key = self._cdn_client.generate_key(FileScopeEnum.EHR, base_path, filename)
@@ -80,8 +104,9 @@ async def upload_ehr_zip(self, resources_files: list[str], data: EHRData) -> str
80104
file_buffer.close()
81105

82106
zip_buffer.seek(0)
107+
file_size = zip_buffer.getbuffer().nbytes
83108
await self._cdn_client.upload(key, zip_buffer)
84-
return key
109+
return filename, file_size
85110
finally:
86111
zip_buffer.close()
87112

0 commit comments

Comments
 (0)