diff --git a/mypy.ini b/mypy.ini index 65580f2a7..a67b4299d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -44,3 +44,6 @@ warn_unreachable = False [mypy-*.factories] disallow_untyped_calls = False + +[mypy-*.fhir.tests.*] +disable_error_code = index,union-attr,arg-type diff --git a/opal/services/fhir/fhir.py b/opal/services/fhir/fhir.py index d6c560233..a4a50b7e7 100644 --- a/opal/services/fhir/fhir.py +++ b/opal/services/fhir/fhir.py @@ -6,7 +6,7 @@ import datetime as dt from datetime import datetime -from typing import Any +from typing import Any, cast import structlog from authlib.integrations.requests_client import OAuth2Session @@ -147,7 +147,9 @@ def patient_conditions(self, uuid: str) -> list[Condition]: _clean_coding(coding) conditions_bundle = Bundle.model_validate(data) - return [condition.resource for condition in conditions_bundle.entry or []] + return [ + cast('Condition', condition.resource) for condition in conditions_bundle.entry or [] if condition.resource + ] def patient_medication_requests(self, uuid: str) -> list[MedicationRequest]: """ @@ -168,7 +170,11 @@ def patient_medication_requests(self, uuid: str) -> list[MedicationRequest]: medications_bundle = Bundle.model_validate(data) - return [medication.resource for medication in medications_bundle.entry or []] + return [ + cast('MedicationRequest', medication.resource) + for medication in medications_bundle.entry or [] + if medication.resource + ] def patient_allergies(self, uuid: str) -> list[AllergyIntolerance]: """ @@ -195,7 +201,9 @@ def patient_allergies(self, uuid: str) -> list[AllergyIntolerance]: _clean_coding(coding) allergies_bundle = Bundle.model_validate(data) - return [allergy.resource for allergy in allergies_bundle.entry or []] + return [ + cast('AllergyIntolerance', allergy.resource) for allergy in allergies_bundle.entry or [] if allergy.resource + ] def patient_immunizations(self, uuid: str) -> list[Immunization]: """ @@ -225,7 +233,11 @@ def patient_immunizations(self, uuid: str) -> list[Immunization]: immunizations_bundle = Bundle.model_validate(data) - return [immunization.resource for immunization in immunizations_bundle.entry or []] + return [ + cast('Immunization', immunization.resource) + for immunization in immunizations_bundle.entry or [] + if immunization.resource + ] def patient_observations(self, uuid: str) -> list[Observation]: """ @@ -245,4 +257,8 @@ def patient_observations(self, uuid: str) -> list[Observation]: data = response.json() observations_bundle = Bundle.model_validate(data) - return [observation.resource for observation in observations_bundle.entry or []] + return [ + cast('Observation', observation.resource) + for observation in observations_bundle.entry or [] + if observation.resource + ] diff --git a/opal/services/fhir/ips.py b/opal/services/fhir/ips.py index 825c49620..a1812362a 100644 --- a/opal/services/fhir/ips.py +++ b/opal/services/fhir/ips.py @@ -58,12 +58,16 @@ def build_patient_summary( # noqa: PLR0913, PLR0917 vital_signs = [ observation for observation in observations_with_category - if observation.category[0].coding[0].code == 'vital-signs' + if observation.category + and observation.category[0].coding + and observation.category[0].coding[0].code == 'vital-signs' ] labs = [ observation for observation in observations_with_category - if observation.category[0].coding[0].code == 'laboratory' + if observation.category + and observation.category[0].coding + and observation.category[0].coding[0].code == 'laboratory' ] # Translates static strings in the right language for the bundle @@ -86,102 +90,101 @@ def build_patient_summary( # noqa: PLR0913, PLR0917 date=dt.datetime.now(tz=dt.UTC).replace(microsecond=0), title=_('International Patient Summary as of {date}').format(date=last_updated), subject=Reference(reference=f'urn:uuid:{patient.id}'), - section=[ - CompositionSection( - title=_('Active Problems'), - code=CodeableConcept( - coding=[Coding(system='http://loinc.org', code='11450-4', display='Problem list Reported')] - ), - entry=[ - Reference(reference=f'urn:uuid:{condition.id}') - for condition in conditions - if condition.clinicalStatus.coding[0].code == 'active' - ], + ) + composition.section = [ + CompositionSection( + title=_('Active Problems'), + code=CodeableConcept( + coding=[Coding(system='http://loinc.org', code='11450-4', display='Problem list Reported')] ), - CompositionSection( - title=_('Past Medical History'), - code=CodeableConcept( - coding=[ - Coding(system='http://loinc.org', code='11348-0', display='History of Past illness note') - ] - ), - entry=[ - Reference(reference=f'urn:uuid:{condition.id}') - for condition in conditions - if condition.clinicalStatus.coding[0].code != 'active' - ], + entry=[ + Reference(reference=f'urn:uuid:{condition.id}') + for condition in conditions + if condition.clinicalStatus + and condition.clinicalStatus.coding + and condition.clinicalStatus.coding[0].code == 'active' + ], + ), + CompositionSection( + title=_('Past Medical History'), + code=CodeableConcept( + coding=[Coding(system='http://loinc.org', code='11348-0', display='History of Past illness note')] ), - CompositionSection( - title=_('Medication'), - code=CodeableConcept( - coding=[ - Coding( - system='http://loinc.org', code='10160-0', display='History of Medication use Narrative' - ) - ], - ), - entry=[ - Reference(reference=f'urn:uuid:{medication_request.id}') - for medication_request in medication_requests + entry=[ + Reference(reference=f'urn:uuid:{condition.id}') + for condition in conditions + if condition.clinicalStatus + and condition.clinicalStatus.coding + and condition.clinicalStatus.coding[0].code != 'active' + ], + ), + CompositionSection( + title=_('Medication'), + code=CodeableConcept( + coding=[ + Coding(system='http://loinc.org', code='10160-0', display='History of Medication use Narrative') ], ), - CompositionSection( - title=_('Allergies and Intolerances'), - code=CodeableConcept( - coding=[ - Coding( - system='http://loinc.org', - code='48765-2', - display='Allergies and adverse reactions Document', - ) - ] - ), - entry=[Reference(reference=f'urn:uuid:{allergy.id}') for allergy in allergies], + entry=[ + Reference(reference=f'urn:uuid:{medication_request.id}') + for medication_request in medication_requests + ], + ), + CompositionSection( + title=_('Allergies and Intolerances'), + code=CodeableConcept( + coding=[ + Coding( + system='http://loinc.org', + code='48765-2', + display='Allergies and adverse reactions Document', + ) + ] ), - CompositionSection( - title=_('Vital Signs'), - code=CodeableConcept( - coding=[Coding(system='http://loinc.org', code='8716-3', display='Vital signs note')] - ), - entry=[Reference(reference=f'urn:uuid:{vital_sign.id}') for vital_sign in vital_signs], + entry=[Reference(reference=f'urn:uuid:{allergy.id}') for allergy in allergies], + ), + CompositionSection( + title=_('Vital Signs'), + code=CodeableConcept( + coding=[Coding(system='http://loinc.org', code='8716-3', display='Vital signs note')] ), - CompositionSection( - title=_('Laboratory Results'), - code=CodeableConcept( - coding=[ - Coding( - system='http://loinc.org', - code='30954-2', - display='Relevant diagnostic tests/laboratory data note', - ) - ] - ), - entry=[Reference(reference=f'urn:uuid:{lab.id}') for lab in labs], + entry=[Reference(reference=f'urn:uuid:{vital_sign.id}') for vital_sign in vital_signs], + ), + CompositionSection( + title=_('Laboratory Results'), + code=CodeableConcept( + coding=[ + Coding( + system='http://loinc.org', + code='30954-2', + display='Relevant diagnostic tests/laboratory data note', + ) + ] ), - CompositionSection( - title=_('Immunizations'), - code=CodeableConcept( - coding=[ - Coding(system='http://loinc.org', code='11369-6', display='History of Immunization note') - ] - ), - entry=[Reference(reference=f'urn:uuid:{immunization.id}') for immunization in immunizations], + entry=[Reference(reference=f'urn:uuid:{lab.id}') for lab in labs], + ), + CompositionSection( + title=_('Immunizations'), + code=CodeableConcept( + coding=[Coding(system='http://loinc.org', code='11369-6', display='History of Immunization note')] ), - ], - ) + entry=[Reference(reference=f'urn:uuid:{immunization.id}') for immunization in immunizations], + ), + ] ips = Bundle( identifier={'system': 'urn:oid:2.16.724.4.8.10.200.10', 'value': f'{uuid.uuid4()}'}, type='document', language=ips_language, timestamp=dt.datetime.now(tz=dt.UTC).replace(microsecond=0), - entry=[ - BundleEntry(resource=composition, fullUrl=f'urn:uuid:{composition.id}'), - BundleEntry(resource=patient, fullUrl=f'urn:uuid:{patient.id}'), - BundleEntry(resource=generator, fullUrl=f'urn:uuid:{generator.id}'), - ], ) + ips.entry = [ + BundleEntry(resource=composition, fullUrl=f'urn:uuid:{composition.id}'), + BundleEntry(resource=patient, fullUrl=f'urn:uuid:{patient.id}'), + BundleEntry(resource=generator, fullUrl=f'urn:uuid:{generator.id}'), + ] + ips.entry.extend( BundleEntry(resource=condition, fullUrl=f'urn:uuid:{condition.id}') for condition in conditions ) @@ -200,7 +203,7 @@ def build_patient_summary( # noqa: PLR0913, PLR0917 # add narrative for empty entries for section in composition.section: - if not section.entry: + if not section.entry and section.title: no_information = _("There is no information available about the subject's {category}.").format( category=section.title.lower() ) diff --git a/opal/services/fhir/tests/test_ips.py b/opal/services/fhir/tests/test_ips.py index a3b62d7b8..ca591ad78 100644 --- a/opal/services/fhir/tests/test_ips.py +++ b/opal/services/fhir/tests/test_ips.py @@ -64,7 +64,7 @@ def _prepare_build_patient_summary() -> tuple[ immunizations, ) - return (summary, patient, conditions, medication_requests, allergies, observations, immunizations) + return (summary, patient, conditions, medication_requests, allergies, observations, immunizations) # type: ignore[return-value] def test_build_patient_summary() -> None: @@ -140,7 +140,7 @@ def test_build_patient_summary_composition() -> None: # The section's resources are referenced if resources: assert len(section.entry) == len(resources) - for entry, resource in zip(section.entry, resources, strict=True): + for entry, resource in zip(section.entry, resources, strict=True): # type:ignore[call-overload] assert entry.reference == f'urn:uuid:{resource.id}', ( f'Section {section.title} has an incorrect resource reference' ) @@ -206,11 +206,11 @@ def test_build_patient_summary_resources_included() -> None: resource_ids = {entry.resource.id for entry in summary.entry[3:]} expected_ids: set[str] = set() - expected_ids.update(condition.id for condition in conditions) - expected_ids.update(medication_request.id for medication_request in medication_requests) - expected_ids.update(allergy.id for allergy in allergies) - expected_ids.update(observation.id for observation in observations if observation.category) - expected_ids.update(immunization.id for immunization in immunizations) + expected_ids.update(condition.id for condition in conditions) # type: ignore[misc] + expected_ids.update(medication_request.id for medication_request in medication_requests) # type: ignore[misc] + expected_ids.update(allergy.id for allergy in allergies) # type: ignore[misc] + expected_ids.update(observation.id for observation in observations if observation.category) # type: ignore[misc] + expected_ids.update(immunization.id for immunization in immunizations) # type: ignore[misc] assert resource_ids == expected_ids diff --git a/opal/services/fhir/utils.py b/opal/services/fhir/utils.py index 1209a0968..20b1df878 100644 --- a/opal/services/fhir/utils.py +++ b/opal/services/fhir/utils.py @@ -6,7 +6,6 @@ import logging import secrets -import uuid import structlog from authlib.oauth2 import OAuth2Error @@ -48,7 +47,7 @@ def jwe_sh_link_encrypt(data: str) -> tuple[str, bytes]: def retrieve_patient_summary( oauth_url: str, fhir_url: str, client_id: str, private_key: str, identifier: str -) -> tuple[str, uuid.UUID]: +) -> tuple[str, str]: """ Retrieve patient data and build a patient summary in IPS format for a patient identified by their identifier. @@ -83,6 +82,10 @@ def retrieve_patient_summary( patient = fhir.find_patient(identifier) patient_uuid = patient.id + + if not patient_uuid: + raise FHIRDataRetrievalError(f'Patient with identifier {identifier} has no UUID') + conditions = fhir.patient_conditions(patient_uuid) medication_requests = fhir.patient_medication_requests(patient_uuid) allergies = fhir.patient_allergies(patient_uuid) @@ -113,4 +116,7 @@ def retrieve_patient_summary( LOGGER.debug('Successfully built IPS bundle for patient with UUID %s', patient_uuid) - return ips_bundle.model_dump_json(indent=2), ips_bundle.identifier.value + # we know that there is an identifier because it is set in the build_patient_summary function + ips_uuid: str = ips_bundle.identifier.value # type: ignore[assignment,union-attr] + + return ips_bundle.model_dump_json(indent=2), ips_uuid diff --git a/pyproject.toml b/pyproject.toml index 911db31f8..f3ca1f442 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "django-tables2==2.8.0", "djangorestframework==3.16.1", "drf-spectacular==0.29.0", - "fhir-resources==8.0.0", + "fhir-resources==8.1.0", "fpdf2==2.8.5", "hl7apy==1.3.5", # required for image export in plotly @@ -88,3 +88,6 @@ docs = [ "mkdocs-material==9.7.1", "mkdocstrings[python-legacy]==0.30.1", ] + +[tool.uv.sources] +fhir-resources = { git = "https://github.com/nazrulworld/fhir.resources.git", rev = "9a2eec1774913dbd89816e9a1cfcda22cdd4388e" } diff --git a/uv.lock b/uv.lock index 24d19f7fb..8568b3b9a 100644 --- a/uv.lock +++ b/uv.lock @@ -105,7 +105,7 @@ requires-dist = [ { name = "django-tables2", specifier = "==2.8.0" }, { name = "djangorestframework", specifier = "==3.16.1" }, { name = "drf-spectacular", specifier = "==0.29.0" }, - { name = "fhir-resources", specifier = "==8.0.0" }, + { name = "fhir-resources", git = "https://github.com/nazrulworld/fhir.resources.git?rev=9a2eec1774913dbd89816e9a1cfcda22cdd4388e" }, { name = "fpdf2", specifier = "==2.8.5" }, { name = "gunicorn", marker = "extra == 'prod'", specifier = "==23.0.0" }, { name = "hl7apy", specifier = "==1.3.5" }, @@ -899,15 +899,11 @@ wheels = [ [[package]] name = "fhir-resources" -version = "8.0.0" -source = { registry = "https://pypi.org/simple" } +version = "8.1.1.dev0" +source = { git = "https://github.com/nazrulworld/fhir.resources.git?rev=9a2eec1774913dbd89816e9a1cfcda22cdd4388e#9a2eec1774913dbd89816e9a1cfcda22cdd4388e" } dependencies = [ { name = "fhir-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/12/342da7d972e39268b1993392d104b92c3b8878e8d818414ad26bd98aeb2a/fhir.resources-8.0.0.tar.gz", hash = "sha256:84dac3af31eaf90d5b0386cac21d26c50e6fb1526d68b88a2c42d112978e9cf9", size = 1526522, upload-time = "2024-12-25T16:11:33.633Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/fd/c44dfd6fd69ad526c815879cecca537450d727cd023426f4bf06879f4c27/fhir.resources-8.0.0-py2.py3-none-any.whl", hash = "sha256:9c46d6d79c6d6629c3bea6f244bcc6e8e0e4d15757a675f19d9d1c05c9ab2199", size = 2200379, upload-time = "2024-12-25T16:11:30.454Z" }, -] [[package]] name = "filelock"