Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
e61d3bd
[PRMP-1054] Create report_orchestration
PedroSoaresNHS Dec 17, 2025
3d9a291
[PRMP-1054] Updated variable name
PedroSoaresNHS Dec 17, 2025
528efe0
[PRMP-1054] Updated method name
PedroSoaresNHS Dec 17, 2025
7c55481
[PRMP-1054] Created tests and formated code
PedroSoaresNHS Dec 17, 2025
dfbff8d
[PRMP-1054] Small updates to allow library to be imported and used
PedroSoaresNHS Dec 18, 2025
f69afbf
[PRMP-1054] fixed unit tests
PedroSoaresNHS Dec 18, 2025
326a6c8
[PRMP-1054] added lambda layer to report_orchestration_lambda
PedroSoaresNHS Dec 19, 2025
4c31c97
Merge remote-tracking branch 'origin/main' into PRMP-1054
PedroSoaresNHS Dec 19, 2025
31fc5e4
[PRMP-1054] fixed how the search works
PedroSoaresNHS Dec 19, 2025
d247394
[PRMP-1054] fixed comment
PedroSoaresNHS Dec 19, 2025
38ea089
Merge remote-tracking branch 'origin/main' into PRMP-1054
PedroSoaresNHS Jan 2, 2026
25fda59
[PRMP-1057] created distribution lambda
PedroSoaresNHS Jan 5, 2026
bdff697
Merge remote-tracking branch 'origin/main' into PRMP-1054
PedroSoaresNHS Jan 5, 2026
21b062c
Merge remote-tracking branch 'origin/PRMP-1054' into PRMP-1057
PedroSoaresNHS Jan 5, 2026
4d9b466
[PRMP-1057] updated for the case it fails to access contacts table
PedroSoaresNHS Jan 5, 2026
4ebc15e
[PRMP-1182] trying with step functions
PedroSoaresNHS Jan 6, 2026
5cecec9
[PRMP-1182] fixed unit tests
PedroSoaresNHS Jan 6, 2026
8cd2335
[PRMP-1182] Improved query to dynamo, to use specific ranges
PedroSoaresNHS Jan 7, 2026
aa1ac5c
[PRMP-1057] adjusted time querie to dynamo
PedroSoaresNHS Jan 7, 2026
5d2a089
[PRMP-1057] added logging, removed 1 todo
PedroSoaresNHS Jan 8, 2026
e7cfb8c
Merge branch 'main' into PRMP-1054
robg-test Jan 9, 2026
68f7d3b
[PRMP-1057] fixed how items are grabbed from contact report table
PedroSoaresNHS Jan 9, 2026
cb0fd89
[PRMP-1057] fixed tests
PedroSoaresNHS Jan 9, 2026
4ba0003
Merge remote-tracking branch 'origin/main' into PRMP-1057
PedroSoaresNHS Jan 9, 2026
5c3fa49
Merge remote-tracking branch 'origin/PRMP-1054' into PRMP-1057
PedroSoaresNHS Jan 9, 2026
5035fed
[PRMP-1057] removed commented line
PedroSoaresNHS Jan 9, 2026
0264411
[PRMP-1057] removed redundancy in error message
PedroSoaresNHS Jan 15, 2026
8f74fe3
[PRMP-1057] fixed comments
PedroSoaresNHS Jan 16, 2026
509f3e9
[PRMP-1057] updated tests
PedroSoaresNHS Jan 16, 2026
0cf7002
[PRMP-1057] updated lambda names
PedroSoaresNHS Jan 19, 2026
2948ffe
[PRMP-1057] updated report columns
PedroSoaresNHS Jan 20, 2026
c67f895
[PRMP-1057] removed comment
PedroSoaresNHS Jan 20, 2026
895863f
[PRMP-1057] updated tests
PedroSoaresNHS Jan 20, 2026
717f32c
[PRMP-1057] removed unused local variable
PedroSoaresNHS Jan 20, 2026
7377d97
Merge remote-tracking branch 'origin/main' into PRMP-1054
PedroSoaresNHS Jan 21, 2026
52cdcf9
Merge remote-tracking branch 'origin/main' into PRMP-1054
PedroSoaresNHS Jan 22, 2026
4dd272b
Merge remote-tracking branch 'origin/PRMP-1054' into PRMP-1057
PedroSoaresNHS Jan 22, 2026
4c9ed8f
[PRMP-1054] fixed sonnar
PedroSoaresNHS Jan 22, 2026
b05e171
[PRMP-1054] fixed sonar
PedroSoaresNHS Jan 22, 2026
7923346
Revert "[PRMP-1054] fixed sonar"
PedroSoaresNHS Jan 22, 2026
c78120e
[PRMP-1054] fixed issues
PedroSoaresNHS Jan 22, 2026
1f1c37f
Merge remote-tracking branch 'origin/PRMP-1054' into PRMP-1057
PedroSoaresNHS Jan 22, 2026
d180ec2
Merge remote-tracking branch 'origin/main' into PRMP-1054
PedroSoaresNHS Jan 22, 2026
f00950c
Merge remote-tracking branch 'origin/PRMP-1054' into PRMP-1057
PedroSoaresNHS Jan 22, 2026
8148c1c
Merge remote-tracking branch 'origin/main' into PRMP-1057
PedroSoaresNHS Jan 23, 2026
11327c7
Merge remote-tracking branch 'origin/PRMP-1057' into PRMP-1057-2
PedroSoaresNHS Jan 26, 2026
ab6d112
Merge remote-tracking branch 'origin/main' into PRMP-1057-2
PedroSoaresNHS Jan 28, 2026
d4b6987
Merge remote-tracking branch 'origin/main' into PRMP-1057-2
PedroSoaresNHS Jan 29, 2026
5ac09fd
Merge remote-tracking branch 'origin/main' into PRMP-1057-2
PedroSoaresNHS Jan 30, 2026
5e9cb3e
[PRMP-1057-2] fixed comments
PedroSoaresNHS Jan 30, 2026
b0f6b71
Merge remote-tracking branch 'origin/main' into PRMP-1057-2
PedroSoaresNHS Jan 30, 2026
0c98c34
[PRMP-1057-2] fixed comments
PedroSoaresNHS Jan 30, 2026
6085927
Merge remote-tracking branch 'origin/main' into PRMP-1057-2
PedroSoaresNHS Jan 30, 2026
a755a54
[PRMP-1057-2] fixed tests
PedroSoaresNHS Jan 30, 2026
2361c30
[PRMP-1057-2] update handlers ans services
PedroSoaresNHS Feb 5, 2026
04b1ecd
Merge remote-tracking branch 'origin/main' into PRMP-1057-2
PedroSoaresNHS Feb 5, 2026
59f0531
[PRMP-1057-2] small fix
PedroSoaresNHS Feb 5, 2026
453c9c5
Merge remote-tracking branch 'origin/main' into PRMP-1057-2
PedroSoaresNHS Feb 6, 2026
68cd6d6
Merge remote-tracking branch 'origin/main' into PRMP-1057-2
PedroSoaresNHS Feb 6, 2026
d8c3005
[PRMP-1057] updated poetry.lock
PedroSoaresNHS Feb 6, 2026
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
16 changes: 15 additions & 1 deletion .github/workflows/base-lambdas-reusable-deploy-all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,21 @@ jobs:
build_branch: ${{ inputs.build_branch }}
sandbox: ${{ inputs.sandbox }}
lambda_handler_name: report_orchestration_handler
lambda_aws_name: reportOrchestration
lambda_aws_name: ReportOrchestration
lambda_layer_names: "core_lambda_layer,reports_lambda_layer"
secrets:
AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }}

