Skip to content

Commit 1d60b43

Browse files
committed
Refactored search immunisations flow
1 parent 3548d4e commit 1d60b43

File tree

7 files changed

+181
-129
lines changed

7 files changed

+181
-129
lines changed

backend/src/authorisation/authoriser.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ def authorise(
3939
requested_operation: ApiOperationCode,
4040
vaccination_types: set[str]
4141
) -> bool:
42+
"""Checks that the supplier system is permitted to carry out the requested operation on the given vaccination
43+
type(s)"""
4244
supplier_permissions = self._get_supplier_permissions(supplier_system)
4345

4446
logger.info(
@@ -49,3 +51,20 @@ def authorise(
4951
requested_operation in supplier_permissions.get(vaccination_type.lower(), [])
5052
for vaccination_type in vaccination_types
5153
)
54+
55+
def filter_permitted_vacc_types(
56+
self,
57+
supplier_system: str,
58+
requested_operation: ApiOperationCode,
59+
vaccination_types: set[str]
60+
) -> set[str]:
61+
"""Returns the set of vaccine types that a given supplier can interact with for a given operation type.
62+
This is a more permissive form of authorisation e.g. used in search as it will filter out any requested vacc
63+
types that they cannot interact with without throwing an error"""
64+
supplier_permissions = self._get_supplier_permissions(supplier_system)
65+
66+
return set([
67+
vaccine_type
68+
for vaccine_type in vaccination_types
69+
if requested_operation in supplier_permissions.get(vaccine_type.lower(), [])
70+
])

backend/src/fhir_controller.py

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -410,25 +410,16 @@ def search_immunizations(self, aws_event: APIGatewayProxyEventV1) -> dict:
410410
return self.create_response(403, unauthorized.to_operation_outcome())
411411
# Check vaxx type permissions on the existing record - start
412412
try:
413-
expanded_permissions = _expand_permissions(imms_vax_type_perms)
414-
vax_type_perm = [
415-
vaccine_type
416-
for vaccine_type in search_params.immunization_targets
417-
if ApiOperationCode.SEARCH in expanded_permissions.get(vaccine_type.lower(), [])
418-
]
419-
if not vax_type_perm:
420-
raise UnauthorizedVaxError
413+
result, request_contained_unauthorised_vaccs = self.fhir_service.search_immunizations(
414+
search_params.patient_identifier,
415+
search_params.immunization_targets,
416+
create_query_string(search_params),
417+
supplier_system,
418+
search_params.date_from,
419+
search_params.date_to,
420+
)
421421
except UnauthorizedVaxError as unauthorized:
422422
return self.create_response(403, unauthorized.to_operation_outcome())
423-
# Check vaxx type permissions on the existing record - end
424-
425-
result = self.fhir_service.search_immunizations(
426-
search_params.patient_identifier,
427-
vax_type_perm,
428-
create_query_string(search_params),
429-
search_params.date_from,
430-
search_params.date_to,
431-
)
432423

433424
if "diagnostics" in result:
434425
exp_error = create_operation_outcome(
@@ -450,7 +441,7 @@ def search_immunizations(self, aws_event: APIGatewayProxyEventV1) -> dict:
450441
1 for entry in result_json_dict["entry"] if entry.get("search", {}).get("mode") == "match"
451442
)
452443
result_json_dict["total"] = total_count
453-
if sorted(search_params.immunization_targets) != sorted(vax_type_perm):
444+
if request_contained_unauthorised_vaccs:
454445
exp_error = create_operation_outcome(
455446
resource_id=str(uuid.uuid4()),
456447
severity=Severity.warning,
@@ -498,7 +489,7 @@ def _validate_identifier_system(self, _id: str, _elements: str) -> Optional[dict
498489
'e.g. "http://xyz.org/vaccs|2345-gh3s-r53h7-12ny"'
499490
),
500491
)
501-
492+
502493
if not _elements:
503494
return None
504495

@@ -570,7 +561,7 @@ def fetch_identifier_system_and_element(self, event: dict):
570561
query_string_has_immunization_identifier,
571562
query_string_has_element,
572563
)
573-
564+
574565
# Post Search Identifier by body form
575566
if body and not query_params:
576567
decoded_body = base64.b64decode(body).decode("utf-8")

