|
| 1 | +from uuid import uuid4 |
| 2 | + |
| 3 | +from nrlf.core.codes import SpineErrorConcept |
| 4 | +from nrlf.core.constants import ( |
| 5 | + PERMISSION_AUDIT_DATES_FROM_PAYLOAD, |
| 6 | + PERMISSION_SUPERSEDE_IGNORE_DELETE_FAIL, |
| 7 | +) |
| 8 | +from nrlf.core.decorators import request_handler |
| 9 | +from nrlf.core.dynamodb.repository import DocumentPointer, DocumentPointerRepository |
| 10 | +from nrlf.core.errors import OperationOutcomeError |
| 11 | +from nrlf.core.logger import LogReference, logger |
| 12 | +from nrlf.core.model import ConnectionMetadata |
| 13 | +from nrlf.core.response import NRLResponse, Response, SpineErrorResponse |
| 14 | +from nrlf.core.utils import create_fhir_instant |
| 15 | +from nrlf.core.validators import DocumentReferenceValidator |
| 16 | +from nrlf.producer.fhir.r4.model import ( |
| 17 | + BaseModel, |
| 18 | + Bundle, |
| 19 | + DocumentReference, |
| 20 | + DocumentReferenceRelatesTo, |
| 21 | + ExpressionItem, |
| 22 | + Meta, |
| 23 | + OperationOutcomeIssue, |
| 24 | +) |
| 25 | + |
| 26 | + |
| 27 | +def _set_create_time_fields( |
| 28 | + create_time: str, document_reference: DocumentReference, nrl_permissions: list[str] |
| 29 | +) -> DocumentReference: |
| 30 | + """ |
| 31 | + Set the date and lastUpdated timestamps on the provided DocumentReference |
| 32 | + """ |
| 33 | + if not document_reference.meta: |
| 34 | + document_reference.meta = Meta() |
| 35 | + document_reference.meta.lastUpdated = create_time |
| 36 | + |
| 37 | + if ( |
| 38 | + document_reference.date |
| 39 | + and PERMISSION_AUDIT_DATES_FROM_PAYLOAD in nrl_permissions |
| 40 | + ): |
| 41 | + # Perserving the original date if it exists and the permission is set |
| 42 | + logger.log( |
| 43 | + LogReference.PROCREATE011, |
| 44 | + id=document_reference.id, |
| 45 | + date=document_reference.date, |
| 46 | + ) |
| 47 | + else: |
| 48 | + document_reference.date = create_time |
| 49 | + |
| 50 | + return document_reference |
| 51 | + |
| 52 | + |
| 53 | +def _create_core_model(resource: DocumentReference, metadata: ConnectionMetadata): |
| 54 | + """ |
| 55 | + Create the DocumentPointer model from the provided DocumentReference |
| 56 | + """ |
| 57 | + creation_time = create_fhir_instant() |
| 58 | + document_reference = _set_create_time_fields( |
| 59 | + creation_time, |
| 60 | + document_reference=resource, |
| 61 | + nrl_permissions=metadata.nrl_permissions, |
| 62 | + ) |
| 63 | + |
| 64 | + return DocumentPointer.from_document_reference( |
| 65 | + document_reference, created_on=creation_time |
| 66 | + ) |
| 67 | + |
| 68 | + |
| 69 | +def _check_permissions( |
| 70 | + core_model: DocumentPointer, metadata: ConnectionMetadata |
| 71 | +) -> Response | None: |
| 72 | + """ |
| 73 | + Check the requester has permissions to create the DocumentReference |
| 74 | + """ |
| 75 | + custodian_parts = tuple( |
| 76 | + filter(None, (core_model.custodian, core_model.custodian_suffix)) |
| 77 | + ) |
| 78 | + if metadata.ods_code_parts != custodian_parts: |
| 79 | + logger.log( |
| 80 | + LogReference.PROCREATE004, |
| 81 | + ods_code_parts=metadata.ods_code_parts, |
| 82 | + custodian_parts=custodian_parts, |
| 83 | + ) |
| 84 | + return SpineErrorResponse.BAD_REQUEST( |
| 85 | + diagnostics="The custodian of the provided DocumentReference does not match the expected ODS code for this organisation", |
| 86 | + expression="custodian.identifier.value", |
| 87 | + ) |
| 88 | + |
| 89 | + if core_model.type not in metadata.pointer_types: |
| 90 | + logger.log( |
| 91 | + LogReference.PROCREATE005, |
| 92 | + ods_code=metadata.ods_code, |
| 93 | + type=core_model.type, |
| 94 | + pointer_types=metadata.pointer_types, |
| 95 | + ) |
| 96 | + return SpineErrorResponse.AUTHOR_CREDENTIALS_ERROR( |
| 97 | + diagnostics="The type of the provided DocumentReference is not in the list of allowed types for this organisation", |
| 98 | + expression="type.coding[0].code", |
| 99 | + ) |
| 100 | + |
| 101 | + return None |
| 102 | + |
| 103 | + |
| 104 | +def _get_document_ids_to_supersede( |
| 105 | + resource: DocumentReference, |
| 106 | + core_model: DocumentPointer, |
| 107 | + metadata: ConnectionMetadata, |
| 108 | + repository: DocumentPointerRepository, |
| 109 | + can_ignore_delete_fail: bool, |
| 110 | +) -> list[str]: |
| 111 | + """ |
| 112 | + Get the list of document IDs to supersede based on the relatesTo field |
| 113 | + """ |
| 114 | + if not resource.relatesTo: |
| 115 | + return [] |
| 116 | + |
| 117 | + logger.log(LogReference.PROCREATE006, relatesTo=resource.relatesTo) |
| 118 | + ids_to_delete: list[str] = [] |
| 119 | + |
| 120 | + for idx, relates_to in enumerate(resource.relatesTo): |
| 121 | + identifier = _validate_identifier(relates_to, idx) |
| 122 | + _validate_producer_id(identifier, metadata, idx) |
| 123 | + |
| 124 | + if not can_ignore_delete_fail: |
| 125 | + existing_pointer = _check_existing_pointer(identifier, repository, idx) |
| 126 | + _validate_pointer_details(existing_pointer, core_model, identifier, idx) |
| 127 | + |
| 128 | + _append_id_if_replaces(relates_to, ids_to_delete, identifier) |
| 129 | + |
| 130 | + return ids_to_delete |
| 131 | + |
| 132 | + |
| 133 | +def _validate_identifier( |
| 134 | + relates_to: DocumentReferenceRelatesTo, idx: str |
| 135 | +) -> str | None: |
| 136 | + """ |
| 137 | + Validate that there is a identifier in relatesTo target |
| 138 | + """ |
| 139 | + identifier = getattr(relates_to.target.identifier, "value", None) |
| 140 | + if not identifier: |
| 141 | + logger.log(LogReference.PROCREATE007a) |
| 142 | + _raise_operation_outcome_error( |
| 143 | + "No identifier value provided for relatesTo target", idx |
| 144 | + ) |
| 145 | + return identifier |
| 146 | + |
| 147 | + |
| 148 | +def _validate_producer_id(identifier, metadata, idx): |
| 149 | + """ |
| 150 | + Validate that there is an ODS code in the relatesTo target identifier |
| 151 | + """ |
| 152 | + producer_id = identifier.split("-", 1)[0] |
| 153 | + if metadata.ods_code_parts != tuple(producer_id.split("|")): |
| 154 | + logger.log( |
| 155 | + LogReference.PROCREATE007b, |
| 156 | + related_identifier=identifier, |
| 157 | + ods_code_parts=metadata.ods_code_parts, |
| 158 | + ) |
| 159 | + _raise_operation_outcome_error( |
| 160 | + "The relatesTo target identifier value does not include the expected ODS code for this organisation", |
| 161 | + idx, |
| 162 | + ) |
| 163 | + |
| 164 | + |
| 165 | +def _check_existing_pointer(identifier, repository, idx): |
| 166 | + """ |
| 167 | + Check that there is an existing pointer that will be deleted when superseding |
| 168 | + """ |
| 169 | + existing_pointer = repository.get_by_id(identifier) |
| 170 | + if not existing_pointer: |
| 171 | + logger.log(LogReference.PROCREATE007c, related_identifier=identifier) |
| 172 | + _raise_operation_outcome_error( |
| 173 | + "The relatesTo target document does not exist", idx |
| 174 | + ) |
| 175 | + return existing_pointer |
| 176 | + |
| 177 | + |
| 178 | +def _validate_pointer_details(existing_pointer, core_model, identifier, idx): |
| 179 | + """ |
| 180 | + Validate that the nhs numbers and type matches between the existing pointer and the requested one. |
| 181 | + """ |
| 182 | + if existing_pointer.nhs_number != core_model.nhs_number: |
| 183 | + logger.log(LogReference.PROCREATE007d, related_identifier=identifier) |
| 184 | + _raise_operation_outcome_error( |
| 185 | + "The relatesTo target document NHS number does not match the NHS number in the request", |
| 186 | + idx, |
| 187 | + ) |
| 188 | + |
| 189 | + if existing_pointer.type != core_model.type: |
| 190 | + logger.log(LogReference.PROCREATE007e, related_identifier=identifier) |
| 191 | + _raise_operation_outcome_error( |
| 192 | + "The relatesTo target document type does not match the type in the request", |
| 193 | + idx, |
| 194 | + ) |
| 195 | + |
| 196 | + |
| 197 | +def _append_id_if_replaces(relates_to, ids_to_delete, identifier): |
| 198 | + """ |
| 199 | + Append pointer ID if the if the relatesTo code is 'replaces' |
| 200 | + """ |
| 201 | + if relates_to.code == "replaces": |
| 202 | + logger.log( |
| 203 | + LogReference.PROCREATE008, |
| 204 | + relates_to_code=relates_to.code, |
| 205 | + identifier=identifier, |
| 206 | + ) |
| 207 | + ids_to_delete.append(identifier) |
| 208 | + |
| 209 | + |
| 210 | +def _raise_operation_outcome_error(diagnostics, idx): |
| 211 | + """ |
| 212 | + General function to raise an operation outcome error |
| 213 | + """ |
| 214 | + raise OperationOutcomeError( |
| 215 | + severity="error", |
| 216 | + code="invalid", |
| 217 | + details=SpineErrorConcept.from_code("BAD_REQUEST"), |
| 218 | + diagnostics=diagnostics, |
| 219 | + expression=[f"relatesTo[{idx}].target.identifier.value"], |
| 220 | + ) |
| 221 | + |
| 222 | + |
| 223 | +def create_document_reference( |
| 224 | + metadata: ConnectionMetadata, |
| 225 | + repository: DocumentPointerRepository, |
| 226 | + document_reference: DocumentReference, |
| 227 | +) -> Response: |
| 228 | + |
| 229 | + logger.log(LogReference.PROCREATE000) |
| 230 | + logger.log(LogReference.PROCREATE001, resource=body) |
| 231 | + |
| 232 | + id_prefix = "|".join(metadata.ods_code_parts) |
| 233 | + body.id = f"{id_prefix}-{uuid4()}" |
| 234 | + |
| 235 | + validator = DocumentReferenceValidator() |
| 236 | + result = validator.validate(body) |
| 237 | + |
| 238 | + if not result.is_valid: |
| 239 | + logger.log(LogReference.PROCREATE002) |
| 240 | + return Response.from_issues(issues=result.issues, statusCode="400") |
| 241 | + |
| 242 | + core_model = _create_core_model(result.resource, metadata) |
| 243 | + if error_response := _check_permissions(core_model, metadata): |
| 244 | + return error_response |
| 245 | + |
| 246 | + can_ignore_delete_fail = ( |
| 247 | + PERMISSION_SUPERSEDE_IGNORE_DELETE_FAIL in metadata.nrl_permissions |
| 248 | + ) |
| 249 | + |
| 250 | + if ids_to_delete := _get_document_ids_to_supersede( |
| 251 | + result.resource, core_model, metadata, repository, can_ignore_delete_fail |
| 252 | + ): |
| 253 | + logger.log( |
| 254 | + LogReference.PROCREATE010, |
| 255 | + pointer_id=result.resource.id, |
| 256 | + ids_to_delete=ids_to_delete, |
| 257 | + ) |
| 258 | + repository.supersede(core_model, ids_to_delete, can_ignore_delete_fail) |
| 259 | + logger.log(LogReference.PROCREATE999) |
| 260 | + return NRLResponse.RESOURCE_SUPERSEDED(resource_id=result.resource.id) |
| 261 | + |
| 262 | + logger.log(LogReference.PROCREATE009, pointer_id=result.resource.id) |
| 263 | + repository.create(core_model) |
| 264 | + logger.log(LogReference.PROCREATE999) |
| 265 | + return NRLResponse.RESOURCE_CREATED(resource_id=result.resource.id) |
| 266 | + |
| 267 | + |
| 268 | +@request_handler(body=Bundle) |
| 269 | +def handler( |
| 270 | + metadata: ConnectionMetadata, |
| 271 | + repository: DocumentPointerRepository, |
| 272 | + body: Bundle, |
| 273 | +) -> Response: |
| 274 | + """ |
| 275 | + Handles an MHDS transaction bundle request. |
| 276 | +
|
| 277 | + Currently limited to register requests only. |
| 278 | +
|
| 279 | + Args: |
| 280 | + metadata (ConnectionMetadata): The connection metadata. |
| 281 | + repository (DocumentPointerRepository): The document pointer repository. |
| 282 | + body (Bundle): The bundle containing the resources to process. |
| 283 | +
|
| 284 | + Returns: |
| 285 | + Response: The response indicating the result of the operation. |
| 286 | + """ |
| 287 | + if not body.meta.profile[0].endswith( |
| 288 | + "profiles.ihe.net/ITI/MHD/StructureDefinition/IHE.MHD.UnContained.Comprehensive.ProvideBundle" |
| 289 | + ): |
| 290 | + return SpineErrorResponse.BAD_REQUEST( |
| 291 | + diagnostics="Only IHE.MHD.UnContained.Comprehensive.ProvideBundle profiles are supported", |
| 292 | + expression="meta.profile", |
| 293 | + ) |
| 294 | + |
| 295 | + if body.type != "transaction": |
| 296 | + return SpineErrorResponse.BAD_REQUEST( |
| 297 | + diagnostics="Only transaction bundles are supported", |
| 298 | + expression="type", |
| 299 | + ) |
| 300 | + |
| 301 | + if body.entry is None: |
| 302 | + return SpineErrorResponse.BAD_REQUEST( |
| 303 | + diagnostics="The bundle must contain at least one entry", expression="entry" |
| 304 | + ) |
| 305 | + |
| 306 | + document_references: list[DocumentReference] = [] |
| 307 | + |
| 308 | + # TODO - Handle this better |
| 309 | + issues: list[BaseModel] = [] |
| 310 | + |
| 311 | + for entry in body.entry: |
| 312 | + if not entry.resource or entry.resource.resourceType != "DocumentReference": |
| 313 | + issues.append( |
| 314 | + OperationOutcomeIssue( |
| 315 | + severity="error", |
| 316 | + code="exception", |
| 317 | + diagnostics="Only DocumentReference resources are supported", |
| 318 | + expression=[ExpressionItem("entry.resource.resourceType")], |
| 319 | + details=SpineErrorConcept.from_code("BAD_REQUEST"), |
| 320 | + ) |
| 321 | + ) |
| 322 | + |
| 323 | + document_references.append(DocumentReference.model_validate(entry.resource)) |
| 324 | + |
| 325 | + if issues: |
| 326 | + return Response.from_issues(issues, statusCode="400") |
| 327 | + |
| 328 | + responses: list[Response] = [] |
| 329 | + for document_reference in document_references: |
| 330 | + try: |
| 331 | + create_response = create_document_reference( |
| 332 | + metadata, repository, document_reference |
| 333 | + ) |
| 334 | + responses.append(create_response) |
| 335 | + except OperationOutcomeError as e: |
| 336 | + responses.append(e.response) |
| 337 | + |
| 338 | + return NRLResponse.BUNDLE_CREATED(responses) |
0 commit comments