diff --git a/api/producer/createDocumentReference/create_document_reference.py b/api/producer/createDocumentReference/create_document_reference.py index 72450ea3f..0e0c70021 100644 --- a/api/producer/createDocumentReference/create_document_reference.py +++ b/api/producer/createDocumentReference/create_document_reference.py @@ -4,6 +4,7 @@ from nrlf.core.constants import ( PERMISSION_AUDIT_DATES_FROM_PAYLOAD, PERMISSION_SUPERSEDE_IGNORE_DELETE_FAIL, + TYPES_WITH_MULTIPLES, ) from nrlf.core.decorators import request_handler from nrlf.core.dynamodb.repository import DocumentPointer, DocumentPointerRepository @@ -255,6 +256,24 @@ def handler( logger.log(LogReference.PROCREATE999) return NRLResponse.RESOURCE_SUPERSEDED(resource_id=result.resource.id) + pointer_type = core_model.type + if pointer_type not in TYPES_WITH_MULTIPLES: + nhs_number = core_model.nhs_number + existing_pointers_count = repository.count_by_nhs_number( + nhs_number, [pointer_type] + ) + + if existing_pointers_count > 0: + logger.log( + LogReference.PROCREATE012, + new_pointer_id=core_model.id, + new_pointer_master_id=core_model.master_identifier, + pointer_type=pointer_type, + nhs_number=nhs_number, + custodian=core_model.custodian, + existing_pointers_count=existing_pointers_count, + ) + logger.log(LogReference.PROCREATE009, pointer_id=result.resource.id) repository.create(core_model) logger.log(LogReference.PROCREATE999) diff --git a/api/producer/createDocumentReference/tests/test_create_document_reference.py b/api/producer/createDocumentReference/tests/test_create_document_reference.py index 55bba64a7..a702d78c5 100644 --- a/api/producer/createDocumentReference/tests/test_create_document_reference.py +++ b/api/producer/createDocumentReference/tests/test_create_document_reference.py @@ -1,5 +1,5 @@ import json -from unittest.mock import patch +from unittest.mock import Mock, patch from freeze_uuid import freeze_uuid from freezegun import freeze_time @@ -1734,3 +1734,144 @@ def test__set_create_time_fields_when_no_date_but_perms(): }, "date": test_time, } + + +@mock_aws +@mock_repository +@freeze_uuid("00000000-0000-0000-0000-000000000001") +@patch("api.producer.createDocumentReference.create_document_reference.logger") +def test_create_logs_for_unexpected_multi_pointer( + mock_logger: Mock, + repository: DocumentPointerRepository, +): + doc_ref = load_document_reference("Y05868-736253002-Valid-with-master-id") + doc_pointer = DocumentPointer.from_document_reference(doc_ref) + repository.create(doc_pointer) + + event = create_test_api_gateway_event( + headers=create_headers(), + body=doc_ref.model_dump_json(exclude_none=True), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "201", + "headers": { + "Location": "/DocumentReference/Y05868-00000000-0000-0000-0000-000000000001", + **default_response_headers(), + }, + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "information", + "code": "informational", + "details": { + "coding": [ + { + "code": "RESOURCE_CREATED", + "display": "Resource created", + "system": "https://fhir.nhs.uk/ValueSet/NRL-ResponseCode", + } + ] + }, + "diagnostics": "The document has been created", + } + ], + } + + assert any( + call[0][0].name == "PROCREATE012" for call in mock_logger.log.call_args_list + ) + + assert { + "existing_pointers_count": 1, + "nhs_number": ( + doc_ref.subject.identifier.value + if doc_ref.subject and doc_ref.subject.identifier + else None + ), + "pointer_type": ( + f"{doc_ref.type.coding[0].system}|{doc_ref.type.coding[0].code}" + if doc_ref.type and doc_ref.type.coding + else None + ), + "custodian": ( + doc_ref.custodian.identifier.value + if doc_ref.custodian and doc_ref.custodian.identifier + else None + ), + "new_pointer_id": "Y05868-00000000-0000-0000-0000-000000000001", + "new_pointer_master_id": ( + doc_ref.masterIdentifier.value if doc_ref.masterIdentifier else None + ), + } == [ + call[1:][0] + for call in mock_logger.log.call_args_list + if call[0][0].name == "PROCREATE012" + ][ + 0 + ] + + +@mock_aws +@mock_repository +@freeze_uuid("00000000-0000-0000-0000-000000000001") +@patch("api.producer.createDocumentReference.create_document_reference.logger") +def test_create_logs_for_expected_multi_pointer( + mock_logger: Mock, + repository: DocumentPointerRepository, +): + doc_ref = load_document_reference("Y05868-Appointment-Valid") + doc_pointer = DocumentPointer.from_document_reference(doc_ref) + repository.create(doc_pointer) + + event = create_test_api_gateway_event( + headers=create_headers(), + body=doc_ref.model_dump_json(exclude_none=True), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "201", + "headers": { + "Location": "/DocumentReference/Y05868-00000000-0000-0000-0000-000000000001", + **default_response_headers(), + }, + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "information", + "code": "informational", + "details": { + "coding": [ + { + "code": "RESOURCE_CREATED", + "display": "Resource created", + "system": "https://fhir.nhs.uk/ValueSet/NRL-ResponseCode", + } + ] + }, + "diagnostics": "The document has been created", + } + ], + } + + assert not any( + call[0][0].name == "PROCREATE012" for call in mock_logger.log.call_args_list + ) diff --git a/api/producer/upsertDocumentReference/tests/test_upsert_document_reference.py b/api/producer/upsertDocumentReference/tests/test_upsert_document_reference.py index 92242ea46..26280ed6e 100644 --- a/api/producer/upsertDocumentReference/tests/test_upsert_document_reference.py +++ b/api/producer/upsertDocumentReference/tests/test_upsert_document_reference.py @@ -1,5 +1,5 @@ import json -from unittest.mock import patch +from unittest.mock import Mock, patch from freezegun import freeze_time from moto import mock_aws @@ -1699,3 +1699,146 @@ def test__set_create_time_fields_when_no_date_but_perms(): }, "date": test_time, } + + +@mock_aws +@mock_repository +@patch("api.producer.upsertDocumentReference.upsert_document_reference.logger") +def test_upsert_logs_for_unexpected_multi_pointer( + mock_logger: Mock, + repository: DocumentPointerRepository, +): + doc_ref = load_document_reference("Y05868-736253002-Valid-with-master-id") + doc_pointer = DocumentPointer.from_document_reference(doc_ref) + repository.create(doc_pointer) + + doc_ref.id = "Y05868-99999-99999-999999-02" + + event = create_test_api_gateway_event( + headers=create_headers(), + body=doc_ref.model_dump_json(exclude_none=True), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "201", + "headers": { + "Location": "/DocumentReference/Y05868-99999-99999-999999-02", + **default_response_headers(), + }, + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "information", + "code": "informational", + "details": { + "coding": [ + { + "code": "RESOURCE_CREATED", + "display": "Resource created", + "system": "https://fhir.nhs.uk/ValueSet/NRL-ResponseCode", + } + ] + }, + "diagnostics": "The document has been created", + } + ], + } + + assert any( + call[0][0].name == "PROUPSERT012" for call in mock_logger.log.call_args_list + ) + + assert { + "existing_pointers_count": 1, + "nhs_number": ( + doc_ref.subject.identifier.value + if doc_ref.subject and doc_ref.subject.identifier + else None + ), + "pointer_type": ( + f"{doc_ref.type.coding[0].system}|{doc_ref.type.coding[0].code}" + if doc_ref.type and doc_ref.type.coding + else None + ), + "custodian": ( + doc_ref.custodian.identifier.value + if doc_ref.custodian and doc_ref.custodian.identifier + else None + ), + "new_pointer_id": doc_ref.id, + "new_pointer_master_id": ( + doc_ref.masterIdentifier.value if doc_ref.masterIdentifier else None + ), + } == [ + call[1:][0] + for call in mock_logger.log.call_args_list + if call[0][0].name == "PROUPSERT012" + ][ + 0 + ] + + +@mock_aws +@mock_repository +@patch("api.producer.upsertDocumentReference.upsert_document_reference.logger") +def test_upsert_logs_for_expected_multi_pointer( + mock_logger: Mock, + repository: DocumentPointerRepository, +): + doc_ref = load_document_reference("Y05868-Appointment-Valid") + doc_pointer = DocumentPointer.from_document_reference(doc_ref) + repository.create(doc_pointer) + + doc_ref.id = "Y05868-99999-99999-999999-02" + + event = create_test_api_gateway_event( + headers=create_headers(), + body=doc_ref.model_dump_json(exclude_none=True), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "201", + "headers": { + "Location": "/DocumentReference/Y05868-99999-99999-999999-02", + **default_response_headers(), + }, + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "information", + "code": "informational", + "details": { + "coding": [ + { + "code": "RESOURCE_CREATED", + "display": "Resource created", + "system": "https://fhir.nhs.uk/ValueSet/NRL-ResponseCode", + } + ] + }, + "diagnostics": "The document has been created", + } + ], + } + + assert not any( + call[0][0].name == "PROUPSERT012" for call in mock_logger.log.call_args_list + ) diff --git a/api/producer/upsertDocumentReference/upsert_document_reference.py b/api/producer/upsertDocumentReference/upsert_document_reference.py index eb2f39c51..b57a1bf2b 100644 --- a/api/producer/upsertDocumentReference/upsert_document_reference.py +++ b/api/producer/upsertDocumentReference/upsert_document_reference.py @@ -2,6 +2,7 @@ from nrlf.core.constants import ( PERMISSION_AUDIT_DATES_FROM_PAYLOAD, PERMISSION_SUPERSEDE_IGNORE_DELETE_FAIL, + TYPES_WITH_MULTIPLES, ) from nrlf.core.decorators import request_handler from nrlf.core.dynamodb.repository import DocumentPointer, DocumentPointerRepository @@ -262,6 +263,24 @@ def handler( logger.log(LogReference.PROUPSERT999) return NRLResponse.RESOURCE_SUPERSEDED(resource_id=saved_model.id) + pointer_type = core_model.type + if pointer_type not in TYPES_WITH_MULTIPLES: + nhs_number = core_model.nhs_number + existing_pointers_count = repository.count_by_nhs_number( + nhs_number, [pointer_type] + ) + + if existing_pointers_count > 0: + logger.log( + LogReference.PROUPSERT012, + new_pointer_id=core_model.id, + new_pointer_master_id=core_model.master_identifier, + pointer_type=pointer_type, + nhs_number=nhs_number, + custodian=core_model.custodian, + existing_pointers_count=existing_pointers_count, + ) + logger.log(LogReference.PROUPSERT009, pointer_id=result.resource.id) saved_model = repository.create(core_model) logger.log(LogReference.PROUPSERT999) diff --git a/layer/nrlf/core/constants.py b/layer/nrlf/core/constants.py index b5cc3bb9c..08f58e6e4 100644 --- a/layer/nrlf/core/constants.py +++ b/layer/nrlf/core/constants.py @@ -200,6 +200,14 @@ def coding_value(self): PointerTypes.SHARED_CARE_RECORD.value: Categories.RECORD_HEADINGS.value, } +# +# Pointer types that can have multiple pointers for a single patient +TYPES_WITH_MULTIPLES = [ + PointerTypes.MRA_UPPER_LIMB_ARTERY.value, + PointerTypes.MRI_AXILLA_BOTH.value, + PointerTypes.APPOINTMENT.value, +] + PRACTICE_SETTING_VALUE_SET_URL = ( "https://fhir.nhs.uk/England/ValueSet/England-PracticeSetting" ) diff --git a/layer/nrlf/core/log_references.py b/layer/nrlf/core/log_references.py index b8948fa0c..eb4fbd33e 100644 --- a/layer/nrlf/core/log_references.py +++ b/layer/nrlf/core/log_references.py @@ -270,6 +270,9 @@ class LogReference(Enum): PROCREATE011 = _Reference( "INFO", "Preserved .date field when creating new document reference" ) + PROCREATE012 = _Reference( + "WARN", "Existing pointers found for patient during create operation" + ) PROCREATE999 = _Reference( "INFO", "Successfully completed producer createDocumentReference" ) @@ -329,6 +332,9 @@ class LogReference(Enum): PROUPSERT011 = _Reference( "INFO", "Preserved .date field when creating new document reference for upsert" ) + PROUPSERT012 = _Reference( + "WARN", "Existing pointers found for patient during upsert operation" + ) PROUPSERT999 = _Reference( "INFO", "Successfully completed producer upsertDocumentReference" ) diff --git a/layer/nrlf/core/logger.py b/layer/nrlf/core/logger.py index 72169bef7..a639b36ba 100644 --- a/layer/nrlf/core/logger.py +++ b/layer/nrlf/core/logger.py @@ -1,5 +1,6 @@ import os from datetime import datetime +from typing import Any from aws_lambda_powertools import Logger as PowertoolsLogger from aws_lambda_powertools.logging.formatter import LambdaPowertoolsFormatter @@ -9,7 +10,7 @@ class SplunkFormatter(LambdaPowertoolsFormatter): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.splunk_index = os.getenv("SPLUNK_INDEX", "aws_recordlocator_dev") @@ -30,7 +31,7 @@ def serialize(self, log: LogRecord) -> str: class Logger(PowertoolsLogger): - def log(self, code: LogReference, **kwargs): + def log(self, code: LogReference, **kwargs: Any) -> None: kwargs["log_reference"] = code.name match code.value.level: case "DEBUG": @@ -45,6 +46,11 @@ def log(self, code: LogReference, **kwargs): self.critical(code.value.message, stacklevel=3, **kwargs) case "EXCEPTION": self.exception(code.value.message, **kwargs) + case _: + self.warning( + f"Unhandled log level: {code.value.level} - {code.value.message}", + **kwargs, + ) logger = Logger(logger_formatter=SplunkFormatter()) diff --git a/layer/nrlf/tests/events.py b/layer/nrlf/tests/events.py index c32d7cd31..aa39952be 100644 --- a/layer/nrlf/tests/events.py +++ b/layer/nrlf/tests/events.py @@ -1,5 +1,5 @@ import json -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from unittest.mock import Mock @@ -46,7 +46,7 @@ def create_test_api_gateway_event( query_string_parameters: Optional[Dict[str, str]] = None, path_parameters: Optional[Dict[str, str]] = None, body: Optional[str] = None, -): +) -> Dict[str, Any]: return { "resource": "/", "path": "/", diff --git a/layer/test_permissions/Y05868-TestApp-12345678/Y05868.json b/layer/test_permissions/Y05868-TestApp-12345678/Y05868.json index aa06c268c..a717f887e 100644 --- a/layer/test_permissions/Y05868-TestApp-12345678/Y05868.json +++ b/layer/test_permissions/Y05868-TestApp-12345678/Y05868.json @@ -3,5 +3,6 @@ "http://snomed.info/sct|736253002", "http://snomed.info/sct|1363501000000100", "http://snomed.info/sct|861421000000109", + "http://snomed.info/sct|749001000000101", "https://nicip.nhs.uk|MAULR" ] diff --git a/tests/data/DocumentReference/Y05868-736253002-Valid-with-master-id.json b/tests/data/DocumentReference/Y05868-736253002-Valid-with-master-id.json new file mode 100644 index 000000000..15d92f465 --- /dev/null +++ b/tests/data/DocumentReference/Y05868-736253002-Valid-with-master-id.json @@ -0,0 +1,111 @@ +{ + "resourceType": "DocumentReference", + "id": "Y05868-99999-99999-999999", + "status": "current", + "docStatus": "final", + "type": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "736253002", + "display": "Mental health crisis plan" + } + ] + }, + "category": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "734163000", + "display": "Care plan" + } + ] + } + ], + "subject": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "6700028191" + } + }, + "author": [ + { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y05868" + } + } + ], + "custodian": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y05868" + } + }, + "description": "Physical document mental health crisis plan", + "securityLabel": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-Confidentiality", + "code": "V", + "display": "very restricted" + } + ] + } + ], + "content": [ + { + "attachment": { + "contentType": "application/pdf", + "language": "en-US", + "url": "https://spine-proxy.national.ncrs.nhs.uk/https%3A%2F%2Fp1.nhs.uk%2FMentalhealthCrisisPlanReport.pdf", + "size": 3654, + "hash": "2jmj7l5rSw0yVb/vlWAYkK/YBwk=", + "title": "Mental health crisis plan report", + "creation": "2022-12-21T10:45:41+11:00" + }, + "format": { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLFormatCode", + "code": "urn:nhs-ic:unstructured", + "display": "Unstructured Document" + }, + "extension": [ + { + "url": "https://fhir.nhs.uk/England/StructureDefinition/Extension-England-ContentStability", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLContentStability", + "code": "static", + "display": "Static" + } + ] + } + } + ] + } + ], + "context": { + "practiceSetting": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "788002001", + "display": "Adult mental health service" + } + ] + }, + "sourcePatientInfo": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "6700028191" + } + } + }, + "masterIdentifier": { + "system": "https://fhir.nhs.uk/Id/document-reference-master-id", + "value": "Y05868-736253002-0000000001" + } +} diff --git a/tests/data/DocumentReference/Y05868-Appointment-Valid.json b/tests/data/DocumentReference/Y05868-Appointment-Valid.json new file mode 100644 index 000000000..a0f7c1519 --- /dev/null +++ b/tests/data/DocumentReference/Y05868-Appointment-Valid.json @@ -0,0 +1,111 @@ +{ + "resourceType": "DocumentReference", + "id": "Y05868-99999-99999-999999", + "status": "current", + "docStatus": "final", + "type": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "749001000000101", + "display": "Appointment" + } + ] + }, + "category": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "419891008", + "display": "Record artifact" + } + ] + } + ], + "subject": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "6700028191" + } + }, + "author": [ + { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y05868" + } + } + ], + "custodian": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y05868" + } + }, + "description": "Physical document mental health crisis plan", + "securityLabel": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-Confidentiality", + "code": "V", + "display": "very restricted" + } + ] + } + ], + "content": [ + { + "attachment": { + "contentType": "application/pdf", + "language": "en-US", + "url": "https://spine-proxy.national.ncrs.nhs.uk/https%3A%2F%2Fp1.nhs.uk%2FMentalhealthCrisisPlanReport.pdf", + "size": 3654, + "hash": "2jmj7l5rSw0yVb/vlWAYkK/YBwk=", + "title": "Mental health crisis plan report", + "creation": "2022-12-21T10:45:41+11:00" + }, + "format": { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLFormatCode", + "code": "urn:nhs-ic:unstructured", + "display": "Unstructured Document" + }, + "extension": [ + { + "url": "https://fhir.nhs.uk/England/StructureDefinition/Extension-England-ContentStability", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLContentStability", + "code": "static", + "display": "Static" + } + ] + } + } + ] + } + ], + "context": { + "practiceSetting": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "788002001", + "display": "Adult mental health service" + } + ] + }, + "sourcePatientInfo": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "6700028191" + } + } + }, + "masterIdentifier": { + "system": "https://fhir.nhs.uk/Id/document-reference-master-id", + "value": "Y05868-736253002-0000000001" + } +}