backend/src/fhir_repository.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ def delete_immunization(self, imms_id: str, supplier_system: str) -> dict:
365365
response=error.response,
366366
)
367367

368-
def find_immunizations(self, patient_identifier: str, vaccine_types: list):
368+
def find_immunizations(self, patient_identifier: str, vaccine_types: set):
369369
"""it should find all of the specified patient's Immunization events for all of the specified vaccine_types"""
370370
condition = Key("PatientPK").eq(_make_patient_pk(patient_identifier))
371371
is_not_deleted = Attr("DeletedAt").not_exists() | Attr("DeletedAt").eq("reinstated")

backend/src/fhir_service.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def get_immunization_by_identifier(
7171

7272
if not imms_resp:
7373
return form_json(imms_resp, None, None, base_url)
74-
74+
7575
if not self.authoriser.authorise(supplier_name, ApiOperationCode.SEARCH, {vaccination_type}):
7676
raise UnauthorizedVaxError()
7777

@@ -312,22 +312,32 @@ def search_immunizations(
312312
nhs_number: str,
313313
vaccine_types: list[str],
314314
params: str,
315+
supplier_system: str,
315316
date_from: datetime.date = parameter_parser.date_from_default,
316317
date_to: datetime.date = parameter_parser.date_to_default,
317-
) -> FhirBundle:
318+
) -> tuple[FhirBundle, bool]:
318319
"""
319320
Finds all instances of Immunization(s) for a specified patient which are for the specified vaccine type(s).
320-
Bundles the resources with the relevant patient resource and returns the bundle.
321+
Bundles the resources with the relevant patient resource and returns the bundle along with a boolean to state
322+
whether the supplier requested vaccine types they were not authorised for.
321323
"""
322324
# TODO: is disease type a mandatory field? (I assumed it is)
323325
# i.e. Should we provide a search option for getting Patient's entire imms history?
324326
if not nhs_number_mod11_check(nhs_number):
325327
return create_diagnostics()
326328

329+
permitted_vacc_types = self.authoriser.filter_permitted_vacc_types(
330+
supplier_system, ApiOperationCode.SEARCH, set(vaccine_types)
331+
)
332+
333+
# Only raise error if supplier's request had no permitted vaccinations
334+
if not permitted_vacc_types:
335+
raise UnauthorizedVaxError()
336+
327337
# Obtain all resources which are for the requested nhs number and vaccine type(s) and within the date range
328338
resources = [
329339
r
330-
for r in self.immunization_repo.find_immunizations(nhs_number, vaccine_types)
340+
for r in self.immunization_repo.find_immunizations(nhs_number, permitted_vacc_types)
331341
if self.is_valid_date_from(r, date_from) and self.is_valid_date_to(r, date_to)
332342
]
333343

