Skip to content

Commit 093bf80

Browse files
authored
Merge branch 'master' into dependabot/github_actions/docker/setup-buildx-action-4.0.0
2 parents fd8fbaf + 7c404ee commit 093bf80

7 files changed

Lines changed: 244 additions & 42 deletions

File tree

lambdas/backend/src/controller/fhir_controller.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,17 @@ def create_immunization(self, aws_event: APIGatewayProxyEventV1) -> dict:
8989
except JSONDecodeError as e:
9090
raise InvalidJsonError(message=str(f"Request's body contains malformed JSON: {e}"))
9191

92-
created_resource_id = self.fhir_service.create_immunization(immunisation, supplier_system)
92+
created_resource_id, created_resource_version = self.fhir_service.create_immunization(
93+
immunisation, supplier_system
94+
)
9395

9496
return create_response(
9597
status_code=201,
9698
body=None,
97-
headers={"Location": f"{self._API_SERVICE_URL}/Immunization/{created_resource_id}", "E-Tag": "1"},
99+
headers={
100+
"Location": f"{self._API_SERVICE_URL}/Immunization/{created_resource_id}",
101+
"E-Tag": str(created_resource_version),
102+
},
98103
)
99104

100105
@fhir_api_exception_handler

lambdas/backend/src/service/fhir_service.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
import os
55
import uuid
6-
from typing import Any, cast
6+
from typing import Any
77
from uuid import uuid4
88

99
from fhir.resources.R4B.bundle import (
@@ -144,7 +144,7 @@ def get_immunization_and_version_by_id(self, imms_id: str, supplier_system: str)
144144

145145
return Immunization.parse_obj(resource), str(immunization_metadata.resource_version)
146146

147-
def create_immunization(self, immunization: dict, supplier_system: str) -> Id:
147+
def create_immunization(self, immunization: dict, supplier_system: str) -> tuple[Id, int]:
148148
if immunization.get("id") is not None:
149149
raise CustomValidationError("id field must not be present for CREATE operation")
150150

@@ -155,16 +155,32 @@ def create_immunization(self, immunization: dict, supplier_system: str) -> Id:
155155
if not self.authoriser.authorise(supplier_system, ApiOperationCode.CREATE, {vaccination_type}):
156156
raise UnauthorizedVaxError()
157157

158-
# Set ID for the requested new record
159-
immunization["id"] = str(uuid.uuid4())
158+
identifier = Identifier.parse_obj(immunization["identifier"][0])
159+
duplicate_identifier = f"{identifier.system}#{identifier.value}"
160160

161-
immunization_fhir_entity = Immunization.parse_obj(immunization)
162-
identifier = cast(Identifier, immunization_fhir_entity.identifier[0])
161+
existing_immunization_resource, existing_immunization_meta = (
162+
self.immunization_repo.get_immunization_by_identifier(identifier)
163+
)
164+
if existing_immunization_resource:
165+
if not existing_immunization_meta.is_deleted:
166+
raise IdentifierDuplicationError(identifier=duplicate_identifier)
167+
168+
immunization_id = existing_immunization_resource["id"]
169+
immunization["id"] = immunization_id
170+
immunization_fhir_entity = Immunization.parse_obj(immunization)
171+
updated_version = self.immunization_repo.update_immunization(
172+
immunization_id,
173+
immunization_fhir_entity,
174+
existing_immunization_meta,
175+
supplier_system,
176+
)
177+
return immunization_id, updated_version
163178

164-
if self.immunization_repo.check_immunization_identifier_exists(identifier.system, identifier.value):
165-
raise IdentifierDuplicationError(identifier=f"{identifier.system}#{identifier.value}")
179+
immunization["id"] = str(uuid.uuid4())
180+
immunization_fhir_entity = Immunization.parse_obj(immunization)
166181

167-
return self.immunization_repo.create_immunization(immunization_fhir_entity, supplier_system)
182+
created_id = self.immunization_repo.create_immunization(immunization_fhir_entity, supplier_system)
183+
return created_id, 1
168184

169185
def update_immunization(self, imms_id: str, immunization: dict, supplier_system: str, resource_version: int) -> int:
170186
self._validate_immunization(immunization)

lambdas/backend/tests/controller/test_fhir_controller.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -529,7 +529,7 @@ def test_create_immunization(self):
529529
"headers": {"SupplierSystem": "Test"},
530530
"body": imms.json(),
531531
}
532-
self.service.create_immunization.return_value = imms_id
532+
self.service.create_immunization.return_value = (imms_id, 1)
533533

534534
response = self.controller.create_immunization(aws_event)
535535

