Skip to content

Commit 3c684b7

Browse files
authored
VED-32 Minimise FHIR JSON in batch delete operation (#735)
1 parent e243652 commit 3c684b7

File tree

11 files changed

+142
-38
lines changed

11 files changed

+142
-38
lines changed

backend/src/forwarding_batch_lambda.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Functions for forwarding each row to the Imms API"""
1+
"""Lambda Handler which streams batch file entries from Kinesis and forwards to the Imms FHIR API"""
22

33
import os
44
import simplejson as json
@@ -47,12 +47,12 @@ def create_diagnostics_dictionary(error: Exception) -> dict:
4747

4848

4949
def forward_request_to_dynamo(
50-
message_body: any, table: any, is_present: bool, batchcontroller: ImmunizationBatchController
50+
message_body: any, table: any, is_present: bool, batch_controller: ImmunizationBatchController
5151
):
5252
"""Forwards the request to the Imms API (where possible) and updates the ack file with the outcome"""
5353
row_id = message_body.get("row_id")
5454
logger.info("FORWARDED MESSAGE: ID %s", row_id)
55-
return batchcontroller.send_request_to_dynamo(message_body, table, is_present)
55+
return batch_controller.send_request_to_dynamo(message_body, table, is_present)
5656

5757

5858
def forward_lambda_handler(event, _):
@@ -62,6 +62,7 @@ def forward_lambda_handler(event, _):
6262
array_of_messages = []
6363
array_of_identifiers = []
6464
controller = make_batch_controller()
65+
6566
for record in event["Records"]:
6667
try:
6768
kinesis_payload = record["kinesis"]["data"]

backend/tests/test_forwarding_batch_lambda.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,18 @@ def tearDown(self):
6767
patch.stopall()
6868

6969
@staticmethod
70-
def generate_fhir_json(include_fhir_json=True, identifier_value=None):
70+
def generate_fhir_json(include_fhir_json=True, identifier_value=None, operation_requested="CREATE"):
7171
"""Generates the fhir json for cases where included and None if excluded"""
72-
if include_fhir_json:
73-
fhir_json = copy.deepcopy(MockFhirImmsResources.all_fields)
72+
if not include_fhir_json:
73+
return None
7474

75-
if "identifier" in fhir_json and fhir_json["identifier"]:
76-
if identifier_value is not None:
77-
fhir_json["identifier"][0]["value"] = identifier_value
75+
fhir_json = copy.deepcopy(MockFhirImmsResources.all_fields) if operation_requested != "DELETE" else (
76+
copy.deepcopy(MockFhirImmsResources.delete_operation_fields))
7877

79-
return fhir_json
78+
if fhir_json.get("identifier") and identifier_value is not None:
79+
fhir_json["identifier"][0]["value"] = identifier_value
8080

81-
return None
81+
return fhir_json
8282

8383
@staticmethod
8484
def generate_details_from_processing(
@@ -90,7 +90,11 @@ def generate_details_from_processing(
9090
"local_id": local_id,
9191
}
9292
if include_fhir_json:
93-
details["fhir_json"] = TestForwardLambdaHandler.generate_fhir_json(include_fhir_json, identifier_value)
93+
details["fhir_json"] = TestForwardLambdaHandler.generate_fhir_json(
94+
include_fhir_json,
95+
identifier_value,
96+
operation_requested
97+
)
9498
return details
9599

96100
@staticmethod

backend/tests/utils/test_utils_for_batch.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,32 @@ class MockFhirImmsResources:
158158
}
159159
],
160160
}
161+
162+
# VED-32 Object for the delete batch operation will only contain the minimum fieldset
163+
delete_operation_fields = {
164+
"resourceType": "Immunization",
165+
"status": "completed",
166+
"protocolApplied": [
167+
{
168+
"targetDisease": [
169+
{
170+
"coding": [
171+
{
172+
"system": "http://snomed.info/sct",
173+
"code": "398102009",
174+
"display": "Acute poliomyelitis",
175+
}
176+
]
177+
}
178+
],
179+
"doseNumberPositiveInt": 1,
180+
}
181+
],
182+
"recorded": "2024-09-04",
183+
"identifier": [
184+
{
185+
"value": "RSV_002",
186+
"system": "https://www.ravs.england.nhs.uk/"
187+
}
188+
]
189+
}

recordprocessor/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,5 @@ COPY src .
2424
RUN chmod 644 $(find . -type f) && chmod 755 $(find . -type d)
2525
# Switch to the non-root user for running the container
2626
USER 1001:1001
27-
CMD ["python", "batch_processing.py"]
28-
ENTRYPOINT ["python", "batch_processing.py"]
27+
CMD ["python", "batch_processor.py"]
28+
ENTRYPOINT ["python", "batch_processor.py"]
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Functions for processing the file on a row-by-row basis"""
1+
"""Application to convert rows from batch files to FHIR and forward to Kinesis for further downstream processing"""
22

33
import json
44
import os
@@ -32,7 +32,7 @@ def process_csv_to_fhir(incoming_message_body: dict) -> None:
3232

3333
target_disease = map_target_disease(vaccine)
3434

35-
row_count = 0 # Initialize a counter for rows
35+
row_count = 0
3636
for row in csv_reader:
3737
row_count += 1
3838
row_id = f"{file_id}^{row_count}"

recordprocessor/src/convert_to_fhir_imms_resource.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
""" "Decorators to add the relevant fields to the FHIR immunization resource from the batch stream"""
1+
"""Decorators to add the relevant fields to the FHIR immunization resource from the batch stream"""
22

33
from typing import List, Callable, Dict
4+
45
from utils_for_fhir_conversion import _is_not_empty, Generate, Add, Convert
5-
from constants import Urls
6+
from constants import Operation, Urls
67

78

89
ImmunizationDecorator = Callable[[Dict, Dict[str, str]], None]
@@ -125,7 +126,7 @@ def _decorate_vaccination(imms: dict, row: Dict[str, str]) -> None:
125126
Add.custom_item(imms, "doseQuantity", dose_quantity_values, Generate.dictionary(dose_quantity_dict))
126127

127128
# If DOSE_SEQUENCE is empty, default FHIR "doseNumberString" to "Dose sequence not recorded",
128-
# otherwise assume the sender's intentiion is to supply a positive integer
129+
# otherwise assume the sender's intention is to supply a positive integer
129130
if _is_not_empty(dose_sequence := row.get("DOSE_SEQUENCE")):
130131
Add.item(imms["protocolApplied"][0], "doseNumberPositiveInt", dose_sequence, Convert.integer)
131132
else:
@@ -145,10 +146,10 @@ def _decorate_performer(imms: dict, row: Dict[str, str]) -> None:
145146
performing_prof_surname := row.get("PERFORMING_PROFESSIONAL_SURNAME"),
146147
performing_prof_forename := row.get("PERFORMING_PROFESSIONAL_FORENAME"),
147148
]
148-
peformer_values = organization_values + practitioner_values
149+
performer_values = organization_values + practitioner_values
149150

