Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
89fccdc
[PRMP-643] Add document review models and status enumeration
NogaNHS Oct 22, 2025
397eb0a
[PRMP-643] Refactor document review models and update document handli…
NogaNHS Oct 22, 2025
1b6ff81
[PRMP-643] update MNS service tests for document reviews
NogaNHS Oct 23, 2025
43975a1
Merge branch 'main' into PRMP-643
NogaNHS Oct 23, 2025
81727f4
set default model class to DocumentReference in fetch_documents method
NogaNHS Oct 24, 2025
e403c16
Merge branch 'main' into PRMP-643
NogaNHS Oct 28, 2025
65f132e
Update document review model and status enumeration
NogaNHS Oct 28, 2025
d713065
Refactor update_patient_ods_code to remove unnecessary table parameter
NogaNHS Oct 28, 2025
88b2f1e
Merge branch 'main' into PRMP-643
NogaNHS Oct 28, 2025
03309bb
Refactor type hints to use union operator for document return types
NogaNHS Oct 28, 2025
d0a9d61
Merge branch 'main' into PRMP-643
NogaNHS Oct 28, 2025
88a3a6c
[PRMP-643] PR comments
NogaNHS Oct 29, 2025
7af8e60
[PRMP-643] Implement DocumentReferenceService and DocumentUploadRevie…
NogaNHS Oct 29, 2025
6cc2a43
[PRMP-643] Fix parameter names in document service fetch calls for co…
NogaNHS Oct 30, 2025
ef5149a
[PRMP-643] Add unit tests for DocumentUploadReviewService and update …
NogaNHS Oct 30, 2025
8b97a84
[PRMP-643] Add unit tests for DocumentReferenceService and update Doc…
NogaNHS Oct 30, 2025
fb1a8be
[PRMP-643] format
NogaNHS Oct 30, 2025
4af40b3
[PRMP-643] Add mock environment variables for DocumentUploadReviewSer…
NogaNHS Oct 30, 2025
887b193
[PRMP-643]Update mock environment variable for DocumentReviewService …
NogaNHS Oct 31, 2025
6cc9622
Merge branch 'main' into PRMP-643
NogaNHS Nov 6, 2025
18c2c0a
Remove table_name from document fetch call
NogaNHS Nov 6, 2025
6493f55
Merge branch 'main' into PRMP-643
NogaNHS Nov 10, 2025
1c93545
Refactor document fetch call to use 'table_name' instead of 'table'
NogaNHS Nov 10, 2025
f7fd4ec
Refactor document fetch call to use 'table_name' instead of 'table' 2
NogaNHS Nov 10, 2025
5dfb610
Merge branch 'main' into PRMP-643
NogaNHS Nov 12, 2025
ebd93b2
Add feature flag for document review iteration 3 and update related s…
NogaNHS Nov 12, 2025
96dd4a6
Merge branch 'main' into PRMP-643
NogaNHS Nov 12, 2025
5cb90e5
Refactor document service to use 'table_name' type hint and improve v…
NogaNHS Nov 13, 2025
db76083
Merge branch 'main' into PRMP-643
NogaNHS Nov 18, 2025
0db6327
PRMP-643 Refactor document services to use cached environment variabl…
NogaNHS Nov 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lambdas/enums/document_review_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from enum import StrEnum


class DocumentReviewStatus(StrEnum):
PENDING_REVIEW = "PENDING_REVIEW"
APPROVED = "APPROVED"
REJECTED = "REJECTED"
1 change: 1 addition & 0 deletions lambdas/enums/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ class FeatureFlags(StrEnum):
"lloydGeorgeValidationStrictModeEnabled"
)
UPLOAD_DOCUMENT_ITERATION_2_ENABLED = "uploadDocumentIteration2Enabled"
UPLOAD_DOCUMENT_ITERATION_3_ENABLED = "uploadDocumentIteration3Enabled"
1 change: 1 addition & 0 deletions lambdas/handlers/mns_notification_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"APPCONFIG_CONFIGURATION",
"APPCONFIG_ENVIRONMENT",
"LLOYD_GEORGE_DYNAMODB_NAME",
"DOCUMENT_REVIEW_DYNAMODB_NAME",
"MNS_NOTIFICATION_QUEUE_URL",
]
)
Expand Down
48 changes: 48 additions & 0 deletions lambdas/models/document_review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import uuid
from typing import Optional

from enums.document_review_status import DocumentReviewStatus
from enums.metadata_field_names import DocumentReferenceMetadataFields
from enums.snomed_codes import SnomedCodes
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_pascal


class DocumentReviewFileDetails(BaseModel):
model_config = ConfigDict(
validate_by_alias=True,
validate_by_name=True,
alias_generator=to_pascal,
)

file_name: str
file_location: str


