Skip to content

Commit dbfd381

Browse files
authored
PRMP-587 Get document review by id (#862)
1 parent c8e81eb commit dbfd381

15 files changed

+867
-8
lines changed

.github/workflows/base-lambdas-reusable-deploy-all.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,20 @@ jobs:
488488
secrets:
489489
AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }}
490490

491+
deploy_get_document_review_lambda:
492+
name: Deploy get document review lambda
493+
uses: ./.github/workflows/base-lambdas-reusable-deploy.yml
494+
with:
495+
environment: ${{ inputs.environment}}
496+
python_version: ${{ inputs.python_version }}
497+
build_branch: ${{ inputs.build_branch}}
498+
sandbox: ${{ inputs.sandbox }}
499+
lambda_handler_name: get_document_review_handler
500+
lambda_aws_name: GetDocumentReview
501+
lambda_layer_names: "core_lambda_layer"
502+
secrets:
503+
AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }}
504+
491505
deploy_edge_presign_lambda:
492506
name: Deploy edge presign cloudfront lambda
493507
uses: ./.github/workflows/base-lambdas-edge-deploy.yml

lambdas/enums/logging_app_interaction.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ class LoggingAppInteraction(Enum):
1919
VIRUS_SCAN = "Virus Scan"
2020
UPLOAD_CONFIRMATION = "Upload confirmation"
2121
UPDATE_UPLOAD_STATE = "Update upload state"
22+
GET_REVIEW_DOCUMENTS = "Get review documents"

lambdas/handlers/authoriser_handler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def lambda_handler(event, context):
4141
auth_token = headers.get("authorization") or headers.get("Authorization")
4242
if event.get("methodArn") is None:
4343
return {"Error": "methodArn is not defined"}
44-
_, _, _, region, aws_account_id, api_gateway_arn = event.get("methodArn").split(":")
44+
_, _, _, region, aws_account_id, api_gateway_arn = event.get("methodArn").split(":", 5)
4545
api_id, stage, _http_verb, _resource_name = api_gateway_arn.split("/", 3)
4646
path = "/" + _resource_name
4747

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import json
2+
3+
from enums.feature_flags import FeatureFlags
4+
from enums.lambda_error import LambdaError
5+
from enums.logging_app_interaction import LoggingAppInteraction
6+
from services.feature_flags_service import FeatureFlagService
7+
from services.get_document_review_service import GetDocumentReviewService
8+
from utils.audit_logging_setup import LoggingService
9+
from utils.decorators.ensure_env_var import ensure_environment_variables
10+
from utils.decorators.handle_lambda_exceptions import handle_lambda_exceptions
11+
from utils.decorators.override_error_check import override_error_check
12+
from utils.decorators.set_audit_arg import set_request_context_for_logging
13+
from utils.decorators.validate_patient_id import validate_patient_id
14+
from utils.lambda_exceptions import GetDocumentReviewException
15+
from utils.lambda_response import ApiGatewayResponse
16+
from utils.request_context import request_context
17+
18+
logger = LoggingService(__name__)
19+
20+
21+
@set_request_context_for_logging
22+
@validate_patient_id
23+
@ensure_environment_variables(
24+
names=[
25+
"DOCUMENT_REVIEW_DYNAMODB_NAME",
26+
"PRESIGNED_ASSUME_ROLE",
27+
"EDGE_REFERENCE_TABLE",
28+
"CLOUDFRONT_URL",
29+
]
30+
)
31+
@override_error_check
32+
@handle_lambda_exceptions
33+
def lambda_handler(event, context):
34+
request_context.app_interaction = LoggingAppInteraction.GET_REVIEW_DOCUMENTS.value
35+
36+
logger.info("Get Document Review handler has been triggered")
37+
feature_flag_service = FeatureFlagService()
38+
upload_lambda_enabled_flag_object = feature_flag_service.get_feature_flags_by_flag(
39+
FeatureFlags.UPLOAD_DOCUMENT_ITERATION_3_ENABLED
40+
)
41+
42+
if not upload_lambda_enabled_flag_object[FeatureFlags.UPLOAD_DOCUMENT_ITERATION_3_ENABLED]:
43+
logger.info("Feature flag not enabled, event will not be processed")
44+
raise GetDocumentReviewException(404, LambdaError.FeatureFlagDisabled)
45+
46+
# Extract patient_id from query string parameters
47+
query_params = event.get("queryStringParameters", {})
48+
patient_id = query_params.get("patientId", "")
49+
50+
if not patient_id:
51+
logger.error("Missing patient_id in query string parameters")
52+
raise GetDocumentReviewException(
53+
400, LambdaError.DocumentReferenceMissingParameters
54+
)
55+
56+
# Extract id from path parameters
57+
path_params = event.get("pathParameters", {})
58+
document_id = path_params.get("id")
59+
60+
if not document_id:
61+
logger.error("Missing id in path parameters")
62+
raise GetDocumentReviewException(
63+
400, LambdaError.DocumentReferenceMissingParameters
64+
)
65+
66+
request_context.patient_nhs_no = patient_id
67+
68+
logger.info(
69+
f"Retrieving document review for patient_id: {patient_id}, document_id: {document_id}"
70+
)
71+
72+
# Get document review service
73+
document_review_service = GetDocumentReviewService()
74+
document_review = document_review_service.get_document_review(
75+
patient_id=patient_id, document_id=document_id
76+
)
77+
78+
if document_review:
79+
logger.info(
80+
"Document review retrieved successfully",
81+
{"Result": "Successful document review retrieval"},
82+
)
83+
return ApiGatewayResponse(
84+
200, json.dumps(document_review), "GET"
85+
).create_api_gateway_response()
86+
else:
87+
logger.error(
88+
"Document review not found",
89+
{"Result": "No document review available"},
90+
)
91+
return ApiGatewayResponse(
92+
404,
93+
LambdaError.DocumentReferenceNotFound.create_error_body(),
94+
"GET",
95+
).create_api_gateway_response()