@@ -366,8 +376,9 @@ def search_immunizations(
366376
# Create the bundle
367377
fhir_bundle = FhirBundle(resourceType="Bundle", type="searchset", entry=entries)
368378
fhir_bundle.link = [BundleLink(relation="self", url=self.create_url_for_bundle_link(params, vaccine_types))]
379+
supplier_requested_unauthorised_vaccs = len(vaccine_types) != len(permitted_vacc_types)
369380

370-
return fhir_bundle
381+
return fhir_bundle, supplier_requested_unauthorised_vaccs
371382

372383
@timed
373384
def _validate_patient(self, imms: dict) -> dict:

backend/tests/test_fhir_controller.py

Lines changed: 22 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1654,7 +1654,7 @@ def test_get_search_immunizations(self, mock_get_supplier_permissions):
16541654
"""it should search based on patient_identifier and immunization_target"""
16551655
mock_get_supplier_permissions.return_value = ["COVID19.S"]
16561656
search_result = Bundle.construct()
1657-
self.service.search_immunizations.return_value = search_result
1657+
self.service.search_immunizations.return_value = search_result, False
16581658

16591659
vaccine_type = "COVID19"
16601660
params = f"{self.immunization_target_key}={vaccine_type}&" + urllib.parse.urlencode(
@@ -1677,18 +1677,18 @@ def test_get_search_immunizations(self, mock_get_supplier_permissions):
16771677
# Then
16781678
mock_get_supplier_permissions.assert_called_once_with("test")
16791679
self.service.search_immunizations.assert_called_once_with(
1680-
self.nhs_number_valid_value, [vaccine_type], params, ANY, ANY
1680+
self.nhs_number_valid_value, [vaccine_type], params, "test", ANY, ANY
16811681
)
16821682
self.assertEqual(response["statusCode"], 200)
16831683
body = json.loads(response["body"])
16841684
self.assertEqual(body["resourceType"], "Bundle")
16851685

16861686
@patch("fhir_controller.get_supplier_permissions")
16871687
def test_get_search_immunizations_vax_permission_check(self, mock_get_supplier_permissions):
1688-
"""it should search based on patient_identifier and immunization_target"""
1689-
mock_get_supplier_permissions.return_value = []
1688+
"""it should return a 403 error if the service raises an unauthorized error"""
16901689
search_result = Bundle.construct()
1691-
self.service.search_immunizations.return_value = search_result
1690+
mock_get_supplier_permissions.return_value = []
1691+
self.service.search_immunizations.side_effect = UnauthorizedVaxError()
16921692

16931693
vaccine_type = "COVID19"
16941694
lambda_event = {
@@ -1704,14 +1704,16 @@ def test_get_search_immunizations_vax_permission_check(self, mock_get_supplier_p
17041704

17051705
# Then
17061706
self.assertEqual(response["statusCode"], 403)
1707+
body = json.loads(response["body"])
1708+
self.assertEqual(body["resourceType"], "OperationOutcome")
17071709

17081710
@patch("fhir_controller.get_supplier_permissions")
17091711
def test_get_search_immunizations_for_unauthorized_vaccine_type_search(self, mock_get_supplier_permissions):
17101712
"""it should return 200 and contains warning operation outcome as the user is not having authorization for one of the vaccine type"""
17111713
search_result = load_json_data("sample_immunization_response _for _not_done_event.json")
17121714
mock_get_supplier_permissions.return_value = ["covid19.S"]
17131715
bundle = Bundle.parse_obj(search_result)
1714-
self.service.search_immunizations.return_value = bundle
1716+
self.service.search_immunizations.return_value = bundle, True
17151717

17161718
vaccine_type = ["COVID19", "FLU"]
17171719
vaccine_type = ",".join(vaccine_type)
@@ -1737,11 +1739,11 @@ def test_get_search_immunizations_for_unauthorized_vaccine_type_search(self, moc
17371739

17381740
@patch("fhir_controller.get_supplier_permissions")
17391741
def test_get_search_immunizations_for_unauthorized_vaccine_type_search_400(self,mock_get_supplier_permissions):
1740-
"""it should return 400 as the the request is having invalid vaccine type"""
1742+
"""it should return 400 as the request has an invalid vaccine type"""
17411743
search_result = load_json_data("sample_immunization_response _for _not_done_event.json")
17421744
mock_get_supplier_permissions.return_value = ["covid19.S"]
17431745
bundle = Bundle.parse_obj(search_result)
1744-
self.service.search_immunizations.return_value = bundle
1746+
self.service.search_immunizations.return_value = bundle, False
17451747

17461748
vaccine_type = "FLUE"
17471749

@@ -1759,60 +1761,12 @@ def test_get_search_immunizations_for_unauthorized_vaccine_type_search_400(self,
17591761
body = json.loads(response["body"])
17601762
self.assertEqual(body["resourceType"], "OperationOutcome")
17611763

1762-
@patch("fhir_controller.get_supplier_permissions")
1763-
def test_get_search_immunizations_for_unauthorized_vaccine_type_search_403(self, mock_get_supplier_permissions):
1764-
"""it should return 403 as the user doesnt have vaccinetype permission"""
1765-
search_result = load_json_data("sample_immunization_response _for _not_done_event.json")
1766-
bundle = Bundle.parse_obj(search_result)
1767-
mock_get_supplier_permissions.return_value = []
1768-
self.service.search_immunizations.return_value = bundle
1769-
1770-
vaccine_type = "COVID19,FLU"
1771-
lambda_event = {
1772-
"headers": {"Content-Type": "application/x-www-form-urlencoded", "SupplierSystem": "test",},
1773-
"multiValueQueryStringParameters": {
1774-
self.immunization_target_key: [vaccine_type],
1775-
self.patient_identifier_key: [self.patient_identifier_valid_value],
1776-
},
1777-
}
1778-
1779-
# When
1780-
response = self.controller.search_immunizations(lambda_event)
1781-
mock_get_supplier_permissions.assert_called_once_with("test")
1782-
self.assertEqual(response["statusCode"], 403)
1783-
body = json.loads(response["body"])
1784-
self.assertEqual(body["resourceType"], "OperationOutcome")
1785-
1786-
@patch("fhir_controller.get_supplier_permissions")
1787-
def test_get_search_immunizations_unauthorized(self, mock_get_supplier_permissions):
1788-
"""it should search based on patient_identifier and immunization_target"""
1789-
search_result = Bundle.construct()
1790-
mock_get_supplier_permissions.return_value = []
1791-
self.service.search_immunizations.return_value = search_result
1792-
1793-
vaccine_type = "COVID19"
1794-
params = f"{self.immunization_target_key}={vaccine_type}&" + urllib.parse.urlencode(
1795-
[(f"{self.patient_identifier_key}", f"{self.patient_identifier_valid_value}")]
1796-
)
1797-
lambda_event = {
1798-
"headers": {"Content-Type": "application/x-www-form-urlencoded", "SupplierSystem": "test"},
1799-
"multiValueQueryStringParameters": {
1800-
self.immunization_target_key: [vaccine_type],
1801-
self.patient_identifier_key: [self.patient_identifier_valid_value],
1802-
},
1803-
}
1804-
1805-
# When
1806-
response = self.controller.search_immunizations(lambda_event)
1807-
mock_get_supplier_permissions.assert_called_once_with("test")
1808-
self.assertEqual(response["statusCode"], 403)
1809-
18101764
@patch("fhir_controller.get_supplier_permissions")
18111765
def test_post_search_immunizations(self,mock_get_supplier_permissions):
18121766
"""it should search based on patient_identifier and immunization_target"""
18131767
mock_get_supplier_permissions.return_value = ["covid19.s"]
18141768
search_result = Bundle.construct()
1815-
self.service.search_immunizations.return_value = search_result
1769+
self.service.search_immunizations.return_value = search_result, False
18161770

18171771
vaccine_type = "COVID19"
18181772
params = f"{self.immunization_target_key}={vaccine_type}&" + urllib.parse.urlencode(
@@ -1837,7 +1791,7 @@ def test_post_search_immunizations(self,mock_get_supplier_permissions):
18371791
response = self.controller.search_immunizations(lambda_event)
18381792
# Then
18391793
self.service.search_immunizations.assert_called_once_with(
1840-
self.nhs_number_valid_value, [vaccine_type], params, ANY, ANY
1794+
self.nhs_number_valid_value, [vaccine_type], params, "Test", ANY, ANY
18411795
)
18421796
self.assertEqual(response["statusCode"], 200)
18431797
mock_get_supplier_permissions.assert_called_once_with("Test")
@@ -1849,7 +1803,7 @@ def test_post_search_immunizations_for_unauthorized_vaccine_type_search(self,moc
18491803
"""it should return 200 and contains warning operation outcome as the user is not having authorization for one of the vaccine type"""
18501804
search_result = load_json_data("sample_immunization_response _for _not_done_event.json")
18511805
bundle = Bundle.parse_obj(search_result)
1852-
self.service.search_immunizations.return_value = bundle
1806+
self.service.search_immunizations.return_value = bundle, True
18531807
mock_get_supplier_permissions.return_value = ["covid19.s"]
18541808

18551809
vaccine_type = "COVID19", "FLU"
@@ -1881,10 +1835,10 @@ def test_post_search_immunizations_for_unauthorized_vaccine_type_search(self,moc
18811835
self.assertTrue(operation_outcome_present, "OperationOutcome resource is not present in the response")
18821836

18831837
def test_post_search_immunizations_for_unauthorized_vaccine_type_search_400(self):
1884-
"""it should return 400 as the the request is having invalid vaccine type"""
1838+
"""it should return 400 as the request is having invalid vaccine type"""
18851839
search_result = load_json_data("sample_immunization_response _for _not_done_event.json")
18861840
bundle = Bundle.parse_obj(search_result)
1887-
self.service.search_immunizations.return_value = bundle
1841+
self.service.search_immunizations.return_value = bundle, False
18881842

18891843
vaccine_type = "FLUE"
18901844

@@ -1915,7 +1869,7 @@ def test_post_search_immunizations_for_unauthorized_vaccine_type_search_403(self
19151869
search_result = load_json_data("sample_immunization_response _for _not_done_event.json")
19161870
bundle = Bundle.parse_obj(search_result)
19171871
mock_get_supplier_permissions.return_value = []
1918-
self.service.search_immunizations.return_value = bundle
1872+
self.service.search_immunizations.side_effect = UnauthorizedVaxError()
19191873

19201874
vaccine_type = ["COVID19", "FLU"]
19211875
vaccine_type = ",".join(vaccine_type)
@@ -1981,11 +1935,11 @@ def test_search_immunizations_returns_400_on_ParameterException_from_parameter_p
19811935

19821936
@patch("fhir_controller.get_supplier_permissions")
19831937
def test_search_immunizations_returns_400_on_passing_superseded_nhs_number(self, mock_get_supplier_permissions):
1984-
"This method should return 400 as input paramter has superseded nhs number."
1938+
"""This method should return 400 as input parameter has superseded nhs number."""
19851939
search_result = {
19861940
"diagnostics": "Validation errors: contained[?(@.resourceType=='Patient')].identifier[0].value does not exists"
19871941
}
1988-
self.service.search_immunizations.return_value = search_result
1942+
self.service.search_immunizations.return_value = search_result, False
19891943
mock_get_supplier_permissions.return_value = ["covid19.s"]
19901944

19911945
vaccine_type = "COVID19"
@@ -2010,11 +1964,11 @@ def test_search_immunizations_returns_400_on_passing_superseded_nhs_number(self,
20101964

20111965
@patch("fhir_controller.get_supplier_permissions")
20121966
def test_search_immunizations_returns_200_remove_vaccine_not_done(self, mock_get_supplier_permissions):
2013-
"This method should return 200 but remove the data which has status as not done."
1967+
"""This method should return 200 but remove the data which has status as not done."""
20141968
search_result = load_json_data("sample_immunization_response _for _not_done_event.json")
20151969
bundle = Bundle.parse_obj(search_result)
20161970
mock_get_supplier_permissions.return_value = ["COVID19.CRUDS"]
2017-
self.service.search_immunizations.return_value = bundle
1971+
self.service.search_immunizations.return_value = bundle, False
20181972
vaccine_type = "COVID19"
20191973
lambda_event = {
20201974
"headers": {
@@ -2038,7 +1992,7 @@ def test_search_immunizations_returns_200_remove_vaccine_not_done(self, mock_get
20381992
@patch("fhir_controller.get_supplier_permissions")
20391993
def test_self_link_excludes_extraneous_params(self, mock_get_supplier_permissions):
20401994
search_result = Bundle.construct()
2041-
self.service.search_immunizations.return_value = search_result
1995+
self.service.search_immunizations.return_value = search_result, False
20421996
vaccine_type = "COVID19"
20431997
mock_get_supplier_permissions.return_value = ["covid19.CUDS"]
20441998
params = f"{self.immunization_target_key}={vaccine_type}&" + urllib.parse.urlencode(
@@ -2063,5 +2017,5 @@ def test_self_link_excludes_extraneous_params(self, mock_get_supplier_permission
20632017
self.controller.search_immunizations(lambda_event)
20642018

20652019
self.service.search_immunizations.assert_called_once_with(
2066-
self.nhs_number_valid_value, [vaccine_type], params, ANY, ANY
2020+
self.nhs_number_valid_value, [vaccine_type], params, "Test", ANY, ANY
20672021
)

0 commit comments

Comments
 (0)