@@ -538,6 +538,23 @@ def test_create_immunization(self):
538538
self.assertEqual(response["statusCode"], 201)
539539
self.assertTrue("body" not in response)
540540
self.assertTrue(response["headers"]["Location"].endswith(f"Immunization/{imms_id}"))
541+
self.assertEqual(response["headers"]["E-Tag"], "1")
542+
543+
def test_create_immunization_returns_current_version_when_reinstating_deleted_record(self):
544+
"""it should return the existing resource location and current version for a reinstated create request"""
545+
imms_id = str(uuid.uuid4())
546+
imms = create_covid_immunization(imms_id)
547+
aws_event = {
548+
"headers": {"SupplierSystem": "Test"},
549+
"body": imms.json(),
550+
}
551+
self.service.create_immunization.return_value = (imms_id, 3)
552+
553+
response = self.controller.create_immunization(aws_event)
554+
555+
self.assertEqual(response["statusCode"], 201)
556+
self.assertTrue(response["headers"]["Location"].endswith(f"Immunization/{imms_id}"))
557+
self.assertEqual(response["headers"]["E-Tag"], "3")
541558

542559
def test_create_immunization_returns_unauthorised_error_when_supplier_system_header_missing(self):
543560
"""it should return unauthorized error"""

lambdas/backend/tests/service/test_fhir_service.py

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -349,31 +349,33 @@ def test_create_immunization(self):
349349
self.mock_redis.hget.return_value = "COVID"
350350
self.mock_redis_getter.return_value = self.mock_redis
351351
self.authoriser.authorise.return_value = True
352-
self.imms_repo.check_immunization_identifier_exists.return_value = False
352+
self.imms_repo.get_immunization_by_identifier.return_value = (None, None)
353353
self.imms_repo.create_immunization.return_value = self._MOCK_NEW_UUID
354354

355355
nhs_number = VALID_NHS_NUMBER
356356
req_imms = create_covid_immunization_dict_no_id(nhs_number)
357357

358358
# When
359-
created_id = self.fhir_service.create_immunization(req_imms, "Test")
359+
created_id, created_version = self.fhir_service.create_immunization(req_imms, "Test")
360360

361361
# Then
362362
self.authoriser.authorise.assert_called_once_with("Test", ApiOperationCode.CREATE, {"COVID"})
363-
self.imms_repo.check_immunization_identifier_exists.assert_called_once_with(
364-
"https://supplierABC/identifiers/vacc", "ACME-vacc123456"
365-
)
363+
self.imms_repo.get_immunization_by_identifier.assert_called_once()
364+
create_identifier = self.imms_repo.get_immunization_by_identifier.call_args.args[0]
365+
self.assertEqual(create_identifier.system, "https://supplierABC/identifiers/vacc")
366+
self.assertEqual(create_identifier.value, "ACME-vacc123456")
366367
self.imms_repo.create_immunization.assert_called_once_with(Immunization.parse_obj(req_imms), "Test")
367368

368369
self.validator.validate.assert_called_once_with(req_imms)
369370
self.assertEqual(self._MOCK_NEW_UUID, created_id)
371+
self.assertEqual(1, created_version)
370372

371373
def test_create_immunization_keeps_first_site_and_route_snomed_coding(self):
372374
"""it should keep the first SNOMED coding for site and route during API create"""
373375
self.mock_redis.hget.return_value = "COVID"
374376
self.mock_redis_getter.return_value = self.mock_redis
375377
self.authoriser.authorise.return_value = True
376-
self.imms_repo.check_immunization_identifier_exists.return_value = False
378+
self.imms_repo.get_immunization_by_identifier.return_value = (None, None)
377379
self.imms_repo.create_immunization.return_value = self._MOCK_NEW_UUID
378380

379381
req_imms = create_covid_immunization_dict_no_id(VALID_NHS_NUMBER)
@@ -382,9 +384,10 @@ def test_create_immunization_keeps_first_site_and_route_snomed_coding(self):
382384
req_imms, "route", "888888888", "Replacement route that should be ignored"
383385
)
384386

385-
created_id = self.pre_validate_fhir_service.create_immunization(req_imms, "Test")
387+
created_id, created_version = self.pre_validate_fhir_service.create_immunization(req_imms, "Test")
386388

387389
self.assertEqual(self._MOCK_NEW_UUID, created_id)
390+
self.assertEqual(1, created_version)
388391
self.assertEqual(req_imms["site"]["coding"], [first_site_coding])
389392
self.assertEqual(req_imms["route"]["coding"], [first_route_coding])
390393
self.imms_repo.create_immunization.assert_called_once_with(Immunization.parse_obj(req_imms), "Test")
@@ -496,7 +499,15 @@ def test_raises_duplicate_error_if_identifier_already_exits(self):
496499
self.mock_redis.hget.return_value = "COVID"
497500
self.mock_redis_getter.return_value = self.mock_redis
498501
self.authoriser.authorise.return_value = True
499-
self.imms_repo.check_immunization_identifier_exists.return_value = True
502+
existing_immunisation = create_covid_immunization_dict("existing-id", VALID_NHS_NUMBER)
503+
identifier = Identifier(
504+
system=existing_immunisation["identifier"][0]["system"],
505+
value=existing_immunisation["identifier"][0]["value"],
506+
)
507+
self.imms_repo.get_immunization_by_identifier.return_value = (
508+
existing_immunisation,
509+
ImmunizationRecordMetadata(identifier=identifier, resource_version=1, is_deleted=False, is_reinstated=False),
510+
)
500511

