Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions .github/workflows/base-lambdas-reusable-deploy-all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,20 @@ jobs:
secrets:
AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }}

deploy_get_document_review_lambda:
name: Deploy get document review lambda
uses: ./.github/workflows/base-lambdas-reusable-deploy.yml
with:
environment: ${{ inputs.environment}}
python_version: ${{ inputs.python_version }}
build_branch: ${{ inputs.build_branch}}
sandbox: ${{ inputs.sandbox }}
lambda_handler_name: get_document_review_handler
lambda_aws_name: GetDocumentReview
lambda_layer_names: "core_lambda_layer"
secrets:
AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }}

deploy_edge_presign_lambda:
name: Deploy edge presign cloudfront lambda
uses: ./.github/workflows/base-lambdas-edge-deploy.yml
Expand Down
1 change: 1 addition & 0 deletions lambdas/enums/logging_app_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ class LoggingAppInteraction(Enum):
VIRUS_SCAN = "Virus Scan"
UPLOAD_CONFIRMATION = "Upload confirmation"
UPDATE_UPLOAD_STATE = "Update upload state"
GET_REVIEW_DOCUMENTS = "Get review documents"
2 changes: 1 addition & 1 deletion lambdas/handlers/authoriser_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def lambda_handler(event, context):
auth_token = headers.get("authorization") or headers.get("Authorization")
if event.get("methodArn") is None:
return {"Error": "methodArn is not defined"}
_, _, _, region, aws_account_id, api_gateway_arn = event.get("methodArn").split(":")
_, _, _, region, aws_account_id, api_gateway_arn = event.get("methodArn").split(":", 5)
api_id, stage, _http_verb, _resource_name = api_gateway_arn.split("/", 3)
path = "/" + _resource_name

Expand Down
95 changes: 95 additions & 0 deletions lambdas/handlers/get_document_review_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import json

from enums.feature_flags import FeatureFlags
from enums.lambda_error import LambdaError
from enums.logging_app_interaction import LoggingAppInteraction
from services.feature_flags_service import FeatureFlagService
from services.get_document_review_service import GetDocumentReviewService
from utils.audit_logging_setup import LoggingService
from utils.decorators.ensure_env_var import ensure_environment_variables
from utils.decorators.handle_lambda_exceptions import handle_lambda_exceptions
from utils.decorators.override_error_check import override_error_check
from utils.decorators.set_audit_arg import set_request_context_for_logging
from utils.decorators.validate_patient_id import validate_patient_id
from utils.lambda_exceptions import GetDocumentReviewException
from utils.lambda_response import ApiGatewayResponse
from utils.request_context import request_context

logger = LoggingService(__name__)


@set_request_context_for_logging
@validate_patient_id
@ensure_environment_variables(
names=[
"DOCUMENT_REVIEW_DYNAMODB_NAME",
"PRESIGNED_ASSUME_ROLE",
"EDGE_REFERENCE_TABLE",
"CLOUDFRONT_URL",
]
)
@override_error_check
@handle_lambda_exceptions
def lambda_handler(event, context):
request_context.app_interaction = LoggingAppInteraction.GET_REVIEW_DOCUMENTS.value

logger.info("Get Document Review handler has been triggered")
feature_flag_service = FeatureFlagService()
upload_lambda_enabled_flag_object = feature_flag_service.get_feature_flags_by_flag(
FeatureFlags.UPLOAD_DOCUMENT_ITERATION_3_ENABLED
)

if not upload_lambda_enabled_flag_object[FeatureFlags.UPLOAD_DOCUMENT_ITERATION_3_ENABLED]:
logger.info("Feature flag not enabled, event will not be processed")
raise GetDocumentReviewException(404, LambdaError.FeatureFlagDisabled)

# Extract patient_id from query string parameters
query_params = event.get("queryStringParameters", {})
patient_id = query_params.get("patientId", "")

if not patient_id:
logger.error("Missing patient_id in query string parameters")
raise GetDocumentReviewException(
400, LambdaError.DocumentReferenceMissingParameters
)

# Extract id from path parameters
path_params = event.get("pathParameters", {})
document_id = path_params.get("id")

if not document_id:
logger.error("Missing id in path parameters")
raise GetDocumentReviewException(
400, LambdaError.DocumentReferenceMissingParameters
)

request_context.patient_nhs_no = patient_id

logger.info(
f"Retrieving document review for patient_id: {patient_id}, document_id: {document_id}"
)

# Get document review service
document_review_service = GetDocumentReviewService()
document_review = document_review_service.get_document_review(
patient_id=patient_id, document_id=document_id
)

