Skip to content

Commit 01d5557

Browse files
authored
Merge branch 'master' into ved-1138-allow-create-to-reinstate-deleted-records
2 parents 4dfbb21 + 1de68e9 commit 01d5557

17 files changed

Lines changed: 352 additions & 153 deletions

File tree

.github/workflows/dependabot-auto-approve.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
- name: Fetch Dependabot metadata
1515
id: metadata
1616
continue-on-error: true
17-
uses: dependabot/fetch-metadata@ffa630c65fa7e0ecfa0625b5ceda64399aea1b36
17+
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98
1818

1919
- name: Auto-approve minor and patch updates
2020
if: steps.metadata.outcome == 'success' && contains(fromJSON('["version-update:semver-minor", "version-update:semver-patch"]'), steps.metadata.outputs.update-type)

.github/workflows/quality-checks.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
steps:
1919
- uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98
2020

21-
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f
21+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
2222
with:
2323
node-version: "23.11.0"
2424
cache: "npm"

config/dev/permissions_config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"FLU.CRUDS",
4747
"HPV.CRUDS",
4848
"MENACWY.CRUDS",
49+
"MENB.CRUDS",
4950
"MMR.CRUDS",
5051
"MMRV.CRUDS",
5152
"PERTUSSIS.CRUDS",

config/preprod/permissions_config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"FLU.CRUDS",
4444
"HPV.CRUDS",
4545
"MENACWY.CRUDS",
46+
"MENB.CRUDS",
4647
"MMR.CRUDS",
4748
"MMRV.CRUDS",
4849
"PERTUSSIS.CRUDS",

config/prod/permissions_config.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@
2525
},
2626
{
2727
"supplier": "RAVS",
28-
"permissions": ["MMR.CRUDS", "RSV.CRUDS", "PNEUMOCOCCAL.CRUDS"],
28+
"permissions": [
29+
"MENB.CRUDS",
30+
"MMR.CRUDS",
31+
"RSV.CRUDS",
32+
"PNEUMOCOCCAL.CRUDS"
33+
],
2934
"ods_codes": ["X26", "X8E5B"]
3035
},
3136
{

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) -> tuple
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

@@ -155,10 +183,7 @@ def create_immunization(self, immunization: dict, supplier_system: str) -> tuple
155183
return created_id, 1
156184

157185
def update_immunization(self, imms_id: str, immunization: dict, supplier_system: str, resource_version: int) -> int:
158-
try:
159-
self.validator.validate(immunization)
160-
except (ValueError, MandatoryError) as error:
161-
raise CustomValidationError(message=str(error)) from error
186+
self._validate_immunization(immunization)
162187

163188
immunization_to_update = Immunization.parse_obj(immunization)
164189

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

@@ -364,6 +370,27 @@ def test_create_immunization(self):
364370
self.assertEqual(self._MOCK_NEW_UUID, created_id)
365371
self.assertEqual(1, created_version)
366372

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

616+
def test_update_immunization_keeps_first_site_and_route_snomed_coding(self):
617+
"""it should keep the first SNOMED coding for site and route during API update"""
618+
imms_id = "an-id"
619+
original_immunisation = create_covid_immunization_dict(imms_id, VALID_NHS_NUMBER)
620+
identifier = Identifier(
621+
system=original_immunisation["identifier"][0]["system"],
622+
value=original_immunisation["identifier"][0]["value"],
623+
)
624+
updated_immunisation = create_covid_immunization_dict(imms_id, VALID_NHS_NUMBER, "2021-02-07T13:28:00+00:00")
625+
first_site_coding = add_snomed_coding(
626+
updated_immunisation, "site", "999999999", "Replacement site that should be ignored"
627+
)
628+
first_route_coding = add_snomed_coding(
629+
updated_immunisation, "route", "888888888", "Replacement route that should be ignored"
630+
)
631+
existing_resource_meta = ImmunizationRecordMetadata(
632+
identifier=identifier, resource_version=1, is_deleted=False, is_reinstated=False
633+
)
634+
635+
self.imms_repo.get_immunization_resource_and_metadata_by_id.return_value = (
636+
original_immunisation,
637+
existing_resource_meta,
638+
)
639+
self.imms_repo.update_immunization.return_value = 2
640+
self.authoriser.authorise.return_value = True
641+
642+
updated_version = self.fhir_service.update_immunization(imms_id, updated_immunisation, "Test", 1)
643+
644+
self.assertEqual(updated_version, 2)
645+
self.assertEqual(updated_immunisation["site"]["coding"], [first_site_coding])
646+
self.assertEqual(updated_immunisation["route"]["coding"], [first_route_coding])
647+
self.imms_repo.update_immunization.assert_called_once()
648+
589649
def test_update_immunization_raises_validation_exception_when_nhs_number_invalid(self):
590650
"""it should raise a CustomValidationError when the patient's NHS number in the payload is invalid"""
591651
imms_id = "an-id"
Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,29 @@
11
# Main validation engine
2+
from typing import Any
3+
24
import exception_messages
35
from conversion_layout import ConversionField, ConversionLayout
46
from extractor import Extractor
57
from mappings import ActionFlag
68

9+
ConversionErrorRecord = dict[str, Any]
10+
ConvertedRecord = dict[str, Any]
11+
712

