diff --git a/app/dao/date_util.py b/app/dao/date_util.py index d93986b45..822491f34 100644 --- a/app/dao/date_util.py +++ b/app/dao/date_util.py @@ -1,4 +1,6 @@ import calendar +import pytz + from datetime import date, datetime, time, timedelta from app.utils import utc_now @@ -93,3 +95,25 @@ def generate_date_range(start_date, end_date=None, days=0): current_date += timedelta(days=1) else: return "An end_date or number of days must be specified" + + +def build_local_and_utc_date_range( + start_date_str: str, + days: int = 7, + timezone: str = "UTC" +): + """ + Convert date to local range based on timezone + """ + + user_timezone = pytz.timezone(timezone) + local_end_date = datetime.strptime(start_date_str, "%Y-%m-%d").replace(tzinfo=user_timezone) + # Subtract (days - 1) so the entire final day is included in the range + local_start_date = local_end_date - timedelta(days=days) + + + # Convert to UTC for database queries + utc_start_date = local_start_date.astimezone(pytz.utc).replace(hour=0, minute=0, second=0) + utc_end_date = local_end_date.astimezone(pytz.utc).replace(hour=23, minute=59, second=59) + + return (local_start_date, utc_start_date, utc_end_date) diff --git a/app/dao/services_dao.py b/app/dao/services_dao.py index 4017acf75..c6ad4cdf9 100644 --- a/app/dao/services_dao.py +++ b/app/dao/services_dao.py @@ -503,7 +503,7 @@ def dao_fetch_stats_for_service_from_days_for_user( select( NotificationAllTimeView.notification_type, NotificationAllTimeView.status, - func.date_trunc("day", NotificationAllTimeView.created_at).label("day"), + func.date_trunc("day", NotificationAllTimeView.created_at).label("timestamp"), func.count(NotificationAllTimeView.id).label("count"), ) .select_from(NotificationAllTimeView) diff --git a/app/service/rest.py b/app/service/rest.py index f6fa6a0f7..09f0638cc 100644 --- a/app/service/rest.py +++ b/app/service/rest.py @@ -20,7 +20,7 @@ from app.dao.api_key_dao import ( save_model_api_key, ) from app.dao.dao_utils import dao_rollback, transaction -from app.dao.date_util import get_calendar_year, get_month_start_and_end_date_in_utc +from app.dao.date_util import build_local_and_utc_date_range, get_calendar_year, get_month_start_and_end_date_in_utc from app.dao.fact_notification_status_dao import ( fetch_monthly_template_usage_for_service, fetch_notification_status_for_service_by_month, @@ -221,72 +221,65 @@ def get_service_notification_statistics(service_id): @service_blueprint.route("//statistics//") def get_service_notification_statistics_by_day(service_id, start, days): - # Allow timezone override, default to UTC user_timezone = request.args.get("timezone", "UTC") + return jsonify( data=get_service_statistics_for_specific_days(service_id, start, int(days), user_timezone) ) -def convert_local_to_utc(local_dt, user_timezone="America/New_York"): - local_timezone = pytz.timezone(user_timezone) - if local_dt.tzinfo is None: - localized_dt = local_timezone.localize(local_dt) - else: - localized_dt = local_dt.astimezone(local_timezone) - - return localized_dt.astimezone(pytz.utc) - - def get_service_statistics_for_specific_days(service_id, start, days=7, timezone="UTC"): - user_timezone = pytz.timezone(timezone) - local_end_date = datetime.strptime(start, "%Y-%m-%d").replace(tzinfo=user_timezone) - local_start_date = local_end_date - timedelta(days=days - 1) - - utc_start_date = local_start_date.astimezone(pytz.utc).replace(hour=0, minute=0, second=0) - utc_end_date = local_end_date.astimezone(pytz.utc).replace(hour=23, minute=59, second=59) - - now_utc = datetime.utcnow().replace(tzinfo=pytz.utc) - now_local = now_utc.astimezone(user_timezone) - - # If the local day hasn't fully ended in UTC yet, extend the end date to the next day - if now_local.date() == local_end_date.date() and now_utc.date() > now_local.date(): - utc_end_date += timedelta(days=1) + ( + local_start_date, + utc_start_date, + utc_end_date + ) = build_local_and_utc_date_range(start, days, timezone) results = dao_fetch_stats_for_service_from_days(service_id, utc_start_date, utc_end_date) - stats = get_specific_days_stats(results, utc_start_date, days=days, timezone=timezone) + stats = get_specific_days_stats( + data=results, + start_date=local_start_date, + days=days, + timezone=timezone + ) return stats - @service_blueprint.route( "//statistics/user///" ) -def get_service_notification_statistics_by_day_by_user( - service_id, user_id, start, days -): +def get_service_notification_statistics_by_day_by_user(service_id, user_id, start, days): + user_timezone = request.args.get('timezone', 'UTC') return jsonify( data=get_service_statistics_for_specific_days_by_user( - service_id, user_id, start, int(days) + service_id, user_id, start, int(days), timezone=user_timezone ) ) def get_service_statistics_for_specific_days_by_user( - service_id, user_id, start, days=1 + service_id, user_id, start, days=1, timezone="UTC" ): - # start and end dates needs to be reversed because - # the end date is today and the start is x days in the past - # a day needs to be substracted to allow for today - end_date = datetime.strptime(start, "%Y-%m-%d") - start_date = end_date - timedelta(days=days - 1) + ( + local_start_date, + utc_start_date, + utc_end_date + ) = build_local_and_utc_date_range(start_date_str=start, days=days, timezone=timezone) results = dao_fetch_stats_for_service_from_days_for_user( - service_id, start_date, end_date, user_id + service_id, + utc_start_date, + utc_end_date, + user_id ) - stats = get_specific_days_stats(results, start_date, days=days) + stats = get_specific_days_stats( + data=results, + start_date=local_start_date, + days=days, + timezone=timezone + ) return stats diff --git a/tests/app/dao/test_date_utils.py b/tests/app/dao/test_date_utils.py index d4581104d..7c44a54d0 100644 --- a/tests/app/dao/test_date_utils.py +++ b/tests/app/dao/test_date_utils.py @@ -1,8 +1,10 @@ -from datetime import date, datetime +from datetime import date, datetime, timedelta +import pytz import pytest from app.dao.date_util import ( + build_local_and_utc_date_range, get_calendar_year, get_calendar_year_for_datetime, get_month_start_and_end_date_in_utc, @@ -75,3 +77,31 @@ def test_get_month_start_and_end_date_in_utc(month, year, expected_start, expect ) def test_get_calendar_year_for_datetime(dt, fy): assert get_calendar_year_for_datetime(dt) == fy + + +def test_build_local_and_utc_date_range(): + local_start, utc_start, utc_end = build_local_and_utc_date_range( + "2025-02-04", 7, "America/New_York" + ) + assert local_start.tzinfo + assert utc_start.tzinfo + assert utc_end.tzinfo + assert utc_end > utc_start + + +def test_build_local_and_utc_7_days_ny(): + local_start, utc_start, utc_end = build_local_and_utc_date_range( + "2025-02-10", 7, "America/New_York" + ) + diff = local_start + timedelta(days=7) + assert diff == datetime(2025, 2, 10, tzinfo=pytz.timezone("America/New_York")) + assert utc_start < utc_end + +def test_build_local_and_utc_1_day_utc(): + local_start, utc_start, utc_end = build_local_and_utc_date_range( + "2025-02-10", 1, "UTC" + ) + # this should be one day before + assert local_start.isoformat() == "2025-02-09T00:00:00+00:00" + assert utc_start.hour == 0 + assert utc_end.hour == 23