150151
# Add performer if there is at least one non-empty performer value
151-
if any(_is_not_empty(value) for value in peformer_values):
152+
if any(_is_not_empty(value) for value in performer_values):
152153
imms["performer"] = []
153154

154155
# Add organization if there is at least one non-empty organization value
@@ -195,7 +196,15 @@ def _decorate_performer(imms: dict, row: Dict[str, str]) -> None:
195196
]
196197

197198

198-
def convert_to_fhir_imms_resource(row: dict, target_disease: list) -> dict:
199+
def _get_decorators_for_action_flag(action_flag: Operation) -> List[ImmunizationDecorator]:
200+
# VED-32 DELETE action only requires the immunisation decorator
201+
if action_flag == Operation.DELETE:
202+
return [_decorate_immunization]
203+
204+
return all_decorators
205+
206+
207+
def convert_to_fhir_imms_resource(row: dict, target_disease: list, action_flag: Operation | str) -> dict:
199208
"""Converts a row of data to a FHIR Immunization Resource"""
200209
# Prepare the imms_resource. Note that all data sent via this service is assumed to be for completed vaccinations.
201210
imms_resource = {
@@ -204,8 +213,9 @@ def convert_to_fhir_imms_resource(row: dict, target_disease: list) -> dict:
204213
"protocolApplied": [{"targetDisease": target_disease}]
205214
}
206215

