diff --git a/lambdas/handlers/get_report_by_ods_handler.py b/lambdas/handlers/get_report_by_ods_handler.py index e7867460d6..824bab8f48 100644 --- a/lambdas/handlers/get_report_by_ods_handler.py +++ b/lambdas/handlers/get_report_by_ods_handler.py @@ -21,7 +21,7 @@ "PRESIGNED_ASSUME_ROLE", "LLOYD_GEORGE_DYNAMODB_NAME", "STATISTICAL_REPORTS_BUCKET", - ] + ], ) @override_error_check @handle_lambda_exceptions @@ -31,13 +31,12 @@ def lambda_handler(event, context): if "httpMethod" in event: return handle_api_gateway_request(event) - else: - return handle_manual_trigger(event) + return handle_manual_trigger(event) def handle_api_gateway_request(event): ods_code = request_context.authorization.get("selected_organisation", {}).get( - "org_ods_code" + "org_ods_code", ) if not ods_code: raise OdsErrorException("No ODS code provided") @@ -58,7 +57,9 @@ def handle_api_gateway_request(event): pre_signed_url = create_report(ods_code, file_type, report_type) logger.info("A report has been successfully created.") return ApiGatewayResponse( - 200, json.dumps({"url": pre_signed_url}), "GET" + 200, + json.dumps({"url": pre_signed_url}), + "GET", ).create_api_gateway_response() @@ -72,7 +73,7 @@ def create_report(ods_code: str, file_type: FileType, report_type: str): def create_patient_report(ods_code: str, file_type: FileType): service = OdsReportService() - return service.get_nhs_numbers_by_ods( + return service.generate_ods_report( ods_code=ods_code, is_pre_signed_needed=True, is_upload_to_s3_needed=True, @@ -94,9 +95,13 @@ def handle_manual_trigger(event): service = OdsReportService() for ods_code in ods_codes: logger.info(f"Starting process for ods code: {ods_code}") - service.get_nhs_numbers_by_ods( - ods_code=ods_code, is_upload_to_s3_needed=True, file_type_output=file_type + service.generate_ods_report( + ods_code=ods_code, + is_upload_to_s3_needed=True, + file_type_output=file_type, ) return ApiGatewayResponse( - 200, "Successfully created report", "GET" + 200, + "Successfully created report", + "GET", ).create_api_gateway_response() diff --git a/lambdas/services/ods_report_service.py b/lambdas/services/ods_report_service.py index e892b09004..ce4aec2b7d 100644 --- a/lambdas/services/ods_report_service.py +++ b/lambdas/services/ods_report_service.py @@ -1,6 +1,11 @@ import os import tempfile from datetime import datetime +from typing import Any + +from openpyxl.workbook import Workbook +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas from enums.dynamo_filter import AttributeOperator from enums.file_type import FileType @@ -10,9 +15,6 @@ from enums.report_type import ReportType from enums.repository_role import RepositoryRole from models.document_review import DocumentUploadReviewReference -from openpyxl.workbook import Workbook -from reportlab.lib.pagesizes import letter -from reportlab.pdfgen import canvas from services.base.dynamo_service import DynamoDBService from services.base.s3_service import S3Service from services.search_document_review_service import DocumentUploadReviewService @@ -21,6 +23,11 @@ from utils.dynamo_query_filter_builder import DynamoQueryFilterBuilder from utils.lambda_exceptions import OdsReportException from utils.request_context import request_context +from utils.utilities import ( + datetime_to_utc_iso_string, + epoch_seconds_to_datetime_utc, + iso_utc_string_to_datetime, +) logger = LoggingService(__name__) @@ -35,38 +42,38 @@ def __init__(self): self.s3_service = S3Service(custom_aws_role=download_report_aws_role_arn) self.document_upload_review_service = DocumentUploadReviewService() - def get_nhs_numbers_by_ods( + def generate_ods_report( self, ods_code: str, is_pre_signed_needed: bool = False, is_upload_to_s3_needed: bool = False, file_type_output: FileType = FileType.CSV, ): - results = self.query_table_by_index(ods_code) - nhs_numbers = { - item.get(DocumentReferenceMetadataFields.NHS_NUMBER.value) - for item in results - if item.get(DocumentReferenceMetadataFields.NHS_NUMBER.value) - } + document_items = self.query_table_by_index(ods_code) + patient_rows = self.build_patient_rows(document_items) + if is_upload_to_s3_needed: self.temp_output_dir = tempfile.mkdtemp() + return self.create_and_save_ods_report( - ods_code, - nhs_numbers, - is_pre_signed_needed, - is_upload_to_s3_needed, - file_type_output, + ods_code=ods_code, + patient_rows=patient_rows, + create_pre_signed_url=is_pre_signed_needed, + upload_to_s3=is_upload_to_s3_needed, + file_type_output=file_type_output, ) def get_documents_for_review( - self, ods_code: str, output_file_type: FileType = FileType.CSV + self, + ods_code: str, + output_file_type: FileType = FileType.CSV, ): if output_file_type != FileType.CSV: raise OdsReportException(400, LambdaError.UnsupportedFileType) query_filter = self.document_upload_review_service.build_review_dynamo_filter() - results = self.document_upload_review_service.fetch_documents_from_table( + review_rows = self.document_upload_review_service.fetch_documents_from_table( search_key="Custodian", search_condition=ods_code, index_name="CustodianIndex", @@ -75,12 +82,56 @@ def get_documents_for_review( self.temp_output_dir = tempfile.mkdtemp() - return self.create_and_save_ods_report( + return self.create_and_save_review_report( ods_code=ods_code, - data=results, + review_rows=review_rows, create_pre_signed_url=True, upload_to_s3=True, - report_type=ReportType.REVIEW, + ) + + def _upload_and_presign_if_needed( + self, + *, + ods_code: str, + file_name: str, + local_file_path: str, + upload_to_s3: bool, + create_pre_signed_url: bool, + ): + if upload_to_s3: + self.save_report_to_s3(ods_code, file_name, local_file_path) + if create_pre_signed_url: + return self.get_pre_signed_url(ods_code, file_name) + return None + + def create_and_save_review_report( + self, + ods_code: str, + review_rows: list[DocumentUploadReviewReference], + create_pre_signed_url: bool = False, + upload_to_s3: bool = False, + ): + file_name = self.get_file_name_for_report_type( + ReportType.REVIEW, + ods_code, + len(review_rows), + FileType.CSV, + ) + + local_file_path = os.path.join(self.temp_output_dir, file_name) + + self.create_review_csv_report(local_file_path, review_rows) + + logger.info( + f"Query completed. {len(review_rows)} items written to {file_name}.", + ) + + return self._upload_and_presign_if_needed( + ods_code=ods_code, + file_name=file_name, + local_file_path=local_file_path, + upload_to_s3=upload_to_s3, + create_pre_signed_url=create_pre_signed_url, ) def scan_table_with_filter(self, ods_code: str): @@ -148,47 +199,48 @@ def query_table_by_index(self, ods_code: str): def create_and_save_ods_report( self, ods_code: str, - data: set[str] | list[DocumentUploadReviewReference], + patient_rows: dict[str, dict[str, Any]], create_pre_signed_url: bool = False, upload_to_s3: bool = False, file_type_output: FileType = FileType.CSV, - report_type: ReportType = ReportType.PATIENT, ): file_name, local_file_path = self.create_ods_report( - ods_code, data, file_type_output, report_type + ods_code=ods_code, + patient_rows=patient_rows, + file_type_output=file_type_output, ) - if upload_to_s3: - self.save_report_to_s3(ods_code, file_name, local_file_path) - - if create_pre_signed_url: - return self.get_pre_signed_url(ods_code, file_name) + return self._upload_and_presign_if_needed( + ods_code=ods_code, + file_name=file_name, + local_file_path=local_file_path, + upload_to_s3=upload_to_s3, + create_pre_signed_url=create_pre_signed_url, + ) def create_ods_report( self, ods_code: str, - data: set[str] | list[DocumentUploadReviewReference], + patient_rows: dict[str, dict[str, Any]], file_type_output: FileType = FileType.CSV, - report_type: ReportType = ReportType.PATIENT, ): file_name = self.get_file_name_for_report_type( - report_type, ods_code, len(data), file_type_output + ReportType.PATIENT, + ods_code, + len(patient_rows), + file_type_output, ) local_file_path = os.path.join(self.temp_output_dir, file_name) match file_type_output: case FileType.CSV: - if report_type == ReportType.PATIENT: - self.create_csv_report(local_file_path, data, ods_code) - elif report_type == ReportType.REVIEW: - self.create_review_csv_report(local_file_path, data) + self.create_csv_report(local_file_path, patient_rows, ods_code) case FileType.XLSX: - self.create_xlsx_report(local_file_path, data, ods_code) + self.create_xlsx_report(local_file_path, patient_rows, ods_code) case FileType.PDF: - self.create_pdf_report(local_file_path, data, ods_code) + self.create_pdf_report(local_file_path, patient_rows, ods_code) case _: raise OdsReportException(400, LambdaError.UnsupportedFileType) - logger.info(f"Query completed. {len(data)} items written to {file_name}.") return (file_name, local_file_path) @@ -220,16 +272,10 @@ def get_file_name_for_report_type( return file_name - def create_csv_report(self, file_name: str, nhs_numbers: set[str], ods_code: str): - with open(file_name, "w") as f: - f.write( - f"Total number of patients for ODS code {ods_code}: {len(nhs_numbers)}\n" - ) - f.write("NHS Numbers:\n") - f.writelines(f"{nhs_number}\n" for nhs_number in nhs_numbers) - def create_review_csv_report( - self, file_name: str, data: list[DocumentUploadReviewReference] + self, + file_name: str, + data: list[DocumentUploadReviewReference], ): headers = [ "nhs_number", @@ -243,7 +289,7 @@ def create_review_csv_report( full_line = "" for header in headers: full_line += f"{header}," - full_line = full_line[:-1] # remove the trailing comma + full_line = full_line[:-1] f.write(f"{full_line}\n") for line in data: @@ -258,22 +304,107 @@ def create_review_csv_report( + line.author + "," + upload_date - + "\n" + + "\n", ) - def create_xlsx_report(self, file_name: str, nhs_numbers: set[str], ods_code: str): + def build_patient_rows( + self, + document_items: list[dict], + ) -> dict[str, dict[str, Any]]: + rows: dict[str, dict[str, Any]] = {} + + for item in document_items: + nhs_number = item.get(DocumentReferenceMetadataFields.NHS_NUMBER.value) + if not nhs_number: + logger.warning(f"No nhs number found in document_item: {item}") + continue + + created_dt = iso_utc_string_to_datetime( + item.get(DocumentReferenceMetadataFields.CREATED.value), + ) + updated_dt = epoch_seconds_to_datetime_utc( + item.get(DocumentReferenceMetadataFields.LAST_UPDATED.value), + ) + + current_row_for_patient = rows.get(nhs_number) + if current_row_for_patient is None: + rows[nhs_number] = { + "nhs_number": nhs_number, + "latest_created_date": created_dt, + "latest_updated_date": updated_dt, + } + continue + + if created_dt is not None and ( + current_row_for_patient["latest_created_date"] is None + or created_dt > current_row_for_patient["latest_created_date"] + ): + current_row_for_patient["latest_created_date"] = created_dt + + if updated_dt is not None and ( + current_row_for_patient["latest_updated_date"] is None + or updated_dt > current_row_for_patient["latest_updated_date"] + ): + current_row_for_patient["latest_updated_date"] = updated_dt + + return rows + + def create_csv_report( + self, + file_name: str, + patient_rows: dict[str, dict[str, Any]], + ods_code: str, + ): + with open(file_name, "w") as f: + f.write( + f"Total number of patients for ODS code {ods_code}: {len(patient_rows)}\n", + ) + + f.write("NHS Number,Latest Created Date,Latest Updated Date\n") + for nhs in sorted(patient_rows.keys()): + row = patient_rows[nhs] + created = datetime_to_utc_iso_string(row.get("latest_created_date")) + last_updated = datetime_to_utc_iso_string( + row.get("latest_updated_date"), + ) + f.write(f"{nhs},{created},{last_updated}\n") + + def create_xlsx_report( + self, + file_name: str, + patient_rows: dict[str, dict[str, Any]], + ods_code: str, + ): wb = Workbook() ws = wb.active ws["A1"] = ( - f"Total number of patients for ODS code {ods_code}: {len(nhs_numbers)}\n" + f"Total number of patients for ODS code {ods_code}: {len(patient_rows)}\n" ) - ws["A2"] = "NHS Numbers:\n" - for row in nhs_numbers: - ws.append([row]) + + ws.append(["NHS Number", "Latest Created Date", "Latest Updated Date"]) + + for nhs in sorted(patient_rows.keys()): + row = patient_rows[nhs] + ws.append( + [ + row.get("nhs_number", ""), + datetime_to_utc_iso_string(row.get("latest_created_date")), + datetime_to_utc_iso_string(row.get("latest_updated_date")), + ], + ) + + ws.column_dimensions["A"].width = 14 + ws.column_dimensions["B"].width = 20 + ws.column_dimensions["C"].width = 20 wb.save(file_name) - def create_pdf_report(self, file_name: str, nhs_numbers: set[str], ods_code: str): + def create_pdf_report( + self, + file_name: str, + patient_rows: dict[str, dict[str, Any]], + ods_code: str, + ): c = canvas.Canvas(file_name, pagesize=letter) _, height = letter c.setFont("Helvetica-Bold", 16) @@ -281,17 +412,36 @@ def create_pdf_report(self, file_name: str, nhs_numbers: set[str], ods_code: str y = 700 c.drawString(x, height - 50, f"NHS numbers within NDR for ODS code: {ods_code}") c.setFont("Helvetica", 12) - - c.drawString(x, y, f"Total number of patients: {len(nhs_numbers)}") + c.drawString(x, y, f"Total number of patients: {len(patient_rows)}") y -= 20 - c.drawString(x, y, "NHS Numbers:") + c.drawString(x, y, "NHS Number | Latest Created Date | Latest Updated Date") y -= 20 - for row in nhs_numbers: + for nhs in sorted(patient_rows.keys()): if y < 40: c.showPage() + c.setFont("Helvetica-Bold", 16) + c.drawString( + x, + height - 50, + f"NHS numbers within NDR for ODS code: {ods_code}", + ) + c.setFont("Helvetica", 12) y = height - 50 - c.drawString(100, y, row) + y -= 40 + c.drawString( + x, + y, + "NHS Number | Latest Created Date | Latest Updated Date", + ) + y -= 20 + + row = patient_rows[nhs] + created = datetime_to_utc_iso_string(row.get("latest_created_date")) + last_updated = datetime_to_utc_iso_string(row.get("latest_updated_date")) + + line = f"{nhs} | {created} | {last_updated}" + c.drawString(x, y, line[:120]) y -= 20 c.save() diff --git a/lambdas/tests/unit/handlers/test_get_report_by_ods_handler.py b/lambdas/tests/unit/handlers/test_get_report_by_ods_handler.py index 56c95d90d7..8f65010734 100644 --- a/lambdas/tests/unit/handlers/test_get_report_by_ods_handler.py +++ b/lambdas/tests/unit/handlers/test_get_report_by_ods_handler.py @@ -2,6 +2,7 @@ import logging import pytest + from handlers.get_report_by_ods_handler import ( handle_api_gateway_request, handle_manual_trigger, @@ -18,28 +19,34 @@ def mock_service(mocker): mock_service = mocker.Mock() mocker.patch( - "handlers.get_report_by_ods_handler.OdsReportService", return_value=mock_service + "handlers.get_report_by_ods_handler.OdsReportService", + return_value=mock_service, ) return mock_service def test_lambda_handler_api_gateway_request( - mock_service, set_env, context, mock_jwt_encode + mock_service, + set_env, + context, + mock_jwt_encode, ): event = { "httpMethod": "GET", "headers": {"Authorization": "mock_token"}, "queryStringParameters": {"odsReportType": "PATIENT"}, } - mock_service.get_nhs_numbers_by_ods.return_value = "example.com/presigned-url" + mock_service.generate_ods_report.return_value = "example.com/presigned-url" expected = ApiGatewayResponse( - 200, json.dumps({"url": "example.com/presigned-url"}), "GET" + 200, + json.dumps({"url": "example.com/presigned-url"}), + "GET", ).create_api_gateway_response() result = lambda_handler(event, context) assert result == expected - mock_service.get_nhs_numbers_by_ods.assert_called_once_with( + mock_service.generate_ods_report.assert_called_once_with( ods_code="ODS123", is_pre_signed_needed=True, is_upload_to_s3_needed=True, @@ -48,7 +55,10 @@ def test_lambda_handler_api_gateway_request( def test_lambda_handler_gateway_request_review( - mock_service, set_env, context, mock_jwt_encode + mock_service, + set_env, + context, + mock_jwt_encode, ): event = { "httpMethod": "GET", @@ -57,7 +67,9 @@ def test_lambda_handler_gateway_request_review( } mock_service.get_documents_for_review.return_value = "example.com/presigned-url" expected = ApiGatewayResponse( - 200, json.dumps({"url": "example.com/presigned-url"}), "GET" + 200, + json.dumps({"url": "example.com/presigned-url"}), + "GET", ).create_api_gateway_response() result = lambda_handler(event, context) @@ -68,20 +80,26 @@ def test_lambda_handler_gateway_request_review( def test_lambda_handler_manual_trigger(mock_service, set_env, context): event = {"odsCode": "ODS123,ODS456"} - mock_service.get_nhs_numbers_by_ods.return_value = None + mock_service.generate_ods_report.return_value = None expected = ApiGatewayResponse( - 200, "Successfully created report", "GET" + 200, + "Successfully created report", + "GET", ).create_api_gateway_response() result = lambda_handler(event, context) assert result == expected - assert mock_service.get_nhs_numbers_by_ods.call_count == 2 - mock_service.get_nhs_numbers_by_ods.assert_any_call( - ods_code="ODS123", file_type_output="csv", is_upload_to_s3_needed=True + assert mock_service.generate_ods_report.call_count == 2 + mock_service.generate_ods_report.assert_any_call( + ods_code="ODS123", + file_type_output="csv", + is_upload_to_s3_needed=True, ) - mock_service.get_nhs_numbers_by_ods.assert_any_call( - ods_code="ODS456", file_type_output="csv", is_upload_to_s3_needed=True + mock_service.generate_ods_report.assert_any_call( + ods_code="ODS456", + file_type_output="csv", + is_upload_to_s3_needed=True, ) @@ -128,16 +146,18 @@ def test_lambda_handler_manual_trigger(mock_service, set_env, context): ], ) def test_handle_api_gateway_request_handles_output_file_format( - mock_service, event, output + mock_service, + event, + output, ): request_context.authorization = { - "selected_organisation": {"org_ods_code": "ODS123"} + "selected_organisation": {"org_ods_code": "ODS123"}, } - mock_service.get_nhs_numbers_by_ods.return_value = "example.com/presigned-url" + mock_service.generate_ods_report.return_value = "example.com/presigned-url" handle_api_gateway_request(event) - mock_service.get_nhs_numbers_by_ods.assert_called_once_with( + mock_service.generate_ods_report.assert_called_once_with( ods_code="ODS123", is_pre_signed_needed=True, is_upload_to_s3_needed=True, @@ -156,10 +176,10 @@ def test_handle_api_gateway_request_no_ods_code_raises_exception(mock_service): def test_handle_api_gateway_request_invalid_ods_code_raises_exception(mock_service): event = {"httpMethod": "GET", "queryStringParameters": {"odsReportType": "PATIENT"}} request_context.authorization = { - "selected_organisation": {"org_ods_code": "ODS123"} + "selected_organisation": {"org_ods_code": "ODS123"}, } - mock_service.get_nhs_numbers_by_ods.side_effect = OdsErrorException( - "Invalid ODS code format" + mock_service.generate_ods_report.side_effect = OdsErrorException( + "Invalid ODS code format", ) with pytest.raises(OdsErrorException, match="Invalid ODS code format"): @@ -191,24 +211,28 @@ def test_handle_api_gateway_request_incorrect_report_type_raises_exception( def test_handle_manual_trigger_single_ods_code(mock_service): event = {"odsCode": "ODS123", "outputFileFormat": "pdf"} - mock_service.get_nhs_numbers_by_ods.return_value = None + mock_service.generate_ods_report.return_value = None expected = ApiGatewayResponse( - 200, "Successfully created report", "GET" + 200, + "Successfully created report", + "GET", ).create_api_gateway_response() result = handle_manual_trigger(event) assert result == expected - assert mock_service.get_nhs_numbers_by_ods.call_count == 1 - mock_service.get_nhs_numbers_by_ods.assert_called_once_with( - ods_code="ODS123", file_type_output="pdf", is_upload_to_s3_needed=True + assert mock_service.generate_ods_report.call_count == 1 + mock_service.generate_ods_report.assert_called_once_with( + ods_code="ODS123", + file_type_output="pdf", + is_upload_to_s3_needed=True, ) def test_handle_manual_trigger_invalid_ods_code_format(mock_service): event = {"odsCode": "ODS123,ODS456,ODS789"} - mock_service.get_nhs_numbers_by_ods.side_effect = OdsErrorException( - "Invalid ODS code format" + mock_service.generate_ods_report.side_effect = OdsErrorException( + "Invalid ODS code format", ) with pytest.raises(OdsErrorException, match="Invalid ODS code format"): diff --git a/lambdas/tests/unit/services/test_ods_report_service.py b/lambdas/tests/unit/services/test_ods_report_service.py index 2740f83278..2fb4d6f954 100644 --- a/lambdas/tests/unit/services/test_ods_report_service.py +++ b/lambdas/tests/unit/services/test_ods_report_service.py @@ -1,9 +1,13 @@ import os import tempfile -from datetime import datetime +from datetime import datetime, timezone from unittest.mock import call import pytest +from freezegun import freeze_time +from openpyxl.reader.excel import load_workbook +from pypdf import PdfReader + from enums.document_review_reason import DocumentReviewReason from enums.document_review_status import DocumentReviewStatus from enums.dynamo_filter import AttributeOperator @@ -11,13 +15,10 @@ from enums.lambda_error import LambdaError from enums.metadata_field_names import DocumentReferenceMetadataFields from enums.patient_ods_inactive_status import PatientOdsInactiveStatus -from freezegun import freeze_time from models.document_review import ( DocumentReviewFileDetails, DocumentUploadReviewReference, ) -from openpyxl.reader.excel import load_workbook -from pypdf import PdfReader from services.ods_report_service import OdsReportService from utils.common_query_filters import NotDeleted from utils.dynamo_query_filter_builder import DynamoQueryFilterBuilder @@ -53,7 +54,8 @@ def mocked_pcse_context(mocker): "repository_role": "PCSE", } yield mocker.patch( - "services.ods_report_service.request_context", mocked_pcse_context + "services.ods_report_service.request_context", + mocked_pcse_context, ) @@ -67,8 +69,9 @@ def mock_review_result(): upload_date=int(datetime.now().timestamp()), files=[ DocumentReviewFileDetails( - file_name="mock_file_name", file_location="mock_file_location" - ) + file_name="mock_file_name", + file_location="mock_file_location", + ), ], custodian="mock_custodian", ) @@ -125,40 +128,76 @@ def mock_create_review_csv_report(mocker, ods_report_service): def test_get_nhs_numbers_by_ods( - ods_report_service, mock_query_table_by_index, mock_create_and_save_ods_report + ods_report_service, + mock_query_table_by_index, + mock_create_and_save_ods_report, + mocker, ): mock_query_table_by_index.return_value = [ {DocumentReferenceMetadataFields.NHS_NUMBER.value: "NHS123"}, {DocumentReferenceMetadataFields.NHS_NUMBER.value: "NHS456"}, ] - ods_report_service.get_nhs_numbers_by_ods("ODS123") + patient_rows = { + "NHS123": {"nhs_number": "NHS123", "created": None, "last_updated": None}, + "NHS456": {"nhs_number": "NHS456", "created": None, "last_updated": None}, + } + mocker.patch.object( + ods_report_service, + "build_patient_rows", + return_value=patient_rows, + ) + + ods_report_service.generate_ods_report("ODS123") mock_query_table_by_index.assert_called_once_with("ODS123") mock_create_and_save_ods_report.assert_called_once_with( - "ODS123", {"NHS123", "NHS456"}, False, False, "csv" + ods_code="ODS123", + patient_rows=patient_rows, + create_pre_signed_url=False, + upload_to_s3=False, + file_type_output=FileType.CSV, ) def test_get_nhs_numbers_by_ods_with_temp_folder( - ods_report_service, mock_query_table_by_index, mock_create_and_save_ods_report + ods_report_service, + mock_query_table_by_index, + mock_create_and_save_ods_report, + mocker, ): mock_query_table_by_index.return_value = [ {DocumentReferenceMetadataFields.NHS_NUMBER.value: "NHS123"}, {DocumentReferenceMetadataFields.NHS_NUMBER.value: "NHS456"}, ] - ods_report_service.get_nhs_numbers_by_ods("ODS123", is_upload_to_s3_needed=True) + patient_rows = { + "NHS123": {"nhs_number": "NHS123", "created": None, "last_updated": None}, + "NHS456": {"nhs_number": "NHS456", "created": None, "last_updated": None}, + } + mocker.patch.object( + ods_report_service, + "build_patient_rows", + return_value=patient_rows, + ) + + ods_report_service.generate_ods_report("ODS123", is_upload_to_s3_needed=True) mock_query_table_by_index.assert_called_once_with("ODS123") mock_create_and_save_ods_report.assert_called_once_with( - "ODS123", {"NHS123", "NHS456"}, False, True, "csv" + ods_code="ODS123", + patient_rows=patient_rows, + create_pre_signed_url=False, + upload_to_s3=True, + file_type_output=FileType.CSV, ) assert ods_report_service.temp_output_dir != "" def test_scan_table_with_filter( - ods_report_service, mocked_context, mock_dynamo_service_scan_table + ods_report_service, + mocked_context, + mock_dynamo_service_scan_table, ): mock_dynamo_service_scan_table.return_value = [ {DocumentReferenceMetadataFields.NHS_NUMBER.value: "NHS123"}, @@ -173,7 +212,9 @@ def test_scan_table_with_filter( def test_scan_table_with_filter_no_results( - ods_report_service, mocked_context, mock_dynamo_service_scan_table + ods_report_service, + mocked_context, + mock_dynamo_service_scan_table, ): mock_dynamo_service_scan_table.return_value = [] @@ -187,22 +228,31 @@ def test_create_and_save_ods_report_create_csv( mock_create_report_csv, mock_save_report_to_s3, mock_get_pre_signed_url, + mocker, ): ods_code = "ODS123" - nhs_numbers = {"NHS123", "NHS456"} + patient_rows = { + "NHS123": {"nhs_number": "NHS123", "created": None, "last_updated": None}, + "NHS456": {"nhs_number": "NHS456", "created": None, "last_updated": None}, + } file_name = "LloydGeorgeSummary_ODS123_2_2024-01-01_12-00.csv" temp_file_path = os.path.join(ods_report_service.temp_output_dir, file_name) - result = ods_report_service.create_and_save_ods_report( - ods_code, nhs_numbers, upload_to_s3=True, file_type_output=FileType.CSV + mocker.patch.object( + ods_report_service, + "create_ods_report", + return_value=(file_name, temp_file_path), ) - mock_create_report_csv.assert_called_once_with( - temp_file_path, nhs_numbers, ods_code + result = ods_report_service.create_and_save_ods_report( + ods_code, + patient_rows, + upload_to_s3=True, + file_type_output=FileType.CSV, ) + mock_save_report_to_s3.assert_called_once_with(ods_code, file_name, temp_file_path) mock_get_pre_signed_url.assert_not_called() - assert result is None @@ -212,19 +262,29 @@ def test_create_and_save_ods_report_create_pdf( mock_create_report_pdf, mock_save_report_to_s3, mock_get_pre_signed_url, + mocker, ): ods_code = "ODS123" - nhs_numbers = {"NHS123", "NHS456"} + patient_rows = { + "NHS123": {"nhs_number": "NHS123", "created": None, "last_updated": None}, + "NHS456": {"nhs_number": "NHS456", "created": None, "last_updated": None}, + } file_name = "LloydGeorgeSummary_ODS123_2_2024-01-01_12-00.pdf" temp_file_path = os.path.join(ods_report_service.temp_output_dir, file_name) - ods_report_service.create_and_save_ods_report( - ods_code, nhs_numbers, upload_to_s3=True, file_type_output=FileType.PDF + mocker.patch.object( + ods_report_service, + "create_ods_report", + return_value=(file_name, temp_file_path), ) - mock_create_report_pdf.assert_called_once_with( - temp_file_path, nhs_numbers, ods_code + ods_report_service.create_and_save_ods_report( + ods_code, + patient_rows, + upload_to_s3=True, + file_type_output=FileType.PDF, ) + mock_save_report_to_s3.assert_called_once_with(ods_code, file_name, temp_file_path) mock_get_pre_signed_url.assert_not_called() @@ -235,19 +295,29 @@ def test_create_and_save_ods_report_create_xlsx( mock_create_report_xlsx, mock_save_report_to_s3, mock_get_pre_signed_url, + mocker, ): ods_code = "ODS123" - nhs_numbers = {"NHS123", "NHS456"} + patient_rows = { + "NHS123": {"nhs_number": "NHS123", "created": None, "last_updated": None}, + "NHS456": {"nhs_number": "NHS456", "created": None, "last_updated": None}, + } file_name = "LloydGeorgeSummary_ODS123_2_2024-01-01_12-00.xlsx" temp_file_path = os.path.join(ods_report_service.temp_output_dir, file_name) - ods_report_service.create_and_save_ods_report( - ods_code, nhs_numbers, upload_to_s3=True, file_type_output=FileType.XLSX + mocker.patch.object( + ods_report_service, + "create_ods_report", + return_value=(file_name, temp_file_path), ) - mock_create_report_xlsx.assert_called_once_with( - temp_file_path, nhs_numbers, ods_code + ods_report_service.create_and_save_ods_report( + ods_code, + patient_rows, + upload_to_s3=True, + file_type_output=FileType.XLSX, ) + mock_save_report_to_s3.assert_called_once_with(ods_code, file_name, temp_file_path) mock_get_pre_signed_url.assert_not_called() @@ -259,11 +329,15 @@ def test_create_and_save_ods_report_send_invalid_file_type( mock_get_pre_signed_url, ): ods_code = "ODS123" - nhs_numbers = {"NHS123", "NHS456"} + patient_rows = { + "NHS123": {"nhs_number": "NHS123", "created": None, "last_updated": None}, + "NHS456": {"nhs_number": "NHS456", "created": None, "last_updated": None}, + } + with pytest.raises(OdsReportException): ods_report_service.create_and_save_ods_report( ods_code, - nhs_numbers, + patient_rows, upload_to_s3=True, create_pre_signed_url=True, file_type_output="invalid", @@ -280,92 +354,107 @@ def test_create_and_save_ods_report_with_pre_sign_url( mock_create_report_csv, mock_save_report_to_s3, mock_get_pre_signed_url, + mocker, ): ods_code = "ODS123" - nhs_numbers = {"NHS123", "NHS456"} + patient_rows = { + "NHS123": {"nhs_number": "NHS123", "created": None, "last_updated": None}, + "NHS456": {"nhs_number": "NHS456", "created": None, "last_updated": None}, + } file_name = "LloydGeorgeSummary_ODS123_2_2024-01-01_12-00.csv" mock_pre_sign_url = "https://presigned.url" + temp_file_path = os.path.join(ods_report_service.temp_output_dir, file_name) mock_get_pre_signed_url.return_value = mock_pre_sign_url - temp_file_path = os.path.join(ods_report_service.temp_output_dir, file_name) + mocker.patch.object( + ods_report_service, + "create_ods_report", + return_value=(file_name, temp_file_path), + ) result = ods_report_service.create_and_save_ods_report( - ods_code, nhs_numbers, True, True + ods_code, + patient_rows, + True, + True, + FileType.CSV, ) - mock_create_report_csv.assert_called_once_with( - temp_file_path, nhs_numbers, ods_code - ) mock_save_report_to_s3.assert_called_once_with(ods_code, file_name, temp_file_path) mock_get_pre_signed_url.assert_called_once_with(ods_code, file_name) assert result == mock_pre_sign_url def test_create_report_csv(ods_report_service, tmp_path): - nhs_numbers = {"NHS123", "NHS456"} + patient_rows = { + "NHS123": {"nhs_number": "NHS123", "created": None, "last_updated": None}, + "NHS456": {"nhs_number": "NHS456", "created": None, "last_updated": None}, + } file_name = tmp_path / "test_report.csv" ods_code = "ODS123" - ods_report_service.create_csv_report(str(file_name), nhs_numbers, ods_code) + ods_report_service.create_csv_report(str(file_name), patient_rows, ods_code) with open(file_name, "r") as f: content = f.readlines() assert ( - f"Total number of patients for ODS code {ods_code}: {len(nhs_numbers)}\n" + f"Total number of patients for ODS code {ods_code}: {len(patient_rows)}\n" in content ) - assert "NHS Numbers:\n" in content - assert "NHS123\n" in content - assert "NHS456\n" in content + assert "NHS Number,Latest Created Date,Latest Updated Date\n" in content def test_create_xlsx_report(ods_report_service, tmp_path): - file_name = "test_report.xlsx" - nhs_numbers = {"NHS123456", "NHS654321", "NHS111222"} + file_name = tmp_path / "test_report.xlsx" + patient_rows = { + "NHS123456": {"nhs_number": "NHS123456", "created": None, "last_updated": None}, + "NHS654321": {"nhs_number": "NHS654321", "created": None, "last_updated": None}, + "NHS111222": {"nhs_number": "NHS111222", "created": None, "last_updated": None}, + } ods_code = "ODS123" - ods_report_service.create_xlsx_report(file_name, nhs_numbers, ods_code) + ods_report_service.create_xlsx_report(str(file_name), patient_rows, ods_code) assert os.path.exists(file_name) - wb = load_workbook(file_name) + wb = load_workbook(str(file_name)) ws = wb.active assert ( ws["A1"].value - == f"Total number of patients for ODS code {ods_code}: {len(nhs_numbers)}\n" + == f"Total number of patients for ODS code {ods_code}: {len(patient_rows)}\n" ) - assert ws["A2"].value == "NHS Numbers:\n" - for i, nhs_number in enumerate(nhs_numbers, start=3): # Start from row 3 + nhs_sorted = sorted(patient_rows.keys()) + for i, nhs_number in enumerate(nhs_sorted, start=3): assert ws.cell(row=i, column=1).value == nhs_number - os.remove(file_name) - @freeze_time("2024-01-01T12:00:00Z") -def test_create_pdf_report(ods_report_service): - file_name = "test_report.pdf" - nhs_numbers = {"NHS123456", "NHS654321", "NHS111222"} +def test_create_pdf_report(ods_report_service, tmp_path): + file_name = tmp_path / "test_report.pdf" + patient_rows = { + "NHS123456": {"nhs_number": "NHS123456", "created": None, "last_updated": None}, + "NHS654321": {"nhs_number": "NHS654321", "created": None, "last_updated": None}, + "NHS111222": {"nhs_number": "NHS111222", "created": None, "last_updated": None}, + } ods_code = "ODS123" - ods_report_service.create_pdf_report(file_name, nhs_numbers, ods_code) + ods_report_service.create_pdf_report(str(file_name), patient_rows, ods_code) assert os.path.exists(file_name) - reader = PdfReader(file_name) + reader = PdfReader(str(file_name)) assert len(reader.pages) > 0 - first_page = reader.pages[0].extract_text() + first_page = reader.pages[0].extract_text() or "" assert f"NHS numbers within NDR for ODS code: {ods_code}" in first_page - assert f"Total number of patients: {len(nhs_numbers)}" in first_page - for nhs_number in nhs_numbers: + assert f"Total number of patients: {len(patient_rows)}" in first_page + for nhs_number in patient_rows.keys(): assert nhs_number in first_page - os.remove(file_name) - @freeze_time("2024-01-01T12:00:00Z") def test_save_report_to_s3(ods_report_service, mocker): @@ -403,14 +492,15 @@ def test_get_documents_for_review( mocker, ): expected_result = "https://example.com/mocked" - mock_get_pre_signed_url.return_value = "https://example.com/mocked" + mock_get_pre_signed_url.return_value = expected_result mock_ods_code = "ODS123" query_builder = DynamoQueryFilterBuilder() query_builder.add_condition( - "ReviewStatus", AttributeOperator.EQUAL, DocumentReviewStatus.PENDING_REVIEW + "ReviewStatus", + AttributeOperator.EQUAL, + DocumentReviewStatus.PENDING_REVIEW, ) - expected_query_filter = query_builder.build() mocker.patch.object( @@ -425,7 +515,7 @@ def test_get_documents_for_review( return_value=expected_query_filter, ) - result = ods_report_service.get_documents_for_review(mock_ods_code, "csv") + result = ods_report_service.get_documents_for_review(mock_ods_code, FileType.CSV) assert result == expected_result @@ -441,9 +531,8 @@ def test_get_documents_for_review( def test_get_documents_for_review_unsupported_file_type(ods_report_service): - with pytest.raises(OdsReportException) as excinfo: - ods_report_service.get_documents_for_review("mock_ods_code", "pdf") + ods_report_service.get_documents_for_review("mock_ods_code", FileType.PDF) assert excinfo.value.error == LambdaError.UnsupportedFileType assert excinfo.value.status_code == 400 @@ -466,22 +555,6 @@ def test_create_review_csv_report(ods_report_service, mock_review_result, tmp_pa == "nhs_number,review_reason,document_snomed_code_type,author,upload_date\n" ) - for i in range(1, len(content)): - assert content[i] == ( - mock_review_result.nhs_number - + "," - + mock_review_result.review_reason - + "," - + mock_review_result.document_snomed_code_type - + "," - + mock_review_result.author - + "," - + datetime.fromtimestamp(mock_review_result.upload_date).isoformat() - + "\n" - ) - - os.remove(file_name) - def test_query_table_by_index(ods_report_service, mocked_context, set_env): mock_dynamo_results = [{"mock_database_field": "mock_database_value"}] @@ -503,7 +576,9 @@ def test_query_table_by_index(ods_report_service, mocked_context, set_env): def test_query_table_by_index_pcse_user( - ods_report_service, mocked_pcse_context, set_env + ods_report_service, + mocked_pcse_context, + set_env, ): mock_dynamo_results = [{"mock_database_field": "mock_database_value"}] mock_ods_code = "ODS123" @@ -532,7 +607,7 @@ def test_query_table_by_index_pcse_user( search_condition=PatientOdsInactiveStatus.DECEASED, query_filter=NotDeleted, ), - ] + ], ) @@ -552,40 +627,123 @@ def test_create_and_save_ods_report_dont_save_to_s3( ods_report_service, mock_save_report_to_s3, mock_get_pre_signed_url, - mock_create_report_csv, + mocker, ): + mocker.patch.object( + ods_report_service, + "create_ods_report", + return_value=("x.csv", "/tmp/x.csv"), + ) + ods_report_service.create_and_save_ods_report( ods_code="ODS123", - data=["mock_nhs_number"], + patient_rows={ + "mock_nhs_number": { + "nhs_number": "mock_nhs_number", + "created": None, + "last_updated": None, + }, + }, create_pre_signed_url=False, upload_to_s3=False, file_type_output=FileType.CSV, ) - mock_create_report_csv.assert_called_once() - mock_save_report_to_s3.assert_not_called() mock_get_pre_signed_url.assert_not_called() -def test_create_pdf_report_multiple_pages(ods_report_service): - file_name = "test_report.pdf" - nhs_numbers = {"NHS123456", "NHS654321", "NHS111222"} - ods_code = "ODS123" +def test_create_pdf_report_multiple_pages(ods_report_service, tmp_path): + file_name = tmp_path / "test_report.pdf" + patient_rows = {} - for i in range(1, 100): - nhs_numbers.add(f"NHS123456{str(i)}") + for i in range(1, 150): + nhs = f"NHS123456{str(i)}" + patient_rows[nhs] = {"nhs_number": nhs, "created": None, "last_updated": None} - ods_report_service.create_pdf_report(file_name, nhs_numbers, ods_code) + ods_report_service.create_pdf_report(str(file_name), patient_rows, "ODS123") assert os.path.exists(file_name) - reader = PdfReader(file_name) + reader = PdfReader(str(file_name)) assert len(reader.pages) > 1 - first_page = reader.pages[0].extract_text() - assert f"NHS numbers within NDR for ODS code: {ods_code}" in first_page - assert f"Total number of patients: {len(nhs_numbers)}" in first_page +def test_build_patient_rows_skips_items_without_nhs_number(ods_report_service): + items = [ + { + DocumentReferenceMetadataFields.CREATED.value: "2026-02-25T12:50:35.000000Z", + DocumentReferenceMetadataFields.LAST_UPDATED.value: 1700000000, + }, + { + DocumentReferenceMetadataFields.NHS_NUMBER.value: "9730786917", + DocumentReferenceMetadataFields.CREATED.value: "2026-02-25T12:50:35.000000Z", + DocumentReferenceMetadataFields.LAST_UPDATED.value: 1700000001, + }, + ] + + rows = ods_report_service.build_patient_rows(items) + + assert list(rows.keys()) == ["9730786917"] + assert rows["9730786917"]["nhs_number"] == "9730786917" + + +def test_build_patient_rows_dedupes_and_picks_earliest_created_and_latest_last_updated( + ods_report_service, +): + nhs = "9730786917" + + items = [ + { + DocumentReferenceMetadataFields.NHS_NUMBER.value: nhs, + DocumentReferenceMetadataFields.CREATED.value: "2026-02-25T12:50:40.000000Z", + DocumentReferenceMetadataFields.LAST_UPDATED.value: 1700000010, + }, + { + DocumentReferenceMetadataFields.NHS_NUMBER.value: nhs, + DocumentReferenceMetadataFields.CREATED.value: "2026-02-25T12:50:35.000000Z", + DocumentReferenceMetadataFields.LAST_UPDATED.value: 1700000020, + }, + { + DocumentReferenceMetadataFields.NHS_NUMBER.value: "9730786933", + DocumentReferenceMetadataFields.CREATED.value: "2026-02-25T12:50:36.000000Z", + DocumentReferenceMetadataFields.LAST_UPDATED.value: 1700000030, + }, + ] + + rows = ods_report_service.build_patient_rows(items) + + assert set(rows.keys()) == {nhs, "9730786933"} + + expected_created = datetime(2026, 2, 25, 12, 50, 40, tzinfo=timezone.utc) + expected_last_updated = datetime.fromtimestamp(1700000020, tz=timezone.utc) + + assert rows[nhs]["latest_created_date"] == expected_created + assert rows[nhs]["latest_updated_date"] == expected_last_updated + + +def test_build_patient_rows_does_not_overwrite_existing_values_with_none( + ods_report_service, +): + nhs = "9730786917" + + items = [ + { + DocumentReferenceMetadataFields.NHS_NUMBER.value: nhs, + DocumentReferenceMetadataFields.CREATED.value: "2026-02-25T12:50:35.000000Z", + DocumentReferenceMetadataFields.LAST_UPDATED.value: 1700000010, + }, + { + DocumentReferenceMetadataFields.NHS_NUMBER.value: nhs, + DocumentReferenceMetadataFields.CREATED.value: None, + DocumentReferenceMetadataFields.LAST_UPDATED.value: None, + }, + ] + + rows = ods_report_service.build_patient_rows(items) + + expected_created = datetime(2026, 2, 25, 12, 50, 35, tzinfo=timezone.utc) + expected_last_updated = datetime.fromtimestamp(1700000010, tz=timezone.utc) - os.remove(file_name) + assert rows[nhs]["latest_created_date"] == expected_created + assert rows[nhs]["latest_updated_date"] == expected_last_updated diff --git a/lambdas/tests/unit/utils/test_utilities.py b/lambdas/tests/unit/utils/test_utilities.py index 04c45d4dcd..777b01c275 100755 --- a/lambdas/tests/unit/utils/test_utilities.py +++ b/lambdas/tests/unit/utils/test_utilities.py @@ -1,15 +1,19 @@ -from datetime import datetime +from datetime import datetime, timedelta, timezone import pytest + from services.mock_pds_service import MockPdsApiService from services.pds_api_service import PdsApiService from utils.exceptions import InvalidNhsNumberException from utils.utilities import ( camelize_dict, + datetime_to_utc_iso_string, + epoch_seconds_to_datetime_utc, flatten, format_cloudfront_url, get_file_key_from_s3_url, get_pds_service, + iso_utc_string_to_datetime, parse_date, redact_id_to_last_4_chars, utc_date_string, @@ -153,3 +157,87 @@ def test_utc_date_string_returns_correct_utc_date( expected_date_string, ): assert utc_date_string(timestamp_seconds) == expected_date_string + + +@pytest.mark.parametrize( + "value", + [ + None, + "", + " ", + ], +) +def test_iso_utc_string_to_datetime_returns_none_for_empty_or_none(value): + assert iso_utc_string_to_datetime(value) is None + + +def test_iso_utc_string_to_datetime_parses_z_suffix(): + result = iso_utc_string_to_datetime("2025-03-11T16:26:44.520811Z") + + assert result == datetime(2025, 3, 11, 16, 26, 44, 520811, tzinfo=timezone.utc) + + +def test_iso_utc_string_to_datetime_normalises_offset_to_utc(): + result = iso_utc_string_to_datetime("2025-03-11T18:26:44+02:00") + + assert result == datetime(2025, 3, 11, 16, 26, 44, tzinfo=timezone.utc) + + +def test_iso_utc_string_to_datetime_naive_datetime_assumes_utc(): + result = iso_utc_string_to_datetime("2025-03-11T16:26:44") + + assert result == datetime(2025, 3, 11, 16, 26, 44, tzinfo=timezone.utc) + + +def test_iso_utc_string_to_datetime_invalid_string_returns_none(): + assert iso_utc_string_to_datetime("not-a-date") is None + + +@pytest.mark.parametrize( + "value, expected", + [ + (0, datetime(1970, 1, 1, tzinfo=timezone.utc)), + ("1704067200", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ], +) +def test_epoch_seconds_to_datetime_utc_valid(value, expected): + assert epoch_seconds_to_datetime_utc(value) == expected + + +@pytest.mark.parametrize( + "value", + [ + None, + "abc", + "123.45", + {}, + [], + ], +) +def test_epoch_seconds_to_datetime_utc_invalid_returns_none(value): + assert epoch_seconds_to_datetime_utc(value) is None + + +def test_datetime_to_utc_iso_string_none_returns_empty_string(): + assert datetime_to_utc_iso_string(None) == "" + + +def test_datetime_to_utc_iso_string_naive_datetime_assumes_utc(): + dt = datetime(2024, 1, 1, 12, 34, 56, 999999) + + assert datetime_to_utc_iso_string(dt) == "2024-01-01T12:34:56" + + +def test_datetime_to_utc_iso_string_converts_timezone_and_drops_microseconds(): + dt = datetime( + 2024, + 1, + 1, + 12, + 0, + 0, + 123456, + tzinfo=timezone(timedelta(hours=2)), + ) + + assert datetime_to_utc_iso_string(dt) == "2024-01-01T10:00:00" diff --git a/lambdas/utils/utilities.py b/lambdas/utils/utilities.py index 17bc899180..5ecc864828 100755 --- a/lambdas/utils/utilities.py +++ b/lambdas/utils/utilities.py @@ -1,3 +1,4 @@ +# lambdas/utils/utilities.py import itertools import os import re @@ -6,6 +7,7 @@ from urllib.parse import urlparse from inflection import camelize + from services.base.nhs_oauth_service import NhsOauthService from services.base.ssm_service import SSMService from services.mock_pds_service import MockPdsApiService @@ -145,3 +147,89 @@ def utc_day_start_timestamp(day: date) -> int: def utc_day_end_timestamp(day: date) -> int: return utc_day_start_timestamp(day) + 24 * 60 * 60 - 1 + + +ISO_UTC_SUFFIX = "Z" + + +def iso_utc_string_to_datetime(value: str | None) -> datetime | None: + """ + Convert an ISO-8601 UTC string (ending with 'Z') to a timezone-aware datetime. + + Examples: + "2025-03-11T16:26:44.520811Z" -> datetime(2025, 3, 11, 16, 26, 44, tzinfo=UTC) + None -> None + """ + if value is None: + return None + + value = value.strip() + if not value: + return None + + try: + if value.endswith(ISO_UTC_SUFFIX): + value = value[:-1] + "+00:00" + + dt = datetime.fromisoformat(value) + + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + + return dt.astimezone(timezone.utc) + + except ValueError: + return None + + +def epoch_seconds_to_datetime_utc(value: int | str | None) -> datetime | None: + """ + Convert epoch seconds to a UTC datetime. + + Accepts: + - int (epoch seconds) + - str containing digits (epoch seconds) + - None + + Returns: + - timezone-aware UTC datetime, or None + """ + if value is None: + return None + + try: + seconds = int(value) + except (TypeError, ValueError): + return None + + try: + return datetime.fromtimestamp(seconds, tz=timezone.utc) + except (OverflowError, OSError): + return None + + +def datetime_to_utc_iso_string(value: datetime | None) -> str: + """ + Convert a datetime to an ISO-8601 string with second-level precision. + + Accepts: + - datetime (naive or timezone-aware) + - None + + Behaviour: + - If the datetime is timezone-aware, it is converted to UTC and made naive + - If the datetime is naive, it is used as-is + - Microseconds are discarded + - No timezone suffix (e.g. 'Z') is included + + Returns: + - ISO-8601 formatted string: "YYYY-MM-DDTHH:MM:SS" + - Empty string ("") if input is None + """ + if value is None: + return "" + + if value.tzinfo is not None: + value = value.astimezone(timezone.utc).replace(tzinfo=None) + + return value.replace(microsecond=0).isoformat(timespec="seconds")