deploy_report_distribution_lambda:
name: Deploy Report Distribution
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: report_distribution_handler
lambda_aws_name: ReportDistribution
lambda_layer_names: "core_lambda_layer,reports_lambda_layer"
secrets:
AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }}
Expand Down
4 changes: 4 additions & 0 deletions lambdas/enums/lambda_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -729,3 +729,7 @@ def create_error_body(
"message": "An internal server error occurred",
"fhir_coding": FhirIssueCoding.EXCEPTION,
}
InvalidAction = {
"err_code": "RD_IA",
"message": "Invalid action. Expected 'list' or 'process_one'.",
}
6 changes: 6 additions & 0 deletions lambdas/enums/report_distribution_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from enum import StrEnum


class ReportDistributionAction(StrEnum):
LIST = "list"
PROCESS_ONE = "process_one"
60 changes: 60 additions & 0 deletions lambdas/handlers/report_distribution_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import os
from typing import Any, Dict

from enums.lambda_error import LambdaError
from enums.report_distribution_action import ReportDistributionAction
from repositories.reporting.report_contact_repository import ReportContactRepository
from services.base.s3_service import S3Service
from services.email_service import EmailService
from services.reporting.report_distribution_service import ReportDistributionService
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.lambda_exceptions import ReportDistributionException

