Skip to content

Commit 4f9c904

Browse files
authored
VED-1053: Implement search by disease type feature (#1257)
* Add target disease search functionality to Immunisation API - Introduced a new search parameter `target-disease` for immunization searches. - Updated the `FhirController` to handle searches by target disease, including validation for mutual exclusivity with existing parameters. - Enhanced the `parameter_parser` to process and validate target disease parameters. - Modified the `fhir_service` to support target disease in search requests and responses. - Updated API documentation to reflect the new search parameter and its usage constraints. - Added tests to ensure correct behavior for target disease searches and error handling for invalid combinations. * Add additional scenarios for target-disease search in Immunization API tests - Implemented new test scenarios for searching immunization events using comma-separated target-disease with both GET and POST methods. - Added validation for date range filtering in search requests. - Enhanced error handling tests for unauthorized access and invalid target-disease codes. - Updated step definitions to support new search functionalities and error validations. * Update test_search_steps.py * Better error reporting in API tests by adding response body details - Introduced a new utility function `get_response_body_for_display` to format response bodies for better error messages. - Updated assertions in `common_steps.py` and `conftest.py` to utilize the new function, improving clarity in failure cases. - Ensured that response bodies are displayed even when they are empty or non-JSON, aiding in debugging. * Update common_steps.py * lint fixes * ruff fixes * Remove unused step for sending a search request with mixed valid and invalid Disease Types in API tests. * Refactor parameter parser and update tests for target disease handling - Removed unused error handling for missing target disease list cache in `parameter_parser.py`. - Updated tests to reflect changes in behavior when the target disease list is missing, ensuring a 200 response with an empty bundle instead of a 500 error. - Adjusted assertions in test cases to validate the new expected outcomes for missing cache scenarios. * chore: empty commit Made-with: Cursor * chore: empty commit EmptyCommit: * better target disease processing and improve API test validations - Refactored `process_target_disease` to build a comprehensive disease-to-vaccine-type mapping from Redis caches, ensuring support for target-disease searches even with partial data. - Added error handling for JSON decoding issues in disease mappings and improved logging for better traceability. - Updated API test steps to assert the presence of Immunization entries and handle cases where only OperationOutcome is returned, enhancing robustness of the tests. - Modified data models to include `OperationOutcome` in the response structure, ensuring compatibility with new response scenarios. * Fix handling of disease code extraction in `process_target_disease` to ensure compatibility with non-dictionary inputs. This change prevents potential errors when processing disease data. * Enhance cleanup process in `conftest.py` to improve error handling during DELETE requests. The code now logs the status of each deletion attempt, indicating success or failure without failing the test, and provides a summary message upon completion of batch cleanup. * PR comments and function breakup * Refactor target disease handling in FHIR controller and parameter parser - Renamed `_search_immunizations_by_disease` to `_search_immunizations_by_target_disease` for clarity. - Updated the `SearchParamsResult` model to replace `all_target_diseases_not_in_mapping` with `no_mapped_target_diseases_provided`. - Improbed the `process_target_disease` function to improve status classification and error handling. - Adjusted unit tests to reflect changes in the parameter parser and controller logic, ensuring accurate assertions on target disease processing. * Refactor disease mapping logic in parameter parser - Introduced `_safe_load_diseases` function to handle JSON decoding and validation of disease data. - Added `_add_vaccine_to_disease_map` function to streamline the process of mapping vaccines to diseases. - Updated `_build_disease_to_vaccs_map` to utilize the new helper functions, improving readability and error handling. * chore: ado build kickstart EmptyCommit: * Added Search API tests for mixed valid and invalid target-disease codes - Added new scenarios to validate Search API responses when using a mix of valid and invalid target-disease codes. - Implemented corresponding step definitions for GET and POST requests to handle mixed target-disease codes. - Included assertions to verify the presence of OperationOutcome in responses for invalid target-disease codes, ensuring comprehensive error handling. * Refactor target disease vaccine types in Search API tests * Update target disease codes in Search API tests - Added new target disease codes for shingles and 6-in-1 vaccine. - Updated test scenarios to use the new target disease codes in both GET and POST requests, ensuring accurate testing of mixed valid and invalid target-disease codes. * Refactor Search API tests for target-disease scenarios - Updated the search feature to use a Scenario Outline for testing errors when combining target-disease with -immunization.target, supporting both GET and POST methods. - Enhanced step definitions to dynamically retrieve patient identifiers and target disease codes, improving test accuracy and maintainability. - Cleaned up redundant target disease code variables in the test steps, ensuring a more streamlined implementation.
1 parent b1cbbbe commit 4f9c904

21 files changed

Lines changed: 1377 additions & 62 deletions

lambdas/backend/src/controller/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class IdentifierSearchParameterName(StrEnum):
1616
class ImmunizationSearchParameterName(StrEnum):
1717
PATIENT_IDENTIFIER = "patient.identifier"
1818
IMMUNIZATION_TARGET = "-immunization.target"
19+
TARGET_DISEASE = "target-disease"
1920
DATE_FROM = "-date.from"
2021
DATE_TO = "-date.to"
2122
INCLUDE = "_include"

lambdas/backend/src/controller/fhir_controller.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,18 @@
1818
get_supplier_system_header,
1919
)
2020
from controller.aws_apig_response_utils import create_response
21-
from controller.constants import E_TAG_HEADER_NAME, IdentifierSearchParameterName
21+
from controller.constants import (
22+
E_TAG_HEADER_NAME,
23+
IdentifierSearchParameterName,
24+
ImmunizationSearchParameterName,
25+
)
2226
from controller.fhir_api_exception_handler import fhir_api_exception_handler
2327
from controller.parameter_parser import (
2428
parse_search_params,
2529
validate_and_retrieve_identifier_search_params,
2630
validate_and_retrieve_search_params,
31+
validate_and_retrieve_search_params_by_disease,
32+
validate_search_param_mutual_exclusivity,
2733
)
2834
from models.errors import (
2935
InconsistentIdError,
@@ -133,14 +139,18 @@ def delete_immunization(self, aws_event: APIGatewayProxyEventV1) -> dict:
133139
def search_immunizations(self, aws_event: APIGatewayProxyEventV1, is_post_endpoint_req: bool = False) -> dict:
134140
"""Performs the client search request based on the parameters provided. The available searches are:
135141
1. Search by identifier: (more like a GET) retrieves immunisation by local identifier.
136-
2. Search by patient and immunisation target"""
142+
2. Search by patient and immunisation target (-immunization.target or target-disease)."""
137143
search_params = self._get_search_params_from_request(aws_event, is_post_endpoint_req)
138144
parsed_search_params = parse_search_params(search_params)
145+
validate_search_param_mutual_exclusivity(parsed_search_params)
139146
supplier_system = get_supplier_system_header(aws_event)
140147

141148
if self._is_identifier_search(parsed_search_params):
142149
return self._get_immunization_by_identifier(parsed_search_params, supplier_system)
143150

151+
if self._is_target_disease_search(parsed_search_params):
152+
return self._search_immunizations_by_target_disease(parsed_search_params, supplier_system)
153+
144154
return self._search_immunizations(parsed_search_params, supplier_system)
145155

146156
def _get_immunization_by_identifier(self, search_params: dict[str, list[str]], supplier_system: str) -> dict:
@@ -173,6 +183,37 @@ def _search_immunizations(self, search_params: dict[str, list[str]], supplier_sy
173183

174184
return create_response(200, prepared_search_bundle)
175185

186+
def _search_immunizations_by_target_disease(self, search_params: dict[str, list[str]], supplier_system: str) -> dict:
187+
result = validate_and_retrieve_search_params_by_disease(search_params)
188+
189+
if result.no_mapped_target_diseases_provided:
190+
search_bundle = self.fhir_service.make_empty_search_bundle_with_target_disease_not_in_mapping(
191+
result.params.patient_identifier,
192+
result.params.date_from,
193+
result.params.date_to,
194+
result.params.include,
195+
result.params.target_disease_codes_for_url,
196+
)
197+
else:
198+
search_bundle = self.fhir_service.search_immunizations(
199+
result.params.patient_identifier,
200+
result.params.immunization_targets,
201+
supplier_system,
202+
result.params.date_from,
203+
result.params.date_to,
204+
result.params.include,
205+
invalid_immunization_targets=None,
206+
target_disease_codes_for_url=result.params.target_disease_codes_for_url,
207+
invalid_target_diseases=result.invalid_target_diseases,
208+
)
209+
210+
prepared_search_bundle = self._prepare_search_bundle(search_bundle)
211+
return create_response(200, prepared_search_bundle)
212+
213+
@staticmethod
214+
def _is_target_disease_search(search_params: dict[str, list[str]]) -> bool:
215+
return ImmunizationSearchParameterName.TARGET_DISEASE in search_params
216+
176217
def _is_valid_immunization_id(self, immunization_id: str) -> bool:
177218
"""Validates if the given unique Immunization ID is valid."""
178219
return False if not re.match(self._IMMUNIZATION_ID_PATTERN, immunization_id) else True

lambdas/backend/src/controller/parameter_parser.py

Lines changed: 262 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import datetime
2+
import json
3+
import logging
24
from dataclasses import dataclass, field
35

4-
from common.models.constants import RedisHashKeys
6+
from common.models.constants import RedisHashKeys, Urls
57
from common.models.utils.generic_utils import nhs_number_mod11_check
68
from common.redis_client import get_redis_client
79
from controller.constants import IdentifierSearchElement, IdentifierSearchParameterName, ImmunizationSearchParameterName
@@ -16,9 +18,30 @@
1618
f"No parameter provided. Search using either {IdentifierSearchParameterName.IDENTIFIER} or "
1719
f"{ImmunizationSearchParameterName.PATIENT_IDENTIFIER}"
1820
)
21+
TARGET_DISEASE_MUTUAL_EXCLUSIVITY_ERROR = (
22+
f"Search parameter {ImmunizationSearchParameterName.TARGET_DISEASE} cannot be used with "
23+
f"{ImmunizationSearchParameterName.IMMUNIZATION_TARGET} or {IdentifierSearchParameterName.IDENTIFIER}. "
24+
"Use one search type only."
25+
)
26+
TARGET_DISEASE_FORMAT_ERROR = (
27+
f"Search parameter {ImmunizationSearchParameterName.TARGET_DISEASE} must be in the format "
28+
f'"{Urls.SNOMED}|{{SNOMED code}}" e.g. "{Urls.SNOMED}|14189004"'
29+
)
30+
TARGET_DISEASE_ALL_INVALID_ERROR = (
31+
f"Search parameter {ImmunizationSearchParameterName.TARGET_DISEASE} must be one or more valid SNOMED codes "
32+
"from the supported target disease list."
33+
)
1934

2035
PATIENT_IDENTIFIER_SYSTEM = "https://fhir.nhs.uk/Id/nhs-number"
2136

37+
TARGET_DISEASE_CODES_FIELD = "codes"
38+
39+
TARGET_DISEASE_STATUS_VALID = "valid"
40+
TARGET_DISEASE_STATUS_FORMAT_INVALID = "format_invalid"
41+
TARGET_DISEASE_STATUS_UNMAPPED = "unmapped"
42+
43+
logger = logging.getLogger(__name__)
44+
2245

2346
@dataclass
2447
class SearchParams:
@@ -27,6 +50,7 @@ class SearchParams:
2750
date_from: datetime.date | None
2851
date_to: datetime.date | None
2952
include: str | None
53+
target_disease_codes_for_url: set[str] | None = None
3054

3155
def __repr__(self):
3256
return str(self.__dict__)
@@ -36,6 +60,8 @@ def __repr__(self):
3660
class SearchParamsResult:
3761
params: SearchParams
3862
invalid_immunization_targets: list[str] = field(default_factory=list)
63+
invalid_target_diseases: list[str] = field(default_factory=list)
64+
no_mapped_target_diseases_provided: bool = field(default=False)
3965

4066

4167
def process_patient_identifier(identifier_params: dict[str, list[str]]) -> str:
@@ -98,6 +124,239 @@ def process_immunization_target(imms_params: dict[str, list[str]]) -> tuple[list
98124
return valid, invalid
99125

100126

127+
def validate_search_param_mutual_exclusivity(params: dict[str, list[str]]) -> None:
128+
"""Raises ParameterExceptionError if target-disease is used with -immunization.target or identifier."""
129+
if ImmunizationSearchParameterName.TARGET_DISEASE not in params:
130+
return
131+
if ImmunizationSearchParameterName.IMMUNIZATION_TARGET in params:
132+
raise ParameterExceptionError(TARGET_DISEASE_MUTUAL_EXCLUSIVITY_ERROR)
133+
if IdentifierSearchParameterName.IDENTIFIER in params:
134+
raise ParameterExceptionError(TARGET_DISEASE_MUTUAL_EXCLUSIVITY_ERROR)
135+
136+
137+
def _extract_target_disease_values(params: dict[str, list[str]]) -> list[str]:
138+
"""Extract and strip target-disease values from params. Raises if empty."""
139+
values = [
140+
v.strip() for v in params.get(ImmunizationSearchParameterName.TARGET_DISEASE, []) if v is not None and v.strip()
141+
]
142+
if not values:
143+
raise ParameterExceptionError(
144+
f"Search parameter {ImmunizationSearchParameterName.TARGET_DISEASE} must have one or more values."
145+
)
146+
return values
147+
148+
149+
def _safe_load_diseases(vacc_type: str, diseases_json) -> list[dict]:
150+
try:
151+
diseases = json.loads(diseases_json)
152+
except (TypeError, json.JSONDecodeError):
153+
logger.warning("Could not decode diseases mapping for vaccine type '%s'", vacc_type)
154+
return []
155+
156+
if not isinstance(diseases, list):
157+
return []
158+
159+
return diseases
160+
161+
162+
def _add_vaccine_to_disease_map(
163+
disease_to_vaccs_map: dict[str, list[str]],
164+
code: str | None,
165+
vacc_type: str,
166+
) -> None:
167+
if not code:
168+
return
169+
170+
existing = disease_to_vaccs_map.get(code)
171+
if existing is None:
172+
disease_to_vaccs_map[code] = [vacc_type]
173+
elif vacc_type not in existing:
174+
existing.append(vacc_type)
175+
176+
177+
def _build_disease_to_vaccs_map(redis) -> dict[str, list[str]]:
178+
"""Build disease code -> vaccine types map from Redis.
179+
180+
Uses the explicit target-disease-to-vaccines cache where available, and
181+
augments it from the existing vaccine-type-to-diseases mapping so target-
182+
disease search remains compatible with current cache contents.
183+
"""
184+
disease_to_vaccs_map: dict[str, list[str]] = {}
185+
186+
disease_to_vaccs_raw = redis.hgetall(RedisHashKeys.TARGET_DISEASE_TO_VACCS_KEY) or {}
187+
for disease_code, raw_json in disease_to_vaccs_raw.items():
188+
try:
189+
decoded = json.loads(raw_json)
190+
except json.JSONDecodeError:
191+
logger.warning("Could not decode target_disease_to_vaccs mapping for disease code '%s'", disease_code)
192+
continue
193+
if isinstance(decoded, list):
194+
disease_to_vaccs_map[disease_code] = [str(vacc_type) for vacc_type in decoded]
195+
196+
# Also derive mappings from the existing vaccine-type -> diseases cache so that
197+
# target-disease search continues to work while the new cache is being rolled out.
198+
vacc_to_diseases_raw = redis.hgetall(RedisHashKeys.VACCINE_TYPE_TO_DISEASES_HASH_KEY) or {}
199+
for vacc_type, diseases_json in vacc_to_diseases_raw.items():
200+
diseases = _safe_load_diseases(vacc_type, diseases_json)
201+
if not diseases:
202+
continue
203+
204+
for disease in diseases:
205+
code = disease.get("code") if isinstance(disease, dict) else None
206+
_add_vaccine_to_disease_map(disease_to_vaccs_map, code, vacc_type)
207+
208+
return disease_to_vaccs_map
209+
210+
211+
def _build_valid_codes_set(redis, disease_to_vaccs_map: dict[str, list[str]]) -> set[str]:
212+
"""Build set of supported SNOMED codes from Redis list and mapping keys."""
213+
valid_codes_set: set[str] = set()
214+
codes_json = redis.hget(RedisHashKeys.TARGET_DISEASE_LIST_KEY, TARGET_DISEASE_CODES_FIELD)
215+
if codes_json:
216+
decoded = codes_json.decode() if isinstance(codes_json, bytes) else codes_json
217+
decoded_codes = None
218+
try:
219+
decoded_codes = json.loads(decoded)
220+
except json.JSONDecodeError:
221+
logger.warning("Could not decode target_disease_list codes from Redis")
222+
if decoded_codes is not None and isinstance(decoded_codes, list):
223+
valid_codes_set.update(str(c) for c in decoded_codes)
224+
valid_codes_set.update(disease_to_vaccs_map.keys())
225+
return valid_codes_set
226+
227+
228+
def _classify_target_disease_value(raw: str, valid_codes_set: set[str]) -> tuple[str, str | None]:
229+
"""Classify one target-disease value.
230+
231+
Returns (status, code_or_none) where status is one of:
232+
- TARGET_DISEASE_STATUS_VALID
233+
- TARGET_DISEASE_STATUS_FORMAT_INVALID
234+
- TARGET_DISEASE_STATUS_UNMAPPED
235+
"""
236+
if "|" not in raw:
237+
return TARGET_DISEASE_STATUS_FORMAT_INVALID, None
238+
parts = raw.split("|", 1)
239+
if len(parts) != 2:
240+
return TARGET_DISEASE_STATUS_FORMAT_INVALID, None
241+
system, code = parts[0].strip(), parts[1].strip()
242+
if system != Urls.SNOMED or not code:
243+
return TARGET_DISEASE_STATUS_FORMAT_INVALID, None
244+
if code not in valid_codes_set:
245+
return TARGET_DISEASE_STATUS_UNMAPPED, code
246+
return TARGET_DISEASE_STATUS_VALID, code
247+
248+
249+
def _resolve_target_disease_result(
250+
valid_raw: list[str],
251+
unmapped_format_valid: list[str],
252+
format_invalid_count: int,
253+
total_count: int,
254+
) -> tuple[list[str], bool]:
255+
"""Apply post-processing and raises if needed.
256+
257+
Returns (final_valid_raw, no_mapped_target_diseases_provided).
258+
"""
259+
if format_invalid_count == total_count:
260+
raise ParameterExceptionError(TARGET_DISEASE_ALL_INVALID_ERROR)
261+
262+
no_mapped_target_diseases_provided = (
263+
len(valid_raw) == 0 and len(unmapped_format_valid) > 0 and format_invalid_count == 0
264+
)
265+
if no_mapped_target_diseases_provided:
266+
valid_raw = list(unmapped_format_valid)
267+
268+
if not no_mapped_target_diseases_provided and len(valid_raw) == 0:
269+
raise ParameterExceptionError(TARGET_DISEASE_ALL_INVALID_ERROR)
270+
271+
return valid_raw, no_mapped_target_diseases_provided
272+
273+
274+
def process_target_disease(params: dict[str, list[str]]) -> tuple[list[str], set[str], list[str], bool]:
275+
"""Parse target-disease parameter.
276+
277+
Returns (valid_raw_for_url, vaccine_types, invalid_diagnostics, no_mapped_target_diseases_provided).
278+
Raises ParameterExceptionError when no values or all format invalid.
279+
"""
280+
values = _extract_target_disease_values(params)
281+
redis = get_redis_client()
282+
disease_to_vaccs_map = _build_disease_to_vaccs_map(redis)
283+
valid_codes_set = _build_valid_codes_set(redis, disease_to_vaccs_map)
284+
285+
valid_raw: list[str] = []
286+
vaccine_types: set[str] = set()
287+
invalid_diagnostics: list[str] = []
288+
unmapped_format_valid: list[str] = []
289+
format_invalid_count = 0
290+
291+
for raw in values:
292+
status, code = _classify_target_disease_value(raw, valid_codes_set)
293+
if status == TARGET_DISEASE_STATUS_FORMAT_INVALID:
294+
invalid_diagnostics.append(f"Invalid format for '{raw}': {TARGET_DISEASE_FORMAT_ERROR}")
295+
format_invalid_count += 1
296+
continue
297+
if status == TARGET_DISEASE_STATUS_UNMAPPED:
298+
invalid_diagnostics.append(
299+
f"Target disease code '{code}' is not a supported target disease in this service."
300+
)
301+
unmapped_format_valid.append(raw)
302+
continue
303+
valid_raw.append(raw)
304+
vaccs_list = disease_to_vaccs_map.get(code, [])
305+
if vaccs_list:
306+
vaccine_types.update(vaccs_list)
307+
else:
308+
logger.warning(
309+
"Target disease code '%s' is considered supported but has no vaccine-type mapping",
310+
code,
311+
)
312+
313+
valid_raw, no_mapped_target_diseases_provided = _resolve_target_disease_result(
314+
valid_raw, unmapped_format_valid, format_invalid_count, len(values)
315+
)
316+
return valid_raw, vaccine_types, invalid_diagnostics, no_mapped_target_diseases_provided
317+
318+
319+
def process_mandatory_params_by_disease(
320+
params: dict[str, list[str]],
321+
) -> tuple[str, list[str], set[str], list[str], bool]:
322+
"""For target-disease search.
323+
324+
Returns (patient_identifier, valid_raw_for_url, vaccine_types, invalid_diagnostics,
325+
no_mapped_target_diseases_provided).
326+
"""
327+
patient_identifier = process_patient_identifier(params)
328+
valid_raw, vaccine_types, invalid_diagnostics, no_mapped_target_diseases_provided = process_target_disease(params)
329+
return patient_identifier, valid_raw, vaccine_types, invalid_diagnostics, no_mapped_target_diseases_provided
330+
331+
332+
def validate_and_retrieve_search_params_by_disease(params: dict[str, list[str]]) -> SearchParamsResult:
333+
"""Validate and retrieve search parameters for target-disease search."""
334+
patient_identifier, valid_raw, vaccine_types, invalid_diagnostics, no_mapped_target_diseases_provided = (
335+
process_mandatory_params_by_disease(params)
336+
)
337+
date_from, date_to, include = process_optional_params(params)
338+
339+
if date_from and date_to and date_from > date_to:
340+
raise ParameterExceptionError(
341+
f"Search parameter {ImmunizationSearchParameterName.DATE_FROM} must be before "
342+
f"{ImmunizationSearchParameterName.DATE_TO}"
343+
)
344+
345+
search_params = SearchParams(
346+
patient_identifier,
347+
vaccine_types,
348+
date_from,
349+
date_to,
350+
include,
351+
target_disease_codes_for_url=set(valid_raw) if valid_raw else None,
352+
)
353+
return SearchParamsResult(
354+
params=search_params,
355+
invalid_target_diseases=invalid_diagnostics,
356+
no_mapped_target_diseases_provided=no_mapped_target_diseases_provided,
357+
)
358+
359+
101360
def process_mandatory_params(params: dict[str, list[str]]) -> tuple[str, list[str], list[str]]:
102361
"""Validate mandatory params and return (patient_identifier, valid_vaccine_types, invalid_vaccine_types).
103362
Raises ParameterExceptionError for any validation error.
@@ -174,7 +433,8 @@ def parse_search_params(search_params_in_req: dict[str, list[str]]) -> dict[str,
174433
"""Ensures the search params provided in the event do not contain duplicated keys. Will split the parameters
175434
provided by comma separators. Raises a ParameterExceptionError for duplicated keys. Existing business logic stipulated
176435
that the API only accepts comma separated values rather than multi-value."""
177-
if any(len(values) > 1 for _, values in search_params_in_req.items()):
436+
437+
if any(len(values) > 1 for values in search_params_in_req.values()):
178438
raise ParameterExceptionError(DUPLICATED_PARAMETERS_ERROR_MESSAGE)
179439

180440
parsed_params = {}

0 commit comments

Comments
 (0)