207-
# Apply all decorators to add the relevant fields to the imms_resource
208-
for decorator in all_decorators:
216+
required_decorators = _get_decorators_for_action_flag(action_flag)
217+
218+
for decorator in required_decorators:
209219
decorator(imms_resource, row)
210220

211221
return imms_resource

recordprocessor/src/process_row.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def process_row(target_disease: list, allowed_operations: set, row: dict) -> dic
4040
}
4141

4242
# Handle missing UNIQUE_ID or UNIQUE_ID_URI
43-
if not (row.get("UNIQUE_ID_URI") and row.get("UNIQUE_ID")):
43+
if not (unique_id_uri and unique_id):
4444
logger.error("Invalid row format: row is missing either UNIQUE_ID or UNIQUE_ID_URI")
4545
return {
4646
"diagnostics": create_diagnostics_dictionary("MISSING_UNIQUE_ID", 400, Diagnostics.MISSING_UNIQUE_ID),
@@ -50,7 +50,7 @@ def process_row(target_disease: list, allowed_operations: set, row: dict) -> dic
5050

5151
# Handle success
5252
return {
53-
"fhir_json": convert_to_fhir_imms_resource(row, target_disease),
53+
"fhir_json": convert_to_fhir_imms_resource(row, target_disease, action_flag),
5454
"operation_requested": operation_requested,
5555
"local_id": local_id,
5656
}

recordprocessor/tests/test_convert_to_fhir_imms_resource.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for convert_to_fhir_imms_resource"""
22
import unittest
3+
from typing import Tuple, List
34
from unittest.mock import patch
45

56
from tests.utils_for_recordprocessor_tests.values_for_recordprocessor_tests import (
@@ -10,7 +11,13 @@
1011
from tests.utils_for_recordprocessor_tests.mock_environment_variables import MOCK_ENVIRONMENT_DICT
1112

1213
with patch("os.environ", MOCK_ENVIRONMENT_DICT):
13-
from convert_to_fhir_imms_resource import convert_to_fhir_imms_resource
14+
from convert_to_fhir_imms_resource import (
15+
_decorate_immunization,
16+
_get_decorators_for_action_flag,
17+
all_decorators,
18+
convert_to_fhir_imms_resource,
19+
ImmunizationDecorator
20+
)
1421

1522

1623
class TestConvertToFhirImmsResource(unittest.TestCase):
@@ -22,22 +29,46 @@ def test_convert_to_fhir_imms_resource(self):
2229
outputted FHIR Immunization Resource matches the expected output.
2330
"""
2431

25-
# Test cases tuples are structure as (test_name, input_values, expected_output)
26-
cases = [
27-
("All fields", MockFieldDictionaries.all_fields, MockFhirImmsResources.all_fields),
32+
# Test cases tuples are structured as (test_name, input_values, expected_output, action_flag)
33+
test_cases = [
34+
("All fields", MockFieldDictionaries.all_fields, MockFhirImmsResources.all_fields, "UPDATE"),
2835
(
2936
"Mandatory fields only",
3037
MockFieldDictionaries.mandatory_fields_only,
3138
MockFhirImmsResources.mandatory_fields_only,
39+
"UPDATE"
3240
),
3341
(
3442
"Critical fields only",
3543
MockFieldDictionaries.critical_fields_only,
3644
MockFhirImmsResources.critical_fields,
45+
"NEW"
3746
),
47+
(
48+
"Delete action only converts minimal fields",
49+
MockFieldDictionaries.mandatory_fields_delete_action,
50+
MockFhirImmsResources.delete_operation_fields,
51+
"DELETE"
52+
)
3853
]
3954

40-
for test_name, input_values, expected_output in cases:
55+
for test_name, input_values, expected_output, action_flag in test_cases:
4156
with self.subTest(test_name):
42-
output = convert_to_fhir_imms_resource(input_values, TargetDiseaseElements.RSV)
57+
output = convert_to_fhir_imms_resource(input_values, TargetDiseaseElements.RSV, action_flag)
4358
self.assertEqual(output, expected_output)
59+
60+
def test_get_decorators_for_action_flag(self):
61+
"""
62+
Test that the _test_get_decorators_for_action_flag function returns the correct list of decorators based on the
63+
action flag provided.
64+
"""
65+
test_cases: List[Tuple[str, str, List[ImmunizationDecorator]]] = [
66+
("Delete action only returns one decorator", "DELETE", [_decorate_immunization]),
67+
("Update action returns all decorators", "UPDATE", all_decorators),
68+
("Create action returns all decorators", "CREATE", all_decorators)
69+
]
70+
71+
for test_name, action_flag, expected_decorators in test_cases:
72+
with self.subTest(test_name):
73+
result = _get_decorators_for_action_flag(action_flag)
74+
self.assertEqual(result, expected_decorators)

recordprocessor/tests/test_process_csv_to_fhir.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from tests.utils_for_recordprocessor_tests.mock_environment_variables import MOCK_ENVIRONMENT_DICT, BucketNames
1818

1919
with patch("os.environ", MOCK_ENVIRONMENT_DICT):
20-
from batch_processing import process_csv_to_fhir
20+
from batch_processor import process_csv_to_fhir
2121

2222

2323
s3_client = boto3.client("s3", region_name=REGION_NAME)
@@ -61,7 +61,7 @@ def test_process_csv_to_fhir_full_permissions(self):
6161
file_key=test_file.file_key, file_content=ValidMockFileContent.with_new_and_update_and_delete
6262
)
6363

64-
with patch("batch_processing.send_to_kinesis") as mock_send_to_kinesis:
64+
with patch("batch_processor.send_to_kinesis") as mock_send_to_kinesis:
6565
process_csv_to_fhir(deepcopy(test_file.event_full_permissions_dict))
6666

6767
self.assertEqual(mock_send_to_kinesis.call_count, 3)
@@ -75,7 +75,7 @@ def test_process_csv_to_fhir_partial_permissions(self):
7575
file_key=test_file.file_key, file_content=ValidMockFileContent.with_new_and_update_and_delete
7676
)
7777