class DocumentUploadReviewReference(BaseModel):
model_config = ConfigDict(
validate_by_alias=True,
validate_by_name=True,
alias_generator=to_pascal,
use_enum_values=True,
)
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
alias=str(DocumentReferenceMetadataFields.ID.value),
)
author: str
custodian: str
review_status: DocumentReviewStatus = Field(
default=DocumentReviewStatus.PENDING_REVIEW
)
review_reason: str
review_date: int = Field(default=None)
reviewer: str = Field(default=None)
upload_date: int
files: list[DocumentReviewFileDetails]
nhs_number: str
ttl: Optional[int] = Field(
alias=str(DocumentReferenceMetadataFields.TTL.value), default=None
)
document_reference_id: str = Field(default=None)
document_snomed_code_type: str = Field(default=SnomedCodes.LLOYD_GEORGE.value.code)
74 changes: 74 additions & 0 deletions lambdas/services/document_reference_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from datetime import datetime

from enums.supported_document_types import SupportedDocumentTypes
from models.document_reference import DocumentReference
from services.document_service import DocumentService
from utils.audit_logging_setup import LoggingService
from utils.dynamo_utils import filter_uploaded_docs_and_recently_uploading_docs
from utils.exceptions import FileUploadInProgress, NoAvailableDocument

logger = LoggingService(__name__)


class DocumentReferenceService(DocumentService):
"""Service for handling DocumentReference operations."""

def __init__(self, doc_type: SupportedDocumentTypes = SupportedDocumentTypes.LG):
super().__init__()
self.doc_type = doc_type

@property
def table_name(self) -> str:
return self.doc_type.get_dynamodb_table_name()

@property
def model_class(self) -> type:
return DocumentReference

@property
def s3_bucket(self) -> str:
return self.doc_type.get_s3_bucket_name()

def get_available_lloyd_george_record_for_patient(
self, nhs_number: str
) -> list[DocumentReference]:
"""Get available Lloyd George records for a patient, checking for upload status."""
filter_expression = filter_uploaded_docs_and_recently_uploading_docs()
available_docs = self.fetch_documents_from_table_with_nhs_number(
nhs_number,
query_filter=filter_expression,
)

file_in_progress_message = (
"The patients Lloyd George record is in the process of being uploaded"
)
if not available_docs:
raise NoAvailableDocument()
for document in available_docs:
if document.uploading and not document.uploaded:
raise FileUploadInProgress(file_in_progress_message)
return available_docs

def update_patient_ods_code(
self,
patient_documents: list[DocumentReference],
updated_ods_code: str,
) -> None:
update_field = {"current_gp_ods", "custodian", "last_updated"}
if not patient_documents:
return

for reference in patient_documents:
logger.info("Updating patient document reference...")

if (
reference.current_gp_ods != updated_ods_code
or reference.custodian != updated_ods_code
):
reference.current_gp_ods = updated_ods_code
reference.custodian = updated_ods_code
reference.last_updated = int(datetime.now().timestamp())

self.update_document(
document=reference, update_fields_name=update_field
)
117 changes: 70 additions & 47 deletions lambdas/services/document_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,39 @@
from enums.metadata_field_names import DocumentReferenceMetadataFields
from enums.supported_document_types import SupportedDocumentTypes
from models.document_reference import DocumentReference
from pydantic import ValidationError
from pydantic import BaseModel, ValidationError
from services.base.dynamo_service import DynamoDBService
from services.base.s3_service import S3Service
from utils.audit_logging_setup import LoggingService
from utils.common_query_filters import NotDeleted
from utils.dynamo_utils import filter_uploaded_docs_and_recently_uploading_docs
from utils.exceptions import (
DocumentServiceException,
FileUploadInProgress,
NoAvailableDocument,
)
from utils.exceptions import DocumentServiceException

logger = LoggingService(__name__)


class DocumentService:
"""Service for document operations."""

def __init__(self):
self.s3_service = S3Service()
self.dynamo_service = DynamoDBService()
self._lg_table_name = os.getenv("LLOYD_GEORGE_DYNAMODB_NAME")
self._lg_s3_bucket = os.getenv("LLOYD_GEORGE_BUCKET_NAME")

@property
def table_name(self) -> str:
"""DynamoDB table name. Can be overridden by child classes."""
return self._lg_table_name

@property
def s3_bucket(self) -> str:
"""S3 bucket name. Can be overridden by child classes."""
return self._lg_s3_bucket

@property
def model_class(self) -> type[BaseModel]:
"""Pydantic model class. Can be overridden by child classes."""
return DocumentReference

