Skip to content

Commit 8647237

Browse files
committed
NRL-1114 for nicip types allow multiple categories
1 parent 2a25ffa commit 8647237

File tree

6 files changed

+240
-57
lines changed

6 files changed

+240
-57
lines changed

api/consumer/searchDocumentReference/tests/test_search_document_reference_consumer.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,126 @@ def test_search_document_reference_happy_path_with_nicip_type(
351351
}
352352

353353

354+
@mock_aws
355+
@mock_repository
356+
def test_search_document_reference_happy_path_with_nicip_type_for_both_categories(
357+
repository: DocumentPointerRepository,
358+
):
359+
doc_ref = load_document_reference("Y05868-736253002-Valid")
360+
doc_ref.type.coding[0].code = "MAULR"
361+
doc_ref.type.coding[0].system = "https://nicip.nhs.uk"
362+
doc_ref.type.coding[0].display = "MRA Upper Limb Rt"
363+
doc_ref.category[0].coding[0].code = "721981007"
364+
doc_ref.category[0].coding[0].display = "Diagnostic Studies Report"
365+
doc_pointer = DocumentPointer.from_document_reference(doc_ref)
366+
367+
repository.create(doc_pointer)
368+
369+
doc_ref2 = load_document_reference("Y05868-736253002-Valid")
370+
doc_ref2.type.coding[0].code = "MAULR"
371+
doc_ref2.type.coding[0].system = "https://nicip.nhs.uk"
372+
doc_ref2.type.coding[0].display = "MRA Upper Limb Rt"
373+
doc_ref2.id = "Y05868-736253002-Valid2"
374+
doc_ref2.category[0].coding[0].code = "103693007"
375+
doc_ref2.category[0].coding[0].display = "Diagnostic procedure"
376+
doc_pointer2 = DocumentPointer.from_document_reference(doc_ref2)
377+
378+
repository.create(doc_pointer2)
379+
380+
event = create_test_api_gateway_event(
381+
headers=create_headers(),
382+
query_string_parameters={
383+
"subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191",
384+
"type": "https://nicip.nhs.uk|MAULR",
385+
},
386+
)
387+
388+
result = handler(event, create_mock_context())
389+
body = result.pop("body")
390+
391+
assert result == {
392+
"statusCode": "200",
393+
"headers": default_response_headers(),
394+
"isBase64Encoded": False,
395+
}
396+
397+
parsed_body = json.loads(body)
398+
assert parsed_body == {
399+
"resourceType": "Bundle",
400+
"type": "searchset",
401+
"link": [
402+
{
403+
"relation": "self",
404+
"url": "https://pytest.api.service.nhs.uk/record-locator/consumer/FHIR/R4/DocumentReference?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|6700028191&type=https://nicip.nhs.uk|MAULR",
405+
}
406+
],
407+
"total": 2,
408+
"entry": [
409+
{"resource": doc_ref2.model_dump(exclude_none=True)},
410+
{"resource": doc_ref.model_dump(exclude_none=True)},
411+
],
412+
}
413+
414+
415+
@mock_aws
416+
@mock_repository
417+
def test_search_document_reference_happy_path_with_nicip_type_for_one_category(
418+
repository: DocumentPointerRepository,
419+
):
420+
doc_ref = load_document_reference("Y05868-736253002-Valid")
421+
doc_ref.type.coding[0].code = "MAULR"
422+
doc_ref.type.coding[0].system = "https://nicip.nhs.uk"
423+
doc_ref.type.coding[0].display = "MRA Upper Limb Rt"
424+
doc_ref.category[0].coding[0].code = "721981007"
425+
doc_ref.category[0].coding[0].display = "Diagnostic Studies Report"
426+
doc_pointer = DocumentPointer.from_document_reference(doc_ref)
427+
428+
repository.create(doc_pointer)
429+
430+
doc_ref2 = load_document_reference("Y05868-736253002-Valid")
431+
doc_ref2.type.coding[0].code = "MAULR"
432+
doc_ref2.type.coding[0].system = "https://nicip.nhs.uk"
433+
doc_ref2.type.coding[0].display = "MRA Upper Limb Rt"
434+
doc_ref2.id = "Y05868-736253002-Valid2"
435+
doc_ref2.category[0].coding[0].code = "103693007"
436+
doc_ref2.category[0].coding[0].display = "Diagnostic procedure"
437+
doc_pointer2 = DocumentPointer.from_document_reference(doc_ref2)
438+
439+
repository.create(doc_pointer2)
440+
441+
event = create_test_api_gateway_event(
442+
headers=create_headers(),
443+
query_string_parameters={
444+
"subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191",
445+
"type": "https://nicip.nhs.uk|MAULR",
446+
"category": "http://snomed.info/sct|721981007",
447+
},
448+
)
449+
450+
result = handler(event, create_mock_context())
451+
body = result.pop("body")
452+
453+
assert result == {
454+
"statusCode": "200",
455+
"headers": default_response_headers(),
456+
"isBase64Encoded": False,
457+
}
458+
459+
parsed_body = json.loads(body)
460+
assert parsed_body == {
461+
"resourceType": "Bundle",
462+
"type": "searchset",
463+
"link": [
464+
{
465+
"relation": "self",
466+
"url": "https://pytest.api.service.nhs.uk/record-locator/consumer/FHIR/R4/DocumentReference?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|6700028191&type=https://nicip.nhs.uk|MAULR&category=http://snomed.info/sct|721981007",
467+
}
468+
],
469+
"total": 1,
470+
"entry": [{"resource": doc_ref.model_dump(exclude_none=True)}],
471+
}
472+
473+
354474
@mock_aws
355475
@mock_repository
356476
def test_search_document_reference_no_results(repository: DocumentPointerRepository):

