11from __future__ import annotations
22
33import csv
4- from datetime import datetime , timedelta
4+ from datetime import date , datetime
55from decimal import Decimal
66from enum import StrEnum
77from io import BytesIO , StringIO
1313from cc_common .data_model .schema .compact .common import COMPACT_TYPE
1414from cc_common .data_model .schema .jurisdiction .common import JURISDICTION_TYPE
1515from cc_common .exceptions import CCInternalException
16+ from report_window import ReportCycle , ReportWindow
1617
1718
1819class ReportableTransactionStatuses (StrEnum ):
@@ -21,73 +22,10 @@ class ReportableTransactionStatuses(StrEnum):
2122 SettledSuccessfully = 'settledSuccessfully'
2223
2324
24- def _get_display_date_range (reporting_cycle : str ) -> tuple [datetime , datetime ]:
25- """Get the display date range for reports.
26-
27- These dates are used for report filenames and email notifications.
28-
29- :param reporting_cycle: Either 'weekly' or 'monthly'
30- :return: Tuple of (start_time, end_time) in UTC for display purposes
31- """
32- if reporting_cycle == 'weekly' :
33- end_time = config .current_standard_datetime
34- # Go back 7 days to capture the full week
35- start_time = end_time - timedelta (days = 7 )
36- return start_time , end_time
37- if reporting_cycle == 'monthly' :
38- # Reports run on the first day of the month.
39- # Knowing this, we can use the current date to get the start and end of the month.
40- # By going back 1 day from the first day of the current month, we get the last day of the previous month.
41- end_time = config .current_standard_datetime .replace (
42- day = 1 , hour = 0 , minute = 0 , second = 0 , microsecond = 0
43- ) - timedelta (days = 1 )
44- # Start time is the first day of the previous month
45- start_time = end_time .replace (day = 1 )
46- return start_time , end_time
47- raise ValueError (f'Invalid reporting cycle: { reporting_cycle } ' )
48-
49-
50- def _get_query_date_range (reporting_cycle : str ) -> tuple [datetime , datetime ]:
51- """Get the query date range for DynamoDB queries.
52-
53- Our Sort Key format for transactions includes additional components after the timestamp
54- (COMPACT#name#TIME#timestamp#BATCH#id#TX#id), So the DynamoDB BETWEEN condition is INCLUSIVE for the beginning
55- range and EXCLUSIVE at the end range. This is because DynamoDB performs lexicographical comparison on the entire
56- sort key string. When the sort key continues beyond the comparison value:
57-
58- - For the lower bound: Additional characters after the comparison point make the full key "greater than" the bound,
59- satisfying the >= condition
60- - For the upper bound: Additional characters after the comparison point make the full key "greater than" the bound,
61- failing the <= condition
62-
63- We need to adjust our timestamps accordingly to ensure we capture all settled transactions exactly once.
64-
65- :param reporting_cycle: Either 'weekly' or 'monthly'
66- :return: Tuple of (start_time, end_time) in UTC for DynamoDB queries
67- """
68- if reporting_cycle == 'weekly' :
69- # Reports run on Friday 10:00 PM UTC
70- end_time = config .current_standard_datetime .replace (hour = 22 , minute = 0 , second = 0 , microsecond = 0 )
71- # Go back 7 days to capture the full week
72- start_time = end_time - timedelta (days = 7 )
73- return start_time , end_time
74-
75- if reporting_cycle == 'monthly' :
76- # Reports run on the first day of the month
77- # End time is midnight, since that will be excluded from the BETWEEN key condition
78- end_time = config .current_standard_datetime .replace (hour = 0 , minute = 0 , second = 0 , microsecond = 0 )
79- # Start time is midnight of the previous month
80- start_time = (end_time - timedelta (days = 1 )).replace (day = 1 , hour = 0 , minute = 0 , second = 0 , microsecond = 0 )
81- return start_time , end_time
82-
83- raise ValueError (f'Invalid reporting cycle: { reporting_cycle } ' )
84-
85-
8625def _store_compact_reports_in_s3 (
8726 compact : str ,
8827 reporting_cycle : str ,
89- start_time : datetime ,
90- end_time : datetime ,
28+ report_window : ReportWindow ,
9129 summary_report : str ,
9230 transaction_detail : str ,
9331 bucket_name : str ,
@@ -96,33 +34,31 @@ def _store_compact_reports_in_s3(
9634
9735 :param compact: Compact name
9836 :param reporting_cycle: Either 'weekly' or 'monthly'
99- :param start_time: Report start time
100- :param end_time: Report end time
37+ :param report_window: the Report Window
10138 :param summary_report: Financial summary report CSV content
10239 :param transaction_detail: Transaction detail report CSV content
10340 :param bucket_name: S3 bucket name
10441 :return: Dictionary of file types to their S3 paths
10542 """
106- date_range = f'{ start_time .strftime ("%Y-%m-%d" )} --{ end_time .strftime ("%Y-%m-%d" )} '
10743 base_path = (
10844 f'compact/{ compact } /reports/compact-transactions/reporting-cycle/{ reporting_cycle } /'
109- f'{ end_time .strftime ("%Y/%m/%d" )} '
45+ f'{ report_window . display_end .strftime ("%Y/%m/%d" )} '
11046 )
11147
11248 # Define paths for all report files
11349 # Currently, we are only sending the .zip file in the email reporting, but there is potential
11450 # to store .gz files in the future
11551 paths = {
116- 'report_zip' : f'{ base_path } /{ compact } -{ date_range } -report.zip' ,
52+ 'report_zip' : f'{ base_path } /{ compact } -{ report_window . display_text } -report.zip' ,
11753 }
11854
11955 s3_client = config .s3_client
12056
12157 # Create and store combined zip with uncompressed CSVs
12258 zip_buffer = BytesIO ()
12359 with ZipFile (zip_buffer , 'w' , compression = ZIP_DEFLATED ) as zip_file :
124- zip_file .writestr (f'financial-summary-{ date_range } .csv' , summary_report .encode ('utf-8' ))
125- zip_file .writestr (f'transaction-detail-{ date_range } .csv' , transaction_detail .encode ('utf-8' ))
60+ zip_file .writestr (f'financial-summary-{ report_window . display_text } .csv' , summary_report .encode ('utf-8' ))
61+ zip_file .writestr (f'transaction-detail-{ report_window . display_text } .csv' , transaction_detail .encode ('utf-8' ))
12662 s3_client .put_object (Bucket = bucket_name , Key = paths ['report_zip' ], Body = zip_buffer .getvalue ())
12763
12864 return paths
@@ -132,8 +68,7 @@ def _store_jurisdiction_reports_in_s3(
13268 compact : str ,
13369 jurisdiction : str ,
13470 reporting_cycle : str ,
135- start_time : datetime ,
136- end_time : datetime ,
71+ report_window : ReportWindow ,
13772 transaction_detail : str ,
13873 bucket_name : str ,
13974) -> dict [str , str ]:
@@ -142,31 +77,31 @@ def _store_jurisdiction_reports_in_s3(
14277 :param compact: Compact name
14378 :param jurisdiction: Jurisdiction postal code
14479 :param reporting_cycle: Either 'weekly' or 'monthly'
145- :param start_time: Report start time
146- :param end_time: Report end time
80+ :param report_window: The report window
14781 :param transaction_detail: Transaction detail report CSV content
14882 :param bucket_name: S3 bucket name
14983 :return: Dictionary of file types to their S3 paths
15084 """
151- date_range = f'{ start_time .strftime ("%Y-%m-%d" )} --{ end_time .strftime ("%Y-%m-%d" )} '
15285 base_path = (
15386 f'compact/{ compact } /reports/jurisdiction-transactions/jurisdiction/{ jurisdiction } /'
154- f'reporting-cycle/{ reporting_cycle } /{ end_time .strftime ("%Y/%m/%d" )} '
87+ f'reporting-cycle/{ reporting_cycle } /{ report_window . display_end .strftime ("%Y/%m/%d" )} '
15588 )
15689
15790 # Define paths for all report files
15891 # Currently, we are only sending the .zip file in the email reporting, but there is potential
15992 # to store .gz files in the future
16093 paths = {
161- 'report_zip' : f'{ base_path } /{ jurisdiction } -{ date_range } -report.zip' ,
94+ 'report_zip' : f'{ base_path } /{ jurisdiction } -{ report_window . display_text } -report.zip' ,
16295 }
16396
16497 s3_client = config .s3_client
16598
16699 # Create and store zip with uncompressed CSV
167100 zip_buffer = BytesIO ()
168101 with ZipFile (zip_buffer , 'w' , compression = ZIP_DEFLATED ) as zip_file :
169- zip_file .writestr (f'{ jurisdiction } -transaction-detail-{ date_range } .csv' , transaction_detail .encode ('utf-8' ))
102+ zip_file .writestr (
103+ f'{ jurisdiction } -transaction-detail-{ report_window .display_text } .csv' , transaction_detail .encode ('utf-8' )
104+ )
170105 s3_client .put_object (Bucket = bucket_name , Key = paths ['report_zip' ], Body = zip_buffer .getvalue ())
171106
172107 return paths
@@ -186,8 +121,26 @@ def generate_transaction_reports(event: dict, context: LambdaContext) -> dict:
186121 :return: Success message
187122 """
188123 compact = event ['compact' ]
189- reporting_cycle = event ['reportingCycle' ]
190- logger .info ('Generating transaction reports' , compact = compact , reporting_cycle = reporting_cycle )
124+ reporting_cycle = ReportCycle (event ['reportingCycle' ])
125+
126+ # Support 'manual' report date overrides for re-runs
127+ report_start_override = event .get ('reportStartOverride' )
128+ report_end_override = event .get ('reportEndOverride' )
129+ if report_start_override and report_end_override :
130+ report_window = ReportWindow (
131+ reporting_cycle ,
132+ display_start_date = date .fromisoformat (report_start_override ),
133+ display_end_date = date .fromisoformat (report_end_override ),
134+ )
135+ else :
136+ report_window = ReportWindow (reporting_cycle )
137+
138+ logger .info (
139+ 'Generating transaction reports' ,
140+ compact = compact ,
141+ reporting_cycle = reporting_cycle ,
142+ window = report_window .display_text ,
143+ )
191144
192145 # this is used to track any errors that occur when generating the reports
193146 # without preventing valid reports from being sent
@@ -216,16 +169,9 @@ def generate_transaction_reports(event: dict, context: LambdaContext) -> dict:
216169 # Get the S3 bucket name
217170 bucket_name = config .transaction_reports_bucket_name
218171
219- # Get both query and display date ranges
220- query_start_time , query_end_time = _get_query_date_range (reporting_cycle )
221-
222- # Convert query times to epochs for DynamoDB
223- start_epoch = int (query_start_time .timestamp ())
224- end_epoch = int (query_end_time .timestamp ())
225-
226172 # Get all transactions for the time period
227173 transactions = transaction_client .get_transactions_in_range (
228- compact = compact , start_epoch = start_epoch , end_epoch = end_epoch
174+ compact = compact , start_epoch = report_window . start_epoch , end_epoch = report_window . end_epoch
229175 )
230176
231177 # For now, we only report on transactions that have been successfully settled, so we filter to only include
@@ -263,14 +209,11 @@ def generate_transaction_reports(event: dict, context: LambdaContext) -> dict:
263209 compact_transaction_csv = _generate_compact_transaction_report (transactions , providers )
264210 jurisdiction_reports = _generate_jurisdiction_reports (transactions , providers , jurisdiction_configurations )
265211
266- display_start_time , display_end_time = _get_display_date_range (reporting_cycle )
267-
268212 # Store compact reports in S3 and get paths
269213 compact_paths = _store_compact_reports_in_s3 (
270214 compact = compact ,
271215 reporting_cycle = reporting_cycle ,
272- start_time = display_start_time ,
273- end_time = display_end_time ,
216+ report_window = report_window ,
274217 summary_report = compact_summary_csv ,
275218 transaction_detail = compact_transaction_csv ,
276219 bucket_name = bucket_name ,
@@ -282,8 +225,8 @@ def generate_transaction_reports(event: dict, context: LambdaContext) -> dict:
282225 compact = compact ,
283226 report_s3_path = compact_paths ['report_zip' ],
284227 reporting_cycle = reporting_cycle ,
285- start_date = display_start_time ,
286- end_date = display_end_time ,
228+ start_date = report_window . display_start ,
229+ end_date = report_window . display_end ,
287230 )
288231 except CCInternalException as e :
289232 logger .error (
@@ -300,8 +243,7 @@ def generate_transaction_reports(event: dict, context: LambdaContext) -> dict:
300243 compact = compact ,
301244 jurisdiction = jurisdiction ,
302245 reporting_cycle = reporting_cycle ,
303- start_time = display_start_time ,
304- end_time = display_end_time ,
246+ report_window = report_window ,
305247 transaction_detail = report_csv ,
306248 bucket_name = bucket_name ,
307249 )
@@ -312,8 +254,8 @@ def generate_transaction_reports(event: dict, context: LambdaContext) -> dict:
312254 jurisdiction = jurisdiction ,
313255 report_s3_path = jurisdiction_paths ['report_zip' ],
314256 reporting_cycle = reporting_cycle ,
315- start_date = display_start_time ,
316- end_date = display_end_time ,
257+ start_date = report_window . display_start ,
258+ end_date = report_window . display_end ,
317259 )
318260 except CCInternalException as e :
319261 logger .error (
0 commit comments