def fetch_available_document_references_by_type(
self,
Expand All @@ -34,56 +48,74 @@ def fetch_available_document_references_by_type(
table_name = doc_type.get_dynamodb_table_name()

return self.fetch_documents_from_table_with_nhs_number(
nhs_number, table_name, query_filter=query_filter
nhs_number,
table_name,
query_filter=query_filter,
)

def fetch_documents_from_table_with_nhs_number(
self, nhs_number: str, table: str, query_filter: Attr | ConditionBase = None
) -> list[DocumentReference]:
self,
nhs_number: str,
table: str | None = None,
query_filter: Attr | ConditionBase = None,
model_class: type[BaseModel] = None,
) -> list:
"""Fetch documents by NHS number from specified or configured table."""
table_name = table or self.table_name

documents = self.fetch_documents_from_table(
table=table,
index_name="NhsNumberIndex",
search_key="NhsNumber",
search_condition=nhs_number,
query_filter=query_filter,
table_name=table_name,
model_class=model_class,
)

return documents

def fetch_documents_from_table(
self,
table: str,
search_condition: str,
search_key: str,
index_name: str | None = None,
query_filter: Attr | ConditionBase = None,
) -> list[DocumentReference]:
table_name: str | None = None,
model_class: type[BaseModel] = None,
) -> list:
"""Fetch documents from specified or configured table using model_class."""
documents = []
table_name = table_name or self.table_name
model_class = model_class or self.model_class

response = self.dynamo_service.query_table(
table_name=table,
table_name=table_name,
index_name=index_name,
search_key=search_key,
search_condition=search_condition,
query_filter=query_filter,
)
for item in response:
try:
document = DocumentReference.model_validate(item)
document = model_class.model_validate(item)
documents.append(document)
except ValidationError as e:
logger.error(f"Validation error on document: {item}")
logger.error(f"{e}")
continue
return documents

def get_nhs_numbers_based_on_ods_code(self, ods_code: str) -> list[str]:
def get_nhs_numbers_based_on_ods_code(
self, ods_code: str, table_name: str | None = None
) -> list[str]:
"""Get unique NHS numbers for patients with given ODS code."""
table_name = table_name or self.table_name

documents = self.fetch_documents_from_table(
table=os.environ["LLOYD_GEORGE_DYNAMODB_NAME"],
index_name="OdsCodeIndex",
search_key=DocumentReferenceMetadataFields.CURRENT_GP_ODS.value,
search_condition=ods_code,
query_filter=NotDeleted,
table_name=table_name,
)
nhs_numbers = list({document.nhs_number for document in documents})
return nhs_numbers
Expand Down Expand Up @@ -139,21 +171,27 @@ def delete_document_object(self, bucket: str, key: str):

def update_document(
self,
table_name: str,
document_reference: DocumentReference,
update_fields_name: set[str] = None,
table_name: str | None = None,
document: BaseModel = None,
update_fields_name: set[str] | None = None,
):
"""Update document in specified or configured table."""
table_name = table_name or self.table_name

self.dynamo_service.update_item(
table_name=table_name,
key_pair={DocumentReferenceMetadataFields.ID.value: document_reference.id},
updated_fields=document_reference.model_dump(
key_pair={DocumentReferenceMetadataFields.ID.value: document.id},
updated_fields=document.model_dump(
exclude_none=True, by_alias=True, include=update_fields_name
),
)

def hard_delete_metadata_records(
self, table_name: str, document_references: list[DocumentReference]
self, table_name: str, document_references: list[BaseModel]
):
"""Permanently delete metadata from specified or configured table."""
table_name = table_name or self.table_name

logger.info(f"Deleting items in table: {table_name} (HARD DELETE)")
primary_key_name = DocumentReferenceMetadataFields.ID.value
for reference in document_references:
Expand All @@ -162,41 +200,26 @@ def hard_delete_metadata_records(
self.dynamo_service.delete_item(table_name, deletion_key)

@staticmethod
def is_upload_in_process(record: DocumentReference):
def is_upload_in_process(record: DocumentReference) -> bool:
"""Check if a document upload is currently in progress."""
return (
not record.uploaded
and record.uploading
and record.last_updated_within_three_minutes()
and record.doc_status != "final"
)

def get_available_lloyd_george_record_for_patient(
self, nhs_number
) -> list[DocumentReference]:
filter_expression = filter_uploaded_docs_and_recently_uploading_docs()
available_docs = self.fetch_available_document_references_by_type(
nhs_number,
SupportedDocumentTypes.LG,
query_filter=filter_expression,
)

file_in_progress_message = (
"The patients Lloyd George record is in the process of being uploaded"
)
if not available_docs:
raise NoAvailableDocument()
for document in available_docs:
if document.uploading and not document.uploaded:
raise FileUploadInProgress(file_in_progress_message)
return available_docs

def get_batch_document_references_by_id(
self, document_ids: list[str], doc_type: SupportedDocumentTypes
) -> list[DocumentReference]:
) -> list:
table_name = doc_type.get_dynamodb_table_name()

table_name = table_name or self.table_name
model_class = self.model_class

response = self.dynamo_service.batch_get_items(
table_name=table_name, key_list=document_ids
)

found_docs = [DocumentReference.model_validate(item) for item in response]
found_docs = [model_class.model_validate(item) for item in response]
return found_docs
Loading
Loading