lambdas/models/document_review.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class DocumentReviewFileDetails(BaseModel):
1717

1818
file_name: str
1919
file_location: str
20+
presigned_url: str | None = None
2021

2122

2223
class DocumentUploadReviewReference(BaseModel):
@@ -36,12 +37,12 @@ class DocumentUploadReviewReference(BaseModel):
3637
default=DocumentReviewStatus.PENDING_REVIEW
3738
)
3839
review_reason: str
39-
review_date: int = Field(default=None)
40-
reviewer: str = Field(default=None)
40+
review_date: int | None = Field(default=None)
41+
reviewer: str | None = Field(default=None)
4142
upload_date: int
42-
files: list[DocumentReviewFileDetails]
43+
files: list[DocumentReviewFileDetails] = Field(min_length=1)
4344
nhs_number: str
44-
ttl: Optional[int] = Field(
45+
ttl: int | None = Field(
4546
alias=str(DocumentReferenceMetadataFields.TTL.value), default=None
4647
)
4748
document_reference_id: str = Field(default=None)

lambdas/services/authoriser_service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ def deny_access_policy(self, path, user_role, nhs_number: str = None):
121121
deny_resource = (
122122
not patient_access_is_allowed or is_user_gp_clinical or is_user_pcse
123123
)
124+
case path if path.startswith("/DocumentReview/"):
125+
deny_resource = (
126+
not patient_access_is_allowed
127+
)
124128

125129
case "/UploadState":
126130
deny_resource = (

lambdas/services/document_service.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
from datetime import datetime, timezone
3+
from typing import Optional
34

45
from boto3.dynamodb.conditions import Attr, ConditionBase
56
from enums.metadata_field_names import DocumentReferenceMetadataFields
@@ -104,12 +105,47 @@ def fetch_documents_from_table(
104105
continue
105106
return documents
106107

108+
def get_item(
109+
self,
110+
document_id: str,
111+
table_name: str = None,
112+
model_class: type[BaseModel] = None,
113+
) -> Optional[BaseModel]:
114+
"""Fetch a single document by ID from specified or configured table.
115+
116+
Args:
117+
document_id: The document ID to retrieve.
118+
table_name: Optional table name, defaults to self.table_name.
119+
model_class: Optional model class, defaults to self.model_class.
120+
121+
Returns:
122+
Document object if found, None otherwise.
123+
"""
124+
table_to_use = table_name or self.table_name
125+
model_to_use = model_class or self.model_class
126+
127+
try:
128+
response = self.dynamo_service.get_item(
129+
table_name=table_to_use, key={"ID": document_id}
130+
)
131+
132+
if "Item" not in response:
133+
logger.info(f"No document found for document_id: {document_id}")
134+
return None
135+
136+
document = model_to_use.model_validate(response["Item"])
137+
return document
138+
139+
except ValidationError as e:
140+
logger.error(f"Validation error on document: {response.get('Item')}")
141+
logger.error(f"{e}")
142+
return None
143+
107144
def get_nhs_numbers_based_on_ods_code(
108145
self, ods_code: str, table_name: str | None = None
109146
) -> list[str]:
110147
"""Get unique NHS numbers for patients with given ODS code."""
111148
table_name = table_name or self.table_name
112-
113149
documents = self.fetch_documents_from_table(
114150
index_name="OdsCodeIndex",
115151
search_key=DocumentReferenceMetadataFields.CURRENT_GP_ODS.value,
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import os
2+
import uuid
3+
from datetime import datetime, timezone
4+
from typing import Optional
5+
6+
from enums.lambda_error import LambdaError
7+
from services.base.s3_service import S3Service
8+
from services.document_upload_review_service import DocumentUploadReviewService
9+
from utils.audit_logging_setup import LoggingService
10+
from utils.exceptions import DynamoServiceException
11+
from utils.lambda_exceptions import GetDocumentReviewException
12+
from utils.utilities import format_cloudfront_url
13+
14+
logger = LoggingService(__name__)
15+
16+
17+
class GetDocumentReviewService:
18+
"""
19+
Service for retrieving document reviews.
20+
"""
21+
22+
def __init__(self):
23+
presigned_assume_role = os.getenv("PRESIGNED_ASSUME_ROLE")
24+
self.s3_service = S3Service(custom_aws_role=presigned_assume_role)
25+
self.document_review_service = DocumentUploadReviewService()
26+
self.cloudfront_table_name = os.environ.get("EDGE_REFERENCE_TABLE")
27+
self.cloudfront_url = os.environ.get("CLOUDFRONT_URL")
28+
29+
def get_document_review(self, patient_id: str, document_id: str) -> Optional[dict]:
30+
"""Retrieve a document review for a given patient and document.
31+
32+
Args:
33+
patient_id: The patient ID (NHS number).
34+
document_id: The document ID to retrieve.
35+
36+
Returns:
37+
Dictionary containing the document review details, or None if not found.
38+
"""
39+
try:
40+
logger.info(
41+
f"Fetching document review for patient_id: {patient_id}, document_id: {document_id}"
42+
)
43+
44+
document_review_item = self.document_review_service.get_item(document_id)
45+
46+
if not document_review_item:
47+
logger.info(f"No document review found for document_id: {document_id}")
48+
return None
49+
50+
if document_review_item.nhs_number != patient_id:
51+
logger.warning(
52+
f"Document {document_id} does not belong to patient {patient_id}"
53+
)
54+
return None
55+
56+
if document_review_item.files:
57+
for file_detail in document_review_item.files:
58+
presigned_url = self.create_cloudfront_presigned_url(
59+
file_detail.file_location
60+
)
61+
file_detail.presigned_url = presigned_url
62+
63+
document_review = document_review_item.model_dump(
64+
by_alias=True,
65+
include={
66+
"id": True,
67+
"upload_date": True,
68+
"files": {"__all__": {"file_name": True, "presigned_url": True}},
69+
"document_snomed_code_type": True,
70+
},
71+
)
72+
73+
logger.info(
74+
f"Successfully retrieved document review for document_id: {document_id}"
75+
)
76+
77+
return document_review
78+
79+
except DynamoServiceException as e:
80+
logger.error(
81+
f"{LambdaError.DocRefClient.to_str()}: {str(e)}",
82+
{"Result": "Failed to retrieve document review"},
83+
)
84+
raise GetDocumentReviewException(500, LambdaError.DocRefClient)
85+
except Exception as e:
86+
logger.error(
87+
f"Unexpected error retrieving document review: {str(e)}",
88+
{"Result": "Failed to retrieve document review"},
89+
)
90+
raise GetDocumentReviewException(500, LambdaError.DocRefClient)
91+
92+
def create_cloudfront_presigned_url(self, file_location: str) -> str:
93+
"""Create a CloudFront obfuscated pre-signed URL for a file.
94+
95+
Args:
96+
file_location: The S3 file key/location.
97+
98+
Returns:
99+
CloudFront URL that obfuscates the actual pre-signed URL.
100+
"""
101+
s3_bucket_name, file_key = file_location.removeprefix("s3://").split("/", 1)
102+
presign_url_response = self.s3_service.create_download_presigned_url(
103+
s3_bucket_name=s3_bucket_name,
104+
file_key=file_key,
105+
)
106+
107+
presigned_id = "review/" + str(uuid.uuid4())
108+
109+
deletion_date = datetime.now(timezone.utc)
110+
ttl_half_an_hour_in_seconds = self.s3_service.presigned_url_expiry
111+
dynamo_item_ttl = int(deletion_date.timestamp() + ttl_half_an_hour_in_seconds)
112+
113+
self.document_review_service.dynamo_service.create_item(
114+
self.cloudfront_table_name,
115+
{
116+
"ID": f"{presigned_id}",
117+
"presignedUrl": presign_url_response,
118+
"TTL": dynamo_item_ttl,
119+
},
120+
)
121+
122+
return format_cloudfront_url(presigned_id, self.cloudfront_url)

lambdas/tests/unit/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138
MOCK_ALERTING_SLACK_CHANNEL_ID = "slack_channel_id"
139139
MOCK_DOCUMENT_REVIEW_TABLE = "test_document_review"
140140
MOCK_DOCUMENT_REVIEW_BUCKET = "test_document_review_bucket"
141+
MOCK_EDGE_TABLE = "test_edge_reference_table"
141142

142143
@pytest.fixture
143144
def set_env(monkeypatch):
@@ -228,10 +229,10 @@ def set_env(monkeypatch):
228229
monkeypatch.setenv("ITOC_TESTING_ODS_CODES", MOCK_ITOC_ODS_CODES)
229230
monkeypatch.setenv("DOCUMENT_REVIEW_DYNAMODB_NAME", MOCK_DOCUMENT_REVIEW_TABLE)
230231
monkeypatch.setenv("DOCUMENT_REVIEW_S3_BUCKET_NAME", MOCK_DOCUMENT_REVIEW_BUCKET)
232+
monkeypatch.setenv("EDGE_REFERENCE_TABLE", MOCK_EDGE_TABLE)
231233
monkeypatch.setenv("STAGING_STORE_BUCKET_NAME", MOCK_STAGING_STORE_BUCKET)
232234
monkeypatch.setenv("METADATA_SQS_QUEUE_URL", MOCK_LG_METADATA_SQS_QUEUE)
233235

234-
235236
EXPECTED_PARSED_PATIENT_BASE_CASE = PatientDetails(
236237
givenName=["Jane"],
237238
familyName="Smith",

lambdas/tests/unit/handlers/conftest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pytest
2+
from enums.feature_flags import FeatureFlags
23
from services.feature_flags_service import FeatureFlagService
34

45

@@ -138,3 +139,13 @@ def mock_validation_strict_disabled(mocker):
138139
"lloydGeorgeValidationStrictModeEnabled": False
139140
}
140141
yield mock_upload_lambda_feature_flag
142+
143+
144+
@pytest.fixture
145+
def mock_upload_document_iteration_3_enabled(mocker):
146+
mock_function = mocker.patch.object(FeatureFlagService, "get_feature_flags_by_flag")
147+
mock_feature_flag = mock_function.return_value = {
148+
FeatureFlags.UPLOAD_DOCUMENT_ITERATION_3_ENABLED: True
149+
}
150+
yield mock_feature_flag
151+

0 commit comments

Comments
 (0)