logger = LoggingService(__name__)


@ensure_environment_variables(
names=[
"REPORT_BUCKET_NAME",
"CONTACT_TABLE_NAME",
"PRM_MAILBOX_EMAIL",
"SES_FROM_ADDRESS",
]
)
@override_error_check
@handle_lambda_exceptions
@set_request_context_for_logging
def lambda_handler(event, context) -> Dict[str, Any]:
action = event.get("action")
if action not in {
ReportDistributionAction.LIST,
ReportDistributionAction.PROCESS_ONE,
}:
logger.error("Invalid action. Expected 'list' or 'process_one'.")
raise ReportDistributionException(400, LambdaError.InvalidAction)

bucket = event.get("bucket") or os.environ["REPORT_BUCKET_NAME"]

service = ReportDistributionService(
bucket=bucket
)

response = {"status": "ok", "bucket": bucket}

if action == ReportDistributionAction.LIST:
prefix = event["prefix"]
keys = service.list_xlsx_keys(prefix=prefix)
logger.info(f"List mode: returning {len(keys)} key(s) for prefix={prefix}")
response.update({"prefix": prefix, "keys": keys})
else:
key = event["key"]
ods_code = service.extract_ods_code_from_key(key)
service.process_one_report(ods_code=ods_code, key=key)
logger.info(f"Process-one mode: processed ods={ods_code}, key={key}")
response.update({"key": key, "ods_code": ods_code})

return response
87 changes: 69 additions & 18 deletions lambdas/handlers/report_orchestration_handler.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import os
import tempfile
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Tuple

from repositories.reporting.reporting_dynamo_repository import ReportingDynamoRepository
from services.base.s3_service import S3Service
from services.reporting.excel_report_generator_service import ExcelReportGenerator
from services.reporting.report_orchestration_service import ReportOrchestrationService
from utils.audit_logging_setup import LoggingService
Expand All @@ -14,42 +15,92 @@
logger = LoggingService(__name__)


def calculate_reporting_window():
def calculate_reporting_window() -> Tuple[int, int]:
now = datetime.now(timezone.utc)
today_7am = now.replace(hour=7, minute=0, second=0, microsecond=0)

if now < today_7am:
today_7am -= timedelta(days=1)

yesterday_7am = today_7am - timedelta(days=1)
return int(yesterday_7am.timestamp()), int(today_7am.timestamp())

return (
int(yesterday_7am.timestamp()),
int(today_7am.timestamp()),
)

def get_report_date_folder() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")


def build_s3_key(ods_code: str, report_date: str) -> str:
return f"Report-Orchestration/{report_date}/{ods_code}.xlsx"