78-
with patch("batch_processing.send_to_kinesis") as mock_send_to_kinesis:
78+
with patch("batch_processor.send_to_kinesis") as mock_send_to_kinesis:
7979
process_csv_to_fhir(deepcopy(test_file.event_create_permissions_only_dict))
8080

8181
self.assertEqual(mock_send_to_kinesis.call_count, 3)
@@ -84,7 +84,7 @@ def test_process_csv_to_fhir_no_permissions(self):
8484
"""Tests that process_csv_to_fhir does not send fhir_json to kinesis when the supplier has no permissions"""
8585
self.upload_source_file(file_key=test_file.file_key, file_content=ValidMockFileContent.with_update_and_delete)
8686

87-
with patch("batch_processing.send_to_kinesis") as mock_send_to_kinesis:
87+
with patch("batch_processor.send_to_kinesis") as mock_send_to_kinesis:
8888
process_csv_to_fhir(deepcopy(test_file.event_create_permissions_only_dict))
8989

9090
self.assertEqual(mock_send_to_kinesis.call_count, 2)
@@ -99,7 +99,7 @@ def test_process_csv_to_fhir_invalid_headers(self):
9999
file_content=ValidMockFileContent.with_new_and_update.replace("NHS_NUMBER", "NHS_NUMBERS"),
100100
)
101101

102-
with patch("batch_processing.send_to_kinesis") as mock_send_to_kinesis:
102+
with patch("batch_processor.send_to_kinesis") as mock_send_to_kinesis:
103103
process_csv_to_fhir(deepcopy(test_file.event_full_permissions_dict))
104104

105105
self.assertEqual(mock_send_to_kinesis.call_count, 0)

recordprocessor/tests/test_recordprocessor_main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
with patch("os.environ", MOCK_ENVIRONMENT_DICT):
2828
from constants import Diagnostics
29-
from batch_processing import main
29+
from batch_processor import main
3030

3131
s3_client = boto3_client("s3", region_name=REGION_NAME)
3232
kinesis_client = boto3_client("kinesis", region_name=REGION_NAME)

0 commit comments

Comments
 (0)