Skip to content

Commit 8aa400a

Browse files
committed
Enhance FhirService to retain first SNOMED coding for site and route during immunization creation and update
1 parent 304096e commit 8aa400a

3 files changed

Lines changed: 117 additions & 9 deletions

File tree

lambdas/backend/src/service/fhir_service.py

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from authorisation.api_operation_code import ApiOperationCode
2323
from authorisation.authoriser import Authoriser
2424
from common.get_service_url import get_service_url
25-
from common.models.constants import Constants
25+
from common.models.constants import Constants, Urls
2626
from common.models.errors import (
2727
Code,
2828
CustomValidationError,
@@ -62,6 +62,7 @@ class FhirService:
6262
_DATA_MISSING_DATE_TIME_ERROR_MSG = (
6363
"Data quality issue - immunisation with ID %s was found containing no occurrenceDateTime"
6464
)
65+
_SINGLE_SNOMED_CODEABLE_CONCEPT_FIELDS = ("site", "route")
6566

6667
def __init__(
6768
self,
@@ -73,6 +74,36 @@ def __init__(
7374
self.immunization_repo = imms_repo
7475
self.validator = validator
7576

77+
@staticmethod
78+
def _keep_first_snomed_coding(coding: list) -> list:
79+
snomed_seen = False
80+
filtered_coding = []
81+
for coding_entry in coding:
82+
is_snomed_coding = isinstance(coding_entry, dict) and coding_entry.get("system") == Urls.SNOMED
83+
if is_snomed_coding and snomed_seen:
84+
continue
85+
86+
snomed_seen = snomed_seen or is_snomed_coding
87+
filtered_coding.append(coding_entry)
88+
89+
return filtered_coding
90+
91+
@classmethod
92+
def _normalize_single_snomed_codeable_concepts(cls, immunization: dict) -> None:
93+
for field_name in cls._SINGLE_SNOMED_CODEABLE_CONCEPT_FIELDS:
94+
field = immunization.get(field_name)
95+
coding = field.get("coding") if isinstance(field, dict) else None
96+
if isinstance(coding, list):
97+
field["coding"] = cls._keep_first_snomed_coding(coding)
98+
99+
def _validate_immunization(self, immunization: dict) -> None:
100+
self._normalize_single_snomed_codeable_concepts(immunization)
101+
102+
try:
103+
self.validator.validate(immunization)
104+
except (ValueError, MandatoryError) as error:
105+
raise CustomValidationError(message=str(error)) from error
106+
76107
def get_immunization_by_identifier(
77108
self, identifier: Identifier, supplier_name: str, elements: set[str] | None
78109
) -> FhirBundle:
@@ -117,10 +148,7 @@ def create_immunization(self, immunization: dict, supplier_system: str) -> Id:
117148
if immunization.get("id") is not None:
118149
raise CustomValidationError("id field must not be present for CREATE operation")
119150

120-
try:
121-
self.validator.validate(immunization)
122-
except (ValueError, MandatoryError) as error:
123-
raise CustomValidationError(message=str(error)) from error
151+
self._validate_immunization(immunization)
124152

125153
vaccination_type = get_vaccine_type(immunization)
126154

@@ -139,10 +167,7 @@ def create_immunization(self, immunization: dict, supplier_system: str) -> Id:
139167
return self.immunization_repo.create_immunization(immunization_fhir_entity, supplier_system)
140168

141169
def update_immunization(self, imms_id: str, immunization: dict, supplier_system: str, resource_version: int) -> int:
142-
try:
143-
self.validator.validate(immunization)
144-
except (ValueError, MandatoryError) as error:
145-
raise CustomValidationError(message=str(error)) from error
170+
self._validate_immunization(immunization)
146171

147172
immunization_to_update = Immunization.parse_obj(immunization)
148173

lambdas/backend/tests/service/test_fhir_service.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@
3636
NHS_NUMBER_USED_IN_SAMPLE_DATA = "9000000009"
3737

3838

39+
def add_snomed_coding(immunization: dict, field_name: str, code: str, display: str) -> dict:
40+
first_coding = deepcopy(immunization[field_name]["coding"][0])
41+
immunization[field_name]["coding"].append({"system": first_coding["system"], "code": code, "display": display})
42+
return first_coding
43+
44+
3945
class TestFhirServiceBase(unittest.TestCase):
4046
"""Base class for all tests to set up common fixtures"""
4147

@@ -362,6 +368,27 @@ def test_create_immunization(self):
362368
self.validator.validate.assert_called_once_with(req_imms)
363369
self.assertEqual(self._MOCK_NEW_UUID, created_id)
364370

371+
def test_create_immunization_keeps_first_site_and_route_snomed_coding(self):
372+
"""it should keep the first SNOMED coding for site and route during API create"""
373+
self.mock_redis.hget.return_value = "COVID"
374+
self.mock_redis_getter.return_value = self.mock_redis
375+
self.authoriser.authorise.return_value = True
376+
self.imms_repo.check_immunization_identifier_exists.return_value = False
377+
self.imms_repo.create_immunization.return_value = self._MOCK_NEW_UUID
378+
379+
req_imms = create_covid_immunization_dict_no_id(VALID_NHS_NUMBER)
380+
first_site_coding = add_snomed_coding(req_imms, "site", "999999999", "Replacement site that should be ignored")
381+
first_route_coding = add_snomed_coding(
382+
req_imms, "route", "888888888", "Replacement route that should be ignored"
383+
)
384+
385+
created_id = self.pre_validate_fhir_service.create_immunization(req_imms, "Test")
386+
387+
self.assertEqual(self._MOCK_NEW_UUID, created_id)
388+
self.assertEqual(req_imms["site"]["coding"], [first_site_coding])
389+
self.assertEqual(req_imms["route"]["coding"], [first_route_coding])
390+
self.imms_repo.create_immunization.assert_called_once_with(Immunization.parse_obj(req_imms), "Test")
391+
365392
def test_create_immunization_with_id_throws_error(self):
366393
"""it should throw exception if id present in create Immunization"""
367394
imms = create_covid_immunization_dict("an-id", "9990548609")
@@ -539,6 +566,39 @@ def test_update_immunization(self):
539566
self.assertEqual(call_args[3], "Test")
540567
self.authoriser.authorise.assert_called_once_with("Test", ApiOperationCode.UPDATE, {"COVID"})
541568

569+
def test_update_immunization_keeps_first_site_and_route_snomed_coding(self):
570+
"""it should keep the first SNOMED coding for site and route during API update"""
571+
imms_id = "an-id"
572+
original_immunisation = create_covid_immunization_dict(imms_id, VALID_NHS_NUMBER)
573+
identifier = Identifier(
574+
system=original_immunisation["identifier"][0]["system"],
575+
value=original_immunisation["identifier"][0]["value"],
576+
)
577+
updated_immunisation = create_covid_immunization_dict(imms_id, VALID_NHS_NUMBER, "2021-02-07T13:28:00+00:00")
578+
first_site_coding = add_snomed_coding(
579+
updated_immunisation, "site", "999999999", "Replacement site that should be ignored"
580+
)
581+
first_route_coding = add_snomed_coding(
582+
updated_immunisation, "route", "888888888", "Replacement route that should be ignored"
583+
)
584+
existing_resource_meta = ImmunizationRecordMetadata(
585+
identifier=identifier, resource_version=1, is_deleted=False, is_reinstated=False
586+
)
587+
588+
self.imms_repo.get_immunization_resource_and_metadata_by_id.return_value = (
589+
original_immunisation,
590+
existing_resource_meta,
591+
)
592+
self.imms_repo.update_immunization.return_value = 2
593+
self.authoriser.authorise.return_value = True
594+
595+
updated_version = self.fhir_service.update_immunization(imms_id, updated_immunisation, "Test", 1)
596+
597+
self.assertEqual(updated_version, 2)
598+
self.assertEqual(updated_immunisation["site"]["coding"], [first_site_coding])
599+
self.assertEqual(updated_immunisation["route"]["coding"], [first_route_coding])
600+
self.imms_repo.update_immunization.assert_called_once()
601+
542602
def test_update_immunization_raises_validation_exception_when_nhs_number_invalid(self):
543603
"""it should raise a CustomValidationError when the patient's NHS number in the payload is invalid"""
544604
imms_id = "an-id"

lambdas/recordforwarder/tests/service/test_fhir_batch_service.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
from test_common.testing_utils.immunization_utils import create_covid_immunization_dict_no_id
1111

1212

13+
def duplicate_first_coding(immunization: dict, field_name: str) -> None:
14+
immunization[field_name]["coding"].append(deepcopy(immunization[field_name]["coding"][0]))
15+
16+
1317
class TestFhirBatchServiceBase(unittest.TestCase):
1418
"""Base class for all tests to set up common fixtures"""
1519

@@ -97,6 +101,25 @@ def test_create_immunization_post_validation_error(self):
97101
self.assertTrue(expected_msg in error.exception.message)
98102
self.mock_repo.create_immunization.assert_not_called()
99103

104+
def test_create_immunization_duplicate_site_snomed_still_rejected_for_batch(self):
105+
"""it should keep batch validation unchanged for duplicate site SNOMED codings"""
106+
107+
imms = create_covid_immunization_dict_no_id()
108+
duplicate_first_coding(imms, "site")
109+
expected_msg = "Validation errors: site.coding[?(@.system=='http://snomed.info/sct')] must be unique"
110+
111+
with self.assertRaises(CustomValidationError) as error:
112+
self.pre_validate_fhir_service.create_immunization(
113+
immunization=imms,
114+
supplier_system="test_supplier",
115+
vax_type="test_vax",
116+
table=self.mock_table,
117+
imms_pk=None,
118+
)
119+
120+
self.assertEqual(expected_msg, error.exception.message)
121+
self.mock_repo.create_immunization.assert_not_called()
122+
100123

101124
class TestUpdateImmunizationBatchService(TestFhirBatchServiceBase):
102125
def setUp(self):

0 commit comments

Comments
 (0)