501512
nhs_number = VALID_NHS_NUMBER
502513
req_imms = create_covid_immunization_dict_no_id(nhs_number)
@@ -507,16 +518,53 @@ def test_raises_duplicate_error_if_identifier_already_exits(self):
507518

508519
# Then
509520
self.authoriser.authorise.assert_called_once_with("Test", ApiOperationCode.CREATE, {"COVID"})
510-
self.imms_repo.check_immunization_identifier_exists.assert_called_once_with(
511-
"https://supplierABC/identifiers/vacc", "ACME-vacc123456"
512-
)
521+
self.imms_repo.get_immunization_by_identifier.assert_called_once()
522+
duplicate_identifier = self.imms_repo.get_immunization_by_identifier.call_args.args[0]
523+
self.assertEqual(duplicate_identifier.system, "https://supplierABC/identifiers/vacc")
524+
self.assertEqual(duplicate_identifier.value, "ACME-vacc123456")
513525
self.imms_repo.create_immunization.assert_not_called()
526+
self.imms_repo.update_immunization.assert_not_called()
514527
self.validator.validate.assert_called_once_with(req_imms)
515528
self.assertEqual(
516529
"The provided identifier: https://supplierABC/identifiers/vacc#ACME-vacc123456 is duplicated",
517530
str(error.exception),
518531
)
519532

533+
def test_reinstates_deleted_duplicate_identifier_on_create(self):
534+
"""it should reinstate a deleted record when a create request matches a deleted identifier"""
535+
self.mock_redis.hget.return_value = "COVID"
536+
self.mock_redis_getter.return_value = self.mock_redis
537+
self.authoriser.authorise.return_value = True
538+
539+
existing_immunisation = create_covid_immunization_dict("existing-id", VALID_NHS_NUMBER)
540+
identifier = Identifier(
541+
system=existing_immunisation["identifier"][0]["system"],
542+
value=existing_immunisation["identifier"][0]["value"],
543+
)
544+
existing_metadata = ImmunizationRecordMetadata(
545+
identifier=identifier,
546+
resource_version=2,
547+
is_deleted=True,
548+
is_reinstated=False,
549+
)
550+
self.imms_repo.get_immunization_by_identifier.return_value = (existing_immunisation, existing_metadata)
551+
self.imms_repo.update_immunization.return_value = 3
552+
553+
req_imms = create_covid_immunization_dict_no_id(VALID_NHS_NUMBER)
554+
555+
reinstated_id, reinstated_version = self.fhir_service.create_immunization(req_imms, "Test")
556+
557+
self.authoriser.authorise.assert_called_once_with("Test", ApiOperationCode.CREATE, {"COVID"})
558+
self.imms_repo.create_immunization.assert_not_called()
559+
self.imms_repo.update_immunization.assert_called_once_with(
560+
"existing-id",
561+
Immunization.parse_obj(req_imms),
562+
existing_metadata,
563+
"Test",
564+
)
565+
self.assertEqual("existing-id", reinstated_id)
566+
self.assertEqual(3, reinstated_version)
567+
520568

521569
class TestUpdateImmunization(TestFhirServiceBase):
522570
"""Tests for FhirService.update_immunization"""

lambdas/recordforwarder/src/repository/fhir_batch_repository.py

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,28 @@ def create_immunization(
9292
table: any,
9393
imms_pk: str | None,
9494
) -> str:
95+
identifier = self._identifier_response(immunization)
96+
query_response = _query_identifier(table, identifier, imms_pk)
97+
if query_response is not None:
98+
deleted_at_required, update_reinstated, is_reinstate = self._get_record_status(query_response)
99+
if not deleted_at_required or update_reinstated:
100+
raise IdentifierDuplicationError(identifier=identifier)
101+
102+
return self._update_existing_immunization(
103+
immunization,
104+
supplier_system,
105+
vax_type,
106+
table,
107+
query_response,
108+
deleted_at_required,
109+
update_reinstated,
110+
is_reinstate,
111+
)
112+
95113
new_id = str(uuid.uuid4())
96114
immunization["id"] = new_id
97115
attr = RecordAttributes(immunization, vax_type, supplier_system, 0)
98116