def upload_generated_reports(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feels like this should belong in a service/handler is doing quite a bit

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tricky one,
It does look like it, but all this method is doing is loop and call
s3_service.upload_file_with_extra_args(

So if it was in the service, the report_orchestration_service would only be calling the s3 service, and as such doing no logic. That would increase nesting and make it harder to understand the workflow.
Also I think it is fine for the handler to do tiny bits of logic/adjustment and then call the correct service, instead of having service needing to know of other services

s3_service: S3Service,
bucket: str,
report_date: str,
generated_files: Dict[str, str],
) -> list[str]:
keys: list[str] = []
for ods_code, local_path in generated_files.items():
key = build_s3_key(ods_code, report_date)
s3_service.upload_file_with_extra_args(
file_name=local_path,
s3_bucket_name=bucket,
file_key=key,
extra_args={"ServerSideEncryption": "aws:kms"},
)
keys.append(key)
logger.info(f"Uploaded report for ODS={ods_code} to s3://{bucket}/{key}")
return keys


@ensure_environment_variables(names=["BULK_UPLOAD_REPORT_TABLE_NAME"])
def build_response(report_date: str, bucket: str, keys: list[str]) -> Dict[str, Any]:
prefix = f"Report-Orchestration/{report_date}/"
return {
"status": "ok",
"report_date": report_date,
"bucket": bucket,
"prefix": prefix,
"keys": keys,
}


@ensure_environment_variables(
names=[
"BULK_UPLOAD_REPORT_TABLE_NAME",
"REPORT_BUCKET_NAME",
]
)
@override_error_check
@handle_lambda_exceptions
@set_request_context_for_logging
def lambda_handler(event, context):
def lambda_handler(event, context) -> Dict[str, Any]:
logger.info("Report orchestration lambda invoked")
table_name = os.getenv("BULK_UPLOAD_REPORT_TABLE_NAME")

repository = ReportingDynamoRepository(table_name)
excel_generator = ExcelReportGenerator()

service = ReportOrchestrationService(
repository=repository,
excel_generator=excel_generator,
report_bucket = os.environ["REPORT_BUCKET_NAME"]
orchestration_service = ReportOrchestrationService(
)
s3_service = S3Service()

window_start, window_end = calculate_reporting_window()
tmp_dir = tempfile.mkdtemp()
report_date = get_report_date_folder()

service.process_reporting_window(
generated_files = orchestration_service.process_reporting_window(
window_start_ts=window_start,
window_end_ts=window_end,
output_dir=tmp_dir,
)

if not generated_files:
logger.info("No reports generated; exiting")
return build_response(report_date=report_date, bucket=report_bucket, keys=[])

keys = upload_generated_reports(
s3_service=s3_service,
bucket=report_bucket,
report_date=report_date,
generated_files=generated_files,
)

logger.info(f"Generated and uploaded {len(keys)} report(s) for report_date={report_date}")
return build_response(report_date=report_date, bucket=report_bucket, keys=keys)
19 changes: 19 additions & 0 deletions lambdas/repositories/reporting/report_contact_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import os

from services.base.dynamo_service import DynamoDBService


class ReportContactRepository:
def __init__(self):
self.table_name = os.environ["CONTACT_TABLE_NAME"]
self.dynamo = DynamoDBService()

def get_contact_email(self, ods_code: str) -> str | None:
resp = self.dynamo.get_item(
table_name=self.table_name,
key={"OdsCode": ods_code},
)
item = (resp or {}).get("Item")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why (resp or {})?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is a failsafe in case resp is None,
In that case the .get does not throw an exception

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is a bit more clear -
item = resp.get("Item") if resp else None
But up to you.

if not item:
return None
return item.get("Email")
Comment on lines +11 to +19
Copy link
Contributor

@steph-torres-nhs steph-torres-nhs Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could this live within the email or distribution service?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, but it is more about separation of concerns.
The service should do its logic without the need of knowing information about how things are stored.

52 changes: 36 additions & 16 deletions lambdas/repositories/reporting/reporting_dynamo_repository.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,55 @@
import os
from datetime import timedelta
from typing import Dict, List

from boto3.dynamodb.conditions import Attr
from boto3.dynamodb.conditions import Key
from services.base.dynamo_service import DynamoDBService
from utils.audit_logging_setup import LoggingService
from utils.utilities import utc_date_string, utc_date, utc_day_start_timestamp, utc_day_end_timestamp

logger = LoggingService(__name__)


class ReportingDynamoRepository:
def __init__(self, table_name: str):
self.table_name = table_name
def __init__(self):
self.table_name = os.environ["BULK_UPLOAD_REPORT_TABLE_NAME"]
self.dynamo_service = DynamoDBService()

def get_records_for_time_window(
self,
start_timestamp: int,
end_timestamp: int,
) -> List[Dict]:
timestamp_index_name = "TimestampIndex"
logger.info(
f"Querying reporting table for window, "
f"table_name: {self.table_name}, "
f"start_timestamp: {start_timestamp}, "
f"end_timestamp: {end_timestamp}",
"Querying reporting table via TimestampIndex for window, "
f"table_name={self.table_name}, start_timestamp={start_timestamp}, end_timestamp={end_timestamp}",
)

filter_expression = Attr("Timestamp").between(
start_timestamp,
end_timestamp,
)
start_date = utc_date(start_timestamp)
end_date = utc_date(end_timestamp)

return self.dynamo_service.scan_whole_table(
table_name=self.table_name,
filter_expression=filter_expression,
)
records_for_window: List[Dict] = []
current_date = start_date

while current_date <= end_date:
day_start_ts = utc_day_start_timestamp(current_date)
day_end_ts = utc_day_end_timestamp(current_date)

effective_start_ts = max(start_timestamp, day_start_ts)
effective_end_ts = min(end_timestamp, day_end_ts)

key_condition = (
Key("Date").eq(current_date.isoformat())
& Key("Timestamp").between(effective_start_ts, effective_end_ts)
)

records_for_day = self.dynamo_service.query_by_key_condition_expression(
table_name=self.table_name,
index_name=timestamp_index_name,
key_condition_expression=key_condition,
)

records_for_window.extend(records_for_day)
current_date += timedelta(days=1)

return records_for_window
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
openpyxl==3.1.5
reportlab==4.3.1
reportlab==4.3.1
pyzipper==0.3.6
40 changes: 40 additions & 0 deletions lambdas/services/base/dynamo_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,46 @@ def build_update_transaction_item(
}
}

def query_by_key_condition_expression(
self,
table_name: str,
key_condition_expression: ConditionBase,
index_name: str | None = None,
query_filter: Attr | ConditionBase | None = None,
limit: int | None = None,
) -> list[dict]:
table = self.get_table(table_name)

collected_items: list[dict] = []
exclusive_start_key: dict | None = None

while True:
query_params: dict = {"KeyConditionExpression": key_condition_expression}

if index_name:
query_params["IndexName"] = index_name
if query_filter:
query_params["FilterExpression"] = query_filter
if exclusive_start_key:
query_params["ExclusiveStartKey"] = exclusive_start_key
if limit:
query_params["Limit"] = limit

try:
response = table.query(**query_params)
except ClientError as exc:
logger.error(str(exc), {"Result": f"Unable to query table: {table_name}"})
raise

collected_items.extend(response.get("Items", []))

exclusive_start_key = response.get("LastEvaluatedKey")
if not exclusive_start_key:
break

return collected_items


def query_table_with_paginator(
self,
table_name: str,
Expand Down
12 changes: 11 additions & 1 deletion lambdas/services/base/s3_service.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import io
from datetime import datetime, timedelta, timezone
from io import BytesIO
from typing import Any, Mapping
from typing import Any, Mapping, Callable

import boto3
from botocore.client import Config as BotoConfig
Expand Down Expand Up @@ -255,3 +255,13 @@ def save_or_create_file(self, source_bucket: str, file_key: str, body: bytes):
return self.client.put_object(
Bucket=source_bucket, Key=file_key, Body=BytesIO(body)
)

def list_object_keys(self, bucket_name: str, prefix: str) -> list[str]:
paginator = self.client.get_paginator("list_objects_v2")
keys: list[str] = []

for page in paginator.paginate(Bucket=bucket_name, Prefix=prefix):
for obj in page.get("Contents", []):
keys.append(obj["Key"])

return keys
Loading
Loading