if document_review:
logger.info(
"Document review retrieved successfully",
{"Result": "Successful document review retrieval"},
)
return ApiGatewayResponse(
200, json.dumps(document_review), "GET"
).create_api_gateway_response()
else:
logger.error(
"Document review not found",
{"Result": "No document review available"},
)
return ApiGatewayResponse(
404,
LambdaError.DocumentReferenceNotFound.create_error_body(),
"GET",
).create_api_gateway_response()
9 changes: 5 additions & 4 deletions lambdas/models/document_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class DocumentReviewFileDetails(BaseModel):

file_name: str
file_location: str
presigned_url: str | None = None


class DocumentUploadReviewReference(BaseModel):
Expand All @@ -36,12 +37,12 @@ class DocumentUploadReviewReference(BaseModel):
default=DocumentReviewStatus.PENDING_REVIEW
)
review_reason: str
review_date: int = Field(default=None)
reviewer: str = Field(default=None)
review_date: int | None = Field(default=None)
reviewer: str | None = Field(default=None)
upload_date: int
files: list[DocumentReviewFileDetails]
files: list[DocumentReviewFileDetails] = Field(min_length=1)
nhs_number: str
ttl: Optional[int] = Field(
ttl: int | None = Field(
alias=str(DocumentReferenceMetadataFields.TTL.value), default=None
)
document_reference_id: str = Field(default=None)
Expand Down
4 changes: 4 additions & 0 deletions lambdas/services/authoriser_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ def deny_access_policy(self, path, user_role, nhs_number: str = None):
deny_resource = (
not patient_access_is_allowed or is_user_gp_clinical or is_user_pcse
)
case path if path.startswith("/DocumentReview/"):
deny_resource = (
not patient_access_is_allowed
)

case "/UploadState":
deny_resource = (
Expand Down
38 changes: 37 additions & 1 deletion lambdas/services/document_service.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
from datetime import datetime, timezone
from typing import Optional

from boto3.dynamodb.conditions import Attr, ConditionBase
from enums.metadata_field_names import DocumentReferenceMetadataFields
Expand Down Expand Up @@ -104,12 +105,47 @@ def fetch_documents_from_table(
continue
return documents

def get_item(
self,
document_id: str,
table_name: str = None,
model_class: type[BaseModel] = None,
) -> Optional[BaseModel]:
"""Fetch a single document by ID from specified or configured table.

Args:
document_id: The document ID to retrieve.
table_name: Optional table name, defaults to self.table_name.
model_class: Optional model class, defaults to self.model_class.

Returns:
Document object if found, None otherwise.
"""
table_to_use = table_name or self.table_name
model_to_use = model_class or self.model_class

try:
response = self.dynamo_service.get_item(
table_name=table_to_use, key={"ID": document_id}
)

if "Item" not in response:
logger.info(f"No document found for document_id: {document_id}")
return None

document = model_to_use.model_validate(response["Item"])
return document

except ValidationError as e:
logger.error(f"Validation error on document: {response.get('Item')}")
logger.error(f"{e}")
return None

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(
index_name="OdsCodeIndex",
search_key=DocumentReferenceMetadataFields.CURRENT_GP_ODS.value,
Expand Down
122 changes: 122 additions & 0 deletions lambdas/services/get_document_review_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import os
import uuid
from datetime import datetime, timezone
from typing import Optional

from enums.lambda_error import LambdaError
from services.base.s3_service import S3Service
from services.document_upload_review_service import DocumentUploadReviewService
from utils.audit_logging_setup import LoggingService
from utils.exceptions import DynamoServiceException
from utils.lambda_exceptions import GetDocumentReviewException
from utils.utilities import format_cloudfront_url

logger = LoggingService(__name__)


class GetDocumentReviewService:
"""
Service for retrieving document reviews.
"""

def __init__(self):
presigned_assume_role = os.getenv("PRESIGNED_ASSUME_ROLE")
self.s3_service = S3Service(custom_aws_role=presigned_assume_role)
self.document_review_service = DocumentUploadReviewService()
self.cloudfront_table_name = os.environ.get("EDGE_REFERENCE_TABLE")
self.cloudfront_url = os.environ.get("CLOUDFRONT_URL")

def get_document_review(self, patient_id: str, document_id: str) -> Optional[dict]:
"""Retrieve a document review for a given patient and document.

Args:
patient_id: The patient ID (NHS number).
document_id: The document ID to retrieve.

Returns:
Dictionary containing the document review details, or None if not found.
"""
try:
logger.info(
f"Fetching document review for patient_id: {patient_id}, document_id: {document_id}"
)

document_review_item = self.document_review_service.get_item(document_id)

if not document_review_item:
logger.info(f"No document review found for document_id: {document_id}")
return None

if document_review_item.nhs_number != patient_id:
logger.warning(
f"Document {document_id} does not belong to patient {patient_id}"
)
return None

if document_review_item.files:
for file_detail in document_review_item.files:
presigned_url = self.create_cloudfront_presigned_url(
file_detail.file_location
)
file_detail.presigned_url = presigned_url

document_review = document_review_item.model_dump(
by_alias=True,
include={
"id": True,
"upload_date": True,
"files": {"__all__": {"file_name": True, "presigned_url": True}},
"document_snomed_code_type": True,
},
)

logger.info(
f"Successfully retrieved document review for document_id: {document_id}"
)

return document_review

except DynamoServiceException as e:
logger.error(
f"{LambdaError.DocRefClient.to_str()}: {str(e)}",
{"Result": "Failed to retrieve document review"},
)
raise GetDocumentReviewException(500, LambdaError.DocRefClient)
except Exception as e:
logger.error(
f"Unexpected error retrieving document review: {str(e)}",
{"Result": "Failed to retrieve document review"},
)
raise GetDocumentReviewException(500, LambdaError.DocRefClient)

def create_cloudfront_presigned_url(self, file_location: str) -> str:
"""Create a CloudFront obfuscated pre-signed URL for a file.

Args:
file_location: The S3 file key/location.

Returns:
CloudFront URL that obfuscates the actual pre-signed URL.
"""
s3_bucket_name, file_key = file_location.removeprefix("s3://").split("/", 1)
presign_url_response = self.s3_service.create_download_presigned_url(
s3_bucket_name=s3_bucket_name,
file_key=file_key,
)

presigned_id = "review/" + str(uuid.uuid4())

deletion_date = datetime.now(timezone.utc)
ttl_half_an_hour_in_seconds = self.s3_service.presigned_url_expiry
dynamo_item_ttl = int(deletion_date.timestamp() + ttl_half_an_hour_in_seconds)

self.document_review_service.dynamo_service.create_item(
self.cloudfront_table_name,
{
"ID": f"{presigned_id}",
"presignedUrl": presign_url_response,
"TTL": dynamo_item_ttl,
},
)

return format_cloudfront_url(presigned_id, self.cloudfront_url)
3 changes: 2 additions & 1 deletion lambdas/tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
MOCK_ALERTING_SLACK_CHANNEL_ID = "slack_channel_id"
MOCK_DOCUMENT_REVIEW_TABLE = "test_document_review"
MOCK_DOCUMENT_REVIEW_BUCKET = "test_document_review_bucket"
MOCK_EDGE_TABLE = "test_edge_reference_table"

@pytest.fixture
def set_env(monkeypatch):
Expand Down Expand Up @@ -228,10 +229,10 @@ def set_env(monkeypatch):
monkeypatch.setenv("ITOC_TESTING_ODS_CODES", MOCK_ITOC_ODS_CODES)
monkeypatch.setenv("DOCUMENT_REVIEW_DYNAMODB_NAME", MOCK_DOCUMENT_REVIEW_TABLE)
monkeypatch.setenv("DOCUMENT_REVIEW_S3_BUCKET_NAME", MOCK_DOCUMENT_REVIEW_BUCKET)
monkeypatch.setenv("EDGE_REFERENCE_TABLE", MOCK_EDGE_TABLE)
monkeypatch.setenv("STAGING_STORE_BUCKET_NAME", MOCK_STAGING_STORE_BUCKET)
monkeypatch.setenv("METADATA_SQS_QUEUE_URL", MOCK_LG_METADATA_SQS_QUEUE)


EXPECTED_PARSED_PATIENT_BASE_CASE = PatientDetails(
givenName=["Jane"],
familyName="Smith",
Expand Down
11 changes: 11 additions & 0 deletions lambdas/tests/unit/handlers/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
from enums.feature_flags import FeatureFlags
from services.feature_flags_service import FeatureFlagService


Expand Down Expand Up @@ -138,3 +139,13 @@ def mock_validation_strict_disabled(mocker):
"lloydGeorgeValidationStrictModeEnabled": False
}
yield mock_upload_lambda_feature_flag


@pytest.fixture
def mock_upload_document_iteration_3_enabled(mocker):
mock_function = mocker.patch.object(FeatureFlagService, "get_feature_flags_by_flag")
mock_feature_flag = mock_function.return_value = {
FeatureFlags.UPLOAD_DOCUMENT_ITERATION_3_ENABLED: True
}
yield mock_feature_flag

Loading
Loading