99-
query_response = _query_identifier(table, attr.identifier, imms_pk)
100-
if query_response is not None:
101-
raise IdentifierDuplicationError(identifier=attr.identifier)
102-
103117
try:
104118
response = table.put_item(
105119
Item={
@@ -140,20 +154,17 @@ def update_immunization(
140154
query_response = _query_identifier(table, identifier, imms_pk)
141155
if query_response is None:
142156
raise ResourceNotFoundError(resource_type="Immunization", resource_id=identifier)
143-
old_id, version = self._get_id_version(query_response)
144157
deleted_at_required, update_reinstated, is_reinstate = self._get_record_status(query_response)
145158

146-
immunization["id"] = old_id.split("#")[1]
147-
attr = RecordAttributes(immunization, vax_type, supplier_system, version)
148-
149-
update_exp = self._build_update_expression(is_reinstate=is_reinstate)
150-
151-
return self._perform_dynamo_update(
152-
update_exp,
153-
attr,
154-
deleted_at_required=deleted_at_required,
155-
update_reinstated=update_reinstated,
156-
table=table,
159+
return self._update_existing_immunization(
160+
immunization,
161+
supplier_system,
162+
vax_type,
163+
table,
164+
query_response,
165+
deleted_at_required,
166+
update_reinstated,
167+
is_reinstate,
157168
)
158169

159170
def delete_immunization(
@@ -250,6 +261,30 @@ def _build_update_expression(is_reinstate: bool) -> str:
250261
"Operation = :operation, Version = :version, SupplierSystem = :supplier_system "
251262
)
252263

264+
def _update_existing_immunization(
265+
self,
266+
immunization: any,
267+
supplier_system: str,
268+
vax_type: str,
269+
table: any,
270+
query_response: any,
271+
deleted_at_required: bool,
272+
update_reinstated: bool,
273+
is_reinstate: bool,
274+
) -> str:
275+
old_id, version = self._get_id_version(query_response)
276+
immunization["id"] = old_id.split("#")[1]
277+
attr = RecordAttributes(immunization, vax_type, supplier_system, version)
278+
update_exp = self._build_update_expression(is_reinstate=is_reinstate)
279+
280+
return self._perform_dynamo_update(
281+
update_exp,
282+
attr,
283+
deleted_at_required=deleted_at_required,
284+
update_reinstated=update_reinstated,
285+
table=table,
286+
)
287+
253288
def _perform_dynamo_update(
254289
self,
255290
update_exp: str,

lambdas/recordforwarder/tests/repository/test_fhir_batch_repository.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,15 +94,57 @@ def test_create_immunization_duplicate(self):
9494
"""it should not create Immunization since the request is duplicate"""
9595
self.table.query = MagicMock(
9696
return_value={
97-
"id": imms_id,
98-
"identifier": [{"system": "test-system", "value": "12345"}],
99-
"contained": [{"resourceType": "Patient", "identifier": [{"value": "98765"}]}],
10097
"Count": 1,
98+
"Items": [
99+
{
100+
"PK": _make_immunization_pk(imms_id),
101+
"Version": 1,
102+
}
103+
],
101104
}
102105
)
103106
with self.assertRaises(IdentifierDuplicationError):
104107
self.repository.create_immunization(self.immunization, "supplier", "vax-type", self.table, None)
105108
self.table.put_item.assert_not_called()
109+
self.table.update_item.assert_not_called()
110+
111+
def test_create_immunization_reinstates_deleted_duplicate(self):
112+
"""it should reinstate a deleted record when a create request matches a deleted identifier"""
113+
existing_pk = _make_immunization_pk(imms_id)
114+
self.table.query = MagicMock(
115+
return_value={
116+
"Count": 1,
117+
"Items": [
118+
{
119+
"PK": existing_pk,
120+
"Version": 2,
121+
"DeletedAt": "20210101",
122+
}
123+
],
124+
}
125+
)
126+
127+
response = self.repository.create_immunization(self.immunization, "supplier", "vax-type", self.table, None)
128+
129+
self.table.put_item.assert_not_called()
130+
self.table.update_item.assert_called_once_with(
131+
Key={"PK": existing_pk},
132+
UpdateExpression=ANY,
133+
ExpressionAttributeNames={"#imms_resource": "Resource"},
134+
ExpressionAttributeValues={
135+
":timestamp": ANY,
136+
":patient_pk": ANY,
137+
":patient_sk": ANY,
138+
":imms_resource_val": json.dumps(self.immunization),
139+
":operation": "UPDATE",
140+
":version": 3,
141+
":supplier_system": "supplier",
142+
":respawn": "reinstated",
143+
},
144+
ReturnValues=ANY,
145+
ConditionExpression=ANY,
146+
)
147+
self.assertEqual(response, existing_pk)
106148

107149
def test_create_should_catch_dynamo_error(self):
108150
"""it should throw UnhandledResponse when the response from dynamodb can't be handled"""

0 commit comments

Comments
 (0)