layer/nrlf/core/constants.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,14 @@ def coding_value(self):
180180
PointerTypes.SUMMARY_RECORD.value: Categories.CLINICAL_NOTE.value,
181181
#
182182
# Imaging
183-
PointerTypes.MRA_UPPER_LIMB_ARTERY.value: Categories.DIAGNOSTIC_STUDIES_REPORT.value,
184-
PointerTypes.MRI_AXILLA_BOTH.value: Categories.DIAGNOSTIC_PROCEDURE.value,
183+
PointerTypes.MRA_UPPER_LIMB_ARTERY.value: {
184+
Categories.DIAGNOSTIC_STUDIES_REPORT.value,
185+
Categories.DIAGNOSTIC_PROCEDURE.value,
186+
},
187+
PointerTypes.MRI_AXILLA_BOTH.value: {
188+
Categories.DIAGNOSTIC_PROCEDURE.value,
189+
Categories.DIAGNOSTIC_STUDIES_REPORT.value,
190+
},
185191
}
186192

187193
PRACTICE_SETTING_VALUE_SET_URL = (

layer/nrlf/core/dynamodb/repository.py

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,16 @@
1515
RepositoryModel = TypeVar("RepositoryModel", bound=DynamoDBModel)
1616

1717

18-
def _get_sk_ids_for_type(pointer_type: str) -> tuple:
18+
def _get_sk_ids_for_type(pointer_type: str) -> tuple[str, str]:
1919
if pointer_type not in TYPE_CATEGORIES:
2020
raise ValueError(f"Cannot find category for pointer type: {pointer_type}")
2121

22-
category = TYPE_CATEGORIES[pointer_type]
22+
categories = TYPE_CATEGORIES[pointer_type]
23+
if isinstance(categories, str):
24+
category = categories
25+
else:
26+
category = next(iter(categories))
27+
2328
category_system, category_code = category.split("|")
2429
if category_system not in SYSTEM_SHORT_IDS:
2530
raise ValueError(f"Unknown system for category: {category_system}")
@@ -158,7 +163,7 @@ def count_by_nhs_number(
158163

159164
if len(pointer_types) == 1:
160165
# Optimisation for single pointer type
161-
category_id, type_id = _get_sk_ids_for_type(pointer_types[0])
166+
category_id, type_id = _get_sk_ids_for_type(pointer_types[0])[0]
162167
patient_sort = f"C#{category_id}#T#{type_id}"
163168
key_conditions.append("begins_with(patient_sort, :patient_sort)")
164169
expression_values[":patient_sort"] = patient_sort
@@ -220,56 +225,80 @@ def search(
220225
pointer_types: Optional[List[str]] = [],
221226
categories: Optional[List[str]] = [],
222227
) -> Iterator[DocumentPointer]:
223-
""""""
224228
logger.log(
225229
LogReference.REPOSITORY020,
226230
nhs_number=nhs_number,
227231
custodian=custodian,
228232
pointer_types=pointer_types,
233+
categories=categories,
229234
)
230235

231236
key_conditions = ["patient_key = :patient_key"]
232237
filter_expressions = []
233238
expression_names = {}
234239
expression_values = {":patient_key": f"P#{nhs_number}"}
235240

236-
if len(pointer_types) == 1:
237-
# Optimisation for single pointer type
238-
category_id, type_id = _get_sk_ids_for_type(pointer_types[0])
239-
patient_sort = f"C#{category_id}#T#{type_id}"
240-
key_conditions.append("begins_with(patient_sort, :patient_sort)")
241-
expression_values[":patient_sort"] = patient_sort
242-
else:
243-
# Handle single/multiple categories and pointer types with filter expressions
244-
if len(categories) == 1:
245-
split_category = categories[0].split("|")
246-
category_id = (
247-
SYSTEM_SHORT_IDS[split_category[0]] + "-" + split_category[1]
248-
)
249-
patient_sort = f"C#{category_id}"
250-
key_conditions.append("begins_with(patient_sort, :patient_sort)")
251-
expression_values[":patient_sort"] = patient_sort
252-
253-
if len(categories) > 1:
254-
expression_names["#category"] = "category"
255-
category_filters = [
256-
f"#category = :category_{i}" for i in range(len(categories))
257-
]
258-
category_filter_values = {
259-
f":category_{i}": categories[i] for i in range(len(categories))
260-
}
261-
filter_expressions.append(f"({' OR '.join(category_filters)})")
262-
expression_values.update(category_filter_values)
263-
241+
# If both categories and pointer_types are provided, filter on both
242+
if pointer_types and categories:
264243
expression_names["#pointer_type"] = "type"
244+
expression_names["#category"] = "category"
265245
types_filters = [
266246
f"#pointer_type = :type_{i}" for i in range(len(pointer_types))
267247
]
268248
types_filter_values = {
269249
f":type_{i}": pointer_types[i] for i in range(len(pointer_types))
270250
}
251+
category_filters = [
252+
f"#category = :category_{i}" for i in range(len(categories))
253+
]
254+
category_filter_values = {
255+
f":category_{i}": categories[i] for i in range(len(categories))
256+
}
271257
filter_expressions.append(f"({' OR '.join(types_filters)})")
258+
filter_expressions.append(f"({' OR '.join(category_filters)})")
272259
expression_values.update(types_filter_values)
260+
expression_values.update(category_filter_values)
261+
262+
# If only pointer_types are provided, retrieve all categories for each type and filter on both
263+
elif pointer_types and not categories:
264+
expression_names["#pointer_type"] = "type"
265+
expression_names["#category"] = "category"
266+
types_filters = []
267+
category_filters = []
268+
types_filter_values = {}
269+
category_filter_values = {}
270+
category_set = set()
271+
for i, pointer_type in enumerate(pointer_types):
272+
types_filters.append(f"#pointer_type = :type_{i}")
273+
types_filter_values[f":type_{i}"] = pointer_type
274+
# Get all categories for this type, handling both set and string
275+
if pointer_type in TYPE_CATEGORIES:
276+
cats = TYPE_CATEGORIES[pointer_type]
277+
if isinstance(cats, str):
278+
category_set.add(cats)
279+
else:
280+
category_set.update(cats)
281+
for j, cat in enumerate(category_set):
282+
category_filters.append(f"#category = :category_{j}")
283+
category_filter_values[f":category_{j}"] = cat
284+
if types_filters:
285+
filter_expressions.append(f"({' OR '.join(types_filters)})")
286+
if category_filters:
287+
filter_expressions.append(f"({' OR '.join(category_filters)})")
288+
expression_values.update(types_filter_values)
289+
expression_values.update(category_filter_values)
290+
291+
# If only categories are provided, filter on categories
292+
elif categories and not pointer_types:
293+
expression_names["#category"] = "category"
294+
category_filters = [
295+
f"#category = :category_{i}" for i in range(len(categories))
296+
]
297+
category_filter_values = {
298+
f":category_{i}": categories[i] for i in range(len(categories))
299+
}
300+
filter_expressions.append(f"({' OR '.join(category_filters)})")
301+
expression_values.update(category_filter_values)
273302

274303
if custodian:
275304
logger.log(

layer/nrlf/core/tests/test_type_categories.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ def test_pointer_types(pointer_type):
2525

2626
@pytest.mark.parametrize("category", Categories)
2727
def test_pointer_category_has_types(category):
28-
assert (
29-
category.value in TYPE_CATEGORIES.values()
28+
assert any(
29+
category.value in cat_set for cat_set in TYPE_CATEGORIES.values()
3030
), f"Pointer category {category.value} is not used by any type"
3131

3232

@@ -42,11 +42,17 @@ def test_type_category_type_is_known(type):
4242
assert type in PointerTypes.list(), f"Unknown type {type} used in TYPE_CATEGORIES"
4343

4444

45-
@pytest.mark.parametrize("category", TYPE_CATEGORIES.values())
46-
def test_type_category_category_is_known(category):
47-
assert (
48-
category in Categories.list()
49-
), f"Unknown category {category} used in TYPE_CATEGORIES"
45+
@pytest.mark.parametrize("cat_set", TYPE_CATEGORIES.values())
46+
def test_type_category_category_is_known(cat_set):
47+
if isinstance(cat_set, (set, list, tuple)):
48+
for category in cat_set:
49+
assert (
50+
category in Categories.list()
51+
), f"Unknown category {category} used in TYPE_CATEGORIES"
52+
else:
53+
assert (
54+
cat_set in Categories.list()
55+
), f"Unknown category {cat_set} used in TYPE_CATEGORIES"
5056

5157

5258
@pytest.mark.parametrize("category", Categories)

layer/nrlf/core/tests/test_validators.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -397,8 +397,11 @@ def test_validate_category_coding_display_mismatch(
397397
matching_type_str = next(
398398
(
399399
type_str
400-
for type_str in TYPE_CATEGORIES
401-
if TYPE_CATEGORIES[type_str] == category_str
400+
for type_str, cat_val in TYPE_CATEGORIES.items()
401+
if (
402+
(isinstance(cat_val, set) and category_str in cat_val)
403+
or (isinstance(cat_val, str) and cat_val == category_str)
404+
)
402405
),
403406
None,
404407
)
@@ -417,11 +420,12 @@ def test_validate_category_coding_display_mismatch(
417420
}
418421

419422
result = validator.validate(document_ref_data)
420-
423+
expected_issues = 2 if type_code == "https://nicip.nhs.uk|" else 1
424+
issue_number = 1 if type_code == "https://nicip.nhs.uk|" else 0
421425
assert result.is_valid is False
422426
assert result.resource.id == "Y05868-99999-99999-999999"
423-
assert len(result.issues) == 1
424-
assert result.issues[0].model_dump(exclude_none=True) == {
427+
assert len(result.issues) == expected_issues
428+
assert result.issues[issue_number].model_dump(exclude_none=True) == {
425429
"severity": "error",
426430
"code": "business-rule",
427431
"details": {
@@ -603,10 +607,15 @@ def test_validate_type_coding_display_mismatch(type_str: str, display: str):
603607
}
604608

605609
# Find the category string that matches the category code to avoid that error
606-
category_str = TYPE_CATEGORIES[type_str]
610+
category_val = TYPE_CATEGORIES[type_str]
611+
if isinstance(category_val, set):
612+
category_str = next(iter(category_val))
613+
else:
614+
category_str = category_val
607615
category_parts = category_str.split("|")
608616
category_system = category_parts[0]
609617
category_code = category_parts[1]
618+
610619
document_ref_data["category"][0] = {
611620
"coding": [
612621
{

0 commit comments

Comments
 (0)