813
class Converter:
9-
def __init__(self, fhir_data, action_flag=ActionFlag.UPDATE, report_unexpected_exception=True):
10-
self.converted = {}
11-
self.error_records = []
14+
def __init__(self, fhir_data: str | dict[str, Any], action_flag: str = ActionFlag.UPDATE) -> None:
15+
self.converted: ConvertedRecord = {}
16+
self.error_records: list[ConversionErrorRecord] = []
1217
self.action_flag = action_flag
13-
self.report_unexpected_exception = report_unexpected_exception
14-
15-
try:
16-
if not fhir_data:
17-
raise ValueError("FHIR data is required for initialization.")
1818

19-
self.extractor = Extractor(fhir_data, self.report_unexpected_exception)
20-
self.conversion_layout = ConversionLayout(self.extractor)
21-
except Exception as e:
22-
if report_unexpected_exception:
23-
self._log_error(f"Initialization failed: [{e.__class__.__name__}] {e}")
24-
raise
19+
if not fhir_data:
20+
raise ValueError("FHIR data is required for initialization.")
2521

26-
def run_conversion(self):
27-
conversions = self.conversion_layout.get_conversion_layout()
22+
self.extractor = Extractor(fhir_data)
23+
self.conversion_layout = ConversionLayout(self.extractor)
2824

29-
for conversion in conversions:
25+
def run_conversion(self) -> ConvertedRecord:
26+
for conversion in self.conversion_layout.get_conversion_layout():
3027
self._convert_data(conversion)
3128

3229
self.error_records.extend(self.extractor.get_error_records())
@@ -35,29 +32,33 @@ def run_conversion(self):
3532
self.converted["CONVERSION_ERRORS"] = self.error_records
3633
return self.converted
3734

38-
def _convert_data(self, conversion: ConversionField):
39-
try:
40-
flat_field = conversion.field_name_flat
35+
def _convert_data(self, conversion: ConversionField) -> None:
36+
flat_field = conversion.field_name_flat
4137

38+
try:
4239
if flat_field == "ACTION_FLAG":
4340
self.converted[flat_field] = self.action_flag
44-
else:
45-
converted = conversion.expression_rule()
46-
if converted is not None:
47-
self.converted[flat_field] = converted
41+
return
4842

49-
except Exception as e:
43+
if (converted := conversion.expression_rule()) is not None:
44+
self.converted[flat_field] = converted
45+
except Exception as error:
5046
self._log_error(
51-
f"Conversion error [{e.__class__.__name__}]: {e}",
47+
flat_field,
48+
f"Conversion error [{error.__class__.__name__}]: {error}",
5249
code=exception_messages.PARSING_ERROR,
5350
)
5451
self.converted[flat_field] = ""
5552

56-
def _log_error(self, e, code=exception_messages.UNEXPECTED_EXCEPTION):
57-
error_obj = {"code": code, "message": str(e)}
58-
59-
if self.report_unexpected_exception:
60-
self.error_records.append(error_obj)
53+
def _log_error(self, field_name: str, e: Exception | str, code: str) -> None:
54+
self.error_records.append(
55+
{
56+
"code": code,
57+
"field": field_name,
58+
"value": None,
59+
"message": str(e),
60+
}
61+
)
6162

62-
def get_error_records(self):
63+
def get_error_records(self) -> list[ConversionErrorRecord]:
6364
return self.error_records

lambdas/delta_backend/src/extractor.py

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,12 @@ class Extractor:
2222
DATE_CONVERT_FORMAT = "%Y%m%d"
2323
DEFAULT_POSTCODE = "ZZ99 3CZ"
2424

25-
def __init__(self, fhir_json_data, report_unexpected_exception=True):
25+
def __init__(self, fhir_json_data):
2626
self.fhir_json_data = (
2727
json.loads(fhir_json_data, parse_float=decimal.Decimal)
2828
if isinstance(fhir_json_data, str)
2929
else fhir_json_data
3030
)
31-
self.report_unexpected_exception = report_unexpected_exception
3231
self.error_records = []
3332

3433
def _get_patient(self):
@@ -174,23 +173,20 @@ def _get_site_information(self):
174173
return site_code, site_code_type_uri
175174

176175
def _log_error(self, field_name, field_value, e, code=exception_messages.RECORD_CHECK_FAILED):
177-
if self.report_unexpected_exception:
178-
if isinstance(e, Exception):
179-
message = exception_messages.MESSAGES[exception_messages.UNEXPECTED_EXCEPTION] % (
180-
e.__class__.__name__,
181-
str(e),
182-
)
183-
else:
184-
message = str(e)
185-
186-
self.error_records.append(
187-
{
188-
"code": code,
189-
"field": field_name,
190-
"value": field_value,
191-
"message": message,
192-
}
193-
)
176+
message = (
177+
exception_messages.MESSAGES[exception_messages.UNEXPECTED_EXCEPTION] % (e.__class__.__name__, str(e))
178+
if isinstance(e, Exception)
179+
else str(e)
180+
)
181+
182+
self.error_records.append(
183+
{
184+
"code": code,
185+
"field": field_name,
186+
"value": field_value,
187+
"message": message,
188+
}
189+
)
194190

195191
def _convert_date(self, field_name, date, format) -> str:
196192
"""

0 commit comments

Comments
 (0)