Skip to content

Commit 6aa6b58

Browse files
[PRMP-589] Create SearchDocumentReview lambda logic (#864)
Co-authored-by: NogaNHS <[email protected]>
1 parent 89cd2ca commit 6aa6b58

15 files changed

+1483
-57
lines changed

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,19 @@ jobs:
682682
lambda_layer_names: "core_lambda_layer"
683683
secrets:
684684
AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }}
685+
686+
deploy_search_document_review_lambda:
687+
name: Deploy Search Document Review
688+
uses: ./.github/workflows/base-lambdas-reusable-deploy.yml
689+
with:
690+
environment: ${{ inputs.environment }}
691+
python_version: ${{ inputs.python_version }}
692+
build_branch: ${{ inputs.build_branch }}
693+
sandbox: ${{ inputs.sandbox }}
694+
lambda_handler_name: search_document_review_handler
695+
lambda_aws_name: SearchDocumentReview
696+
secrets:
697+
AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }}
685698

686699
deploy_get_document_reference_by_id_lambda:
687700
name: Deploy get_document_reference_lambda
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from enum import StrEnum
2+
3+
4+
class DocumentReviewQuerystringParameters(StrEnum):
5+
LIMIT = "limit"
6+
NEXT_PAGE_TOKEN = "nextPageToken"
7+
UPLOADER = "uploader"
8+
NHS_NUMBER = "nhsNumber"
9+

lambdas/enums/lambda_error.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,30 +60,29 @@ def create_error_body(self, params: Optional[dict] = None, **kwargs) -> str:
6060
"fhir_coding": UKCoreSpineError.VALIDATION_ERROR,
6161
}
6262

63-
6463
"""
6564
Errors for /DocumentReference
6665
"""
6766
DocRefNoBody = {
6867
"err_code": "DR_4001",
6968
"message": "Missing event body",
7069
"fhir_coding": FhirIssueCoding.REQUIRED,
71-
}
70+
}
7271
DocRefPayload = {
7372
"err_code": "DR_4002",
7473
"message": "Invalid json in body",
7574
"fhir_coding": FhirIssueCoding.INVALID,
76-
}
75+
}
7776
DocRefProps = {
7877
"err_code": "DR_4003",
7978
"message": "Request body missing some properties",
80-
"fhircoding": FhirIssueCoding.REQUIRED
79+
"fhircoding": FhirIssueCoding.REQUIRED,
8180
}
8281
DocRefInvalidFiles = {
8382
"err_code": "DR_4004",
8483
"message": "Invalid files or id",
85-
"fhir_coding": FhirIssueCoding.INVALID
86-
}
84+
"fhir_coding": FhirIssueCoding.INVALID,
85+
}
8786
DocRefNoParse = {
8887
"err_code": "DR_4005",
8988
"message": "Failed to parse document upload request data",
@@ -117,7 +116,7 @@ def create_error_body(self, params: Optional[dict] = None, **kwargs) -> str:
117116
DocRefUploadInternalError = {
118117
"err_code": "DR_5002",
119118
"message": "An error occurred when creating pre-signed url for document reference",
120-
"fhir_coding": FhirIssueCoding.EXCEPTION
119+
"fhir_coding": FhirIssueCoding.EXCEPTION,
121120
}
122121
DocRefPatientSearchInvalid = {
123122
"err_code": "DR_5003",
@@ -136,12 +135,12 @@ def create_error_body(self, params: Optional[dict] = None, **kwargs) -> str:
136135
UpdateDocNHSNumberMismatch = {
137136
"err_code": "UDR_5005",
138137
"message": "NHS number did not match",
139-
"fhir_coding": FhirIssueCoding.INVARIANT
138+
"fhir_coding": FhirIssueCoding.INVARIANT,
140139
}
141140
UpdateDocNotLatestVersion = {
142141
"err_code": "UDR_5006",
143142
"message": "Document is not the latest version",
144-
"fhir_coding": FhirIssueCoding.INVARIANT
143+
"fhir_coding": FhirIssueCoding.INVARIANT,
145144
}
146145

147146
"""
@@ -630,3 +629,26 @@ def create_error_body(self, params: Optional[dict] = None, **kwargs) -> str:
630629
"message": "An internal server error occurred",
631630
"fhir_coding": FhirIssueCoding.EXCEPTION,
632631
}
632+
633+
"""
634+
Errors for SearchDocumentReviewReference exceptions
635+
"""
636+
DocumentReviewDB = {
637+
"err_code": "SDR_5001",
638+
"message": RETRIEVE_DOCUMENTS,
639+
}
640+
641+
DocumentReviewValidation = {
642+
"err_code": "SDR_5002",
643+
"message": "Review document model error",
644+
}
645+
646+
SearchDocumentReviewMissingODS = {
647+
"err_code": "SDR_4001",
648+
"message": "Missing ODS code in request context",
649+
}
650+
651+
SearchDocumentInvalidQuerystring = {
652+
"err_code": "SDR_4002",
653+
"message": "Invalid query string passed",
654+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import json
2+
3+
from enums.document_review_accepted_querystring_parameters import (
4+
DocumentReviewQuerystringParameters,
5+
)
6+
from enums.feature_flags import FeatureFlags
7+
from enums.lambda_error import LambdaError
8+
from services.feature_flags_service import FeatureFlagService
9+
from services.search_document_review_service import SearchDocumentReviewService
10+
from utils.audit_logging_setup import LoggingService
11+
from utils.decorators.ensure_env_var import ensure_environment_variables
12+
from utils.decorators.handle_lambda_exceptions import handle_lambda_exceptions
13+
from utils.decorators.override_error_check import override_error_check
14+
from utils.decorators.set_audit_arg import set_request_context_for_logging
15+
from utils.exceptions import OdsErrorException
16+
from utils.lambda_exceptions import DocumentReviewException
17+
from utils.lambda_response import ApiGatewayResponse
18+
from utils.request_context import request_context
19+
20+
logger = LoggingService(__name__)
21+
22+
23+
@set_request_context_for_logging
24+
@ensure_environment_variables(names=["DOCUMENT_REVIEW_DYNAMODB_NAME"])
25+
@override_error_check
26+
@handle_lambda_exceptions
27+
def lambda_handler(event, context):
28+
"""
29+
Lambda handler for searching documents pending review by custodian.
30+
Triggered by GET request to /SearchDocumentReview endpoint.
31+
Args:
32+
event: API Gateway event containing query optional query string parameters
33+
QueryStringParameters: limit - Limit for DynamoDB query, defaulted to 50 if not provided,
34+
nhsNumber - Patient NHS number, used for filtering DynamoDB results,
35+
uploader - Author ODS code, used for filtering DynamoDB results,
36+
nextPageToken - Encoded exclusive start key used to query DynamoDB for next page of results.
37+
context: Lambda context
38+
39+
Returns:
40+
API Gateway response containing Document Review references, number of reference returned, and next page token if present.
41+
401 - No ODS code or auth token provided in request.
42+
500 - Document Review Error, Internal Server Error.
43+
44+
"""
45+
try:
46+
feature_flag_service = FeatureFlagService()
47+
upload_lambda_enabled_flag_object = (
48+
feature_flag_service.get_feature_flags_by_flag(
49+
FeatureFlags.UPLOAD_DOCUMENT_ITERATION_3_ENABLED
50+
)
51+
)
52+
53+
if not upload_lambda_enabled_flag_object[
54+
FeatureFlags.UPLOAD_DOCUMENT_ITERATION_3_ENABLED
55+
]:
56+
logger.info("Feature flag not enabled, event will not be processed")
57+
raise DocumentReviewException(403, LambdaError.FeatureFlagDisabled)
58+
59+
ods_code = get_ods_code_from_request_context()
60+
61+
params = parse_querystring_parameters(event)
62+
63+
search_document_reference_service = SearchDocumentReviewService()
64+
65+
references, next_page_token = search_document_reference_service.process_request(
66+
params=params, ods_code=ods_code
67+
)
68+
69+
response = {"documentReviewReferences": references, "count": len(references)}
70+
71+
if next_page_token:
72+
response["nextPageToken"] = next_page_token
73+
74+
return ApiGatewayResponse(
75+
status_code=200,
76+
body=json.dumps(response),
77+
methods="GET",
78+
).create_api_gateway_response()
79+
80+
except OdsErrorException as e:
81+
logger.error(e)
82+
return ApiGatewayResponse(
83+
status_code=401,
84+
body=LambdaError.SearchDocumentReviewMissingODS.create_error_body(),
85+
methods="GET",
86+
).create_api_gateway_response()
87+
88+
89+
def get_ods_code_from_request_context():
90+
logger.info("Getting ODS code from request context")
91+
try:
92+
ods_code = request_context.authorization.get("selected_organisation", {}).get(
93+
"org_ods_code"
94+
)
95+
if not ods_code:
96+
raise OdsErrorException()
97+
98+
return ods_code
99+
100+
except AttributeError as e:
101+
logger.error(e)
102+
raise DocumentReviewException(401, LambdaError.SearchDocumentReviewMissingODS)
103+
104+
105+
def parse_querystring_parameters(event):
106+
logger.info("Parsing query string parameters.")
107+
params = event.get("queryStringParameters", {})
108+
109+
extracted_params = {}
110+
111+
if not params:
112+
return extracted_params
113+
114+
for param in DocumentReviewQuerystringParameters:
115+
if param in params:
116+
extracted_params[param.value] = params.get(param)
117+
118+
return extracted_params

lambdas/services/authoriser_service.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ def deny_access_policy(self, path, user_role, nhs_number: str = None):
117117
case "/Feedback":
118118
deny_resource = False
119119

120+
case "/DocumentReview":
121+
deny_resource = False
122+
120123
case "/DocumentStatus":
121124
deny_resource = (
122125
not patient_access_is_allowed or is_user_gp_clinical or is_user_pcse

lambdas/services/base/dynamo_service.py

Lines changed: 81 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,33 @@ def get_table(self, table_name: str):
3636
logger.error(str(e), {"Result": "Unable to connect to DB"})
3737
raise e
3838

39-
def query_table(
39+
def query_table_single(
4040
self,
41-
table_name,
42-
search_key,
41+
table_name: str,
42+
search_key: str,
4343
search_condition: str,
4444
index_name: str | None = None,
45-
requested_fields: list[str] = None,
46-
query_filter: Attr | ConditionBase = None,
47-
) -> list[dict]:
45+
requested_fields: list[str] | None = None,
46+
query_filter: Attr | ConditionBase | None = None,
47+
limit: int | None = None,
48+
start_key: dict | None = None,
49+
) -> dict:
50+
"""
51+
Execute a single DynamoDB query and return the full response.
52+
53+
Args:
54+
table_name: Name of the DynamoDB table
55+
search_key: The partition key name to search on
56+
search_condition: The value to match for the search key
57+
index_name: Optional GSI/LSI name
58+
requested_fields: Optional list of fields to project
59+
query_filter: Optional filter expression
60+
limit: Optional limit on number of items to return
61+
start_key: Optional exclusive start key for pagination
62+
63+
Returns:
64+
Full DynamoDB query response including Items, LastEvaluatedKey, etc.
65+
"""
4866
try:
4967
table = self.get_table(table_name)
5068

@@ -54,30 +72,75 @@ def query_table(
5472

5573
if index_name:
5674
query_params["IndexName"] = index_name
75+
5776
if requested_fields:
5877
projection_expression = ",".join(requested_fields)
5978
query_params["ProjectionExpression"] = projection_expression
79+
6080
if query_filter:
6181
query_params["FilterExpression"] = query_filter
62-
items = []
63-
while True:
64-
results = table.query(**query_params)
6582

66-
if results is None or "Items" not in results:
67-
logger.error(f"Unusable results in DynamoDB: {results!r}")
68-
raise DynamoServiceException("Unrecognised response from DynamoDB")
83+
if start_key:
84+
query_params["ExclusiveStartKey"] = start_key
6985

70-
items += results["Items"]
86+
if limit:
87+
query_params["Limit"] = limit
7188

72-
if "LastEvaluatedKey" in results:
73-
query_params["ExclusiveStartKey"] = results["LastEvaluatedKey"]
74-
else:
75-
break
76-
return items
89+
return table.query(**query_params)
7790
except ClientError as e:
7891
logger.error(str(e), {"Result": f"Unable to query table: {table_name}"})
7992
raise e
8093

94+
def query_table(
95+
self,
96+
table_name: str,
97+
search_key: str,
98+
search_condition: str,
99+
index_name: str | None = None,
100+
requested_fields: list[str] | None = None,
101+
query_filter: Attr | ConditionBase | None = None,
102+
) -> list[dict]:
103+
"""
104+
Execute a DynamoDB query and automatically paginate through all results.
105+
106+
Args:
107+
table_name: Name of the DynamoDB table
108+
search_key: The partition key name to search on
109+
search_condition: The value to match for the search key
110+
index_name: Optional GSI/LSI name
111+
requested_fields: Optional list of fields to project
112+
query_filter: Optional filter expression
113+
114+
Returns:
115+
List of all items from paginated query results
116+
"""
117+
items = []
118+
start_key = None
119+
120+
while True:
121+
results = self.query_table_single(
122+
table_name=table_name,
123+
search_key=search_key,
124+
search_condition=search_condition,
125+
index_name=index_name,
126+
requested_fields=requested_fields,
127+
query_filter=query_filter,
128+
start_key=start_key,
129+
)
130+
131+
if results is None or "Items" not in results:
132+
logger.error(f"Unusable results in DynamoDB: {results!r}")
133+
raise DynamoServiceException("Unrecognised response from DynamoDB")
134+
135+
items += results["Items"]
136+
137+
if "LastEvaluatedKey" in results:
138+
start_key = results["LastEvaluatedKey"]
139+
else:
140+
break
141+
142+
return items
143+
81144
def create_item(self, table_name, item):
82145
try:
83146
table = self.get_table(table_name)

0 commit comments

Comments
 (0)