Merge pull request #2362 from GSA/feat/fe-tz-change

Added local/utc time conversion for text metrics
This commit is contained in:
Alex Janousek
2025-02-26 14:59:44 -05:00
committed by GitHub
5 changed files with 210 additions and 6 deletions

View File

@@ -162,3 +162,8 @@ upload-static:
# @cf map-route notify-admin ${DNS_NAME} --hostname www # @cf map-route notify-admin ${DNS_NAME} --hostname www
# @cf unmap-route notify-admin-failwhale ${DNS_NAME} --hostname www # @cf unmap-route notify-admin-failwhale ${DNS_NAME} --hostname www
# @echo "Failwhale is disabled" # @echo "Failwhale is disabled"
.PHONY: test-single
test-single: export NEW_RELIC_ENVIRONMENT=test
test-single: ## Run a single test file
poetry run pytest $(TEST_FILE)

View File

@@ -216,7 +216,13 @@
return; return;
} }
var url = type === 'service' ? `/services/${currentServiceId}/daily-stats.json` : `/services/${currentServiceId}/daily-stats-by-user.json`; var userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
var url = type === 'service'
? `/services/${currentServiceId}/daily-stats.json?timezone=${encodeURIComponent(userTimezone)}`
: `/services/${currentServiceId}/daily-stats-by-user.json`;
return fetch(url) return fetch(url)
.then(response => { .then(response => {
if (!response.ok) { if (!response.ok) {

View File

@@ -1,7 +1,8 @@
import calendar import calendar
from datetime import datetime from datetime import datetime, timedelta
from functools import partial from functools import partial
from itertools import groupby from itertools import groupby
from zoneinfo import ZoneInfo
from flask import Response, abort, jsonify, render_template, request, session, url_for from flask import Response, abort, jsonify, render_template, request, session, url_for
from flask_login import current_user from flask_login import current_user
@@ -103,10 +104,47 @@ def job_is_finished(job_dict):
@user_has_permissions() @user_has_permissions()
def get_daily_stats(service_id): def get_daily_stats(service_id):
date_range = get_stats_date_range() date_range = get_stats_date_range()
stats = service_api_client.get_service_notification_statistics_by_day( days = date_range["days"]
service_id, start_date=date_range["start_date"], days=date_range["days"] user_timezone = request.args.get("timezone", "UTC")
stats_utc = service_api_client.get_service_notification_statistics_by_day(
service_id,
start_date=date_range["start_date"],
days=days,
) )
return jsonify(stats)
local_stats = get_local_daily_stats_for_last_x_days(stats_utc, user_timezone, days)
return jsonify(local_stats)
def get_local_daily_stats_for_last_x_days(stats_utc, user_timezone, days):
tz = ZoneInfo(user_timezone)
today_local = datetime.now(tz).date()
start_local = today_local - timedelta(days=days - 1)
# Generate exactly days local dates, each with zeroed stats
days_list = [
(start_local + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(days)
]
aggregator = {
d: {
"sms": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
"email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
}
for d in days_list
}
# Convert each UTC timestamp to local date and iterate
for utc_ts, data in stats_utc.items():
utc_dt = datetime.strptime(utc_ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=ZoneInfo("UTC"))
local_day = utc_dt.astimezone(tz).strftime("%Y-%m-%d")
if local_day in aggregator:
for msg_type in ["sms", "email"]:
for status in ["delivered", "failure", "pending", "requested"]:
aggregator[local_day][msg_type][status] += data[msg_type][status]
return aggregator
@main.route("/services/<uuid:service_id>/daily-stats-by-user.json") @main.route("/services/<uuid:service_id>/daily-stats-by-user.json")

View File

@@ -1,6 +1,8 @@
import copy import copy
import json import json
from datetime import datetime from datetime import datetime
from unittest.mock import patch
from zoneinfo import ZoneInfo
import pytest import pytest
from flask import url_for from flask import url_for
@@ -12,6 +14,7 @@ from app.main.views.dashboard import (
aggregate_template_usage, aggregate_template_usage,
format_monthly_stats_to_list, format_monthly_stats_to_list,
get_dashboard_totals, get_dashboard_totals,
get_local_daily_stats_for_last_x_days,
get_tuples_of_financial_years, get_tuples_of_financial_years,
) )
from tests import ( from tests import (
@@ -1945,3 +1948,155 @@ def test_service_dashboard_shows_batched_jobs(
assert len(rows) == 1 assert len(rows) == 1
assert job_table_body is not None assert job_table_body is not None
@patch("app.main.views.dashboard.datetime", wraps=datetime)
def test_simple_local_conversion(mock_dt):
stats_utc = {
"2025-02-24T15:00:00Z": {
"sms": {"delivered": 1, "failure": 0, "pending": 0, "requested": 1},
"email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
},
"2025-02-25T07:00:00Z": {
"sms": {"delivered": 2, "failure": 0, "pending": 0, "requested": 2},
"email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
},
}
# Mock today's date in local time: 2025-02-25 at 10:00
mock_dt.now.return_value = datetime(2025, 2, 25, 10, 0, 0, tzinfo=ZoneInfo("America/New_York"))
local_stats = get_local_daily_stats_for_last_x_days(
stats_utc,
"America/New_York",
days=2
)
assert len(local_stats) == 2
assert "2025-02-24" in local_stats
assert "2025-02-25" in local_stats
assert local_stats["2025-02-24"]["sms"]["delivered"] == 1
assert local_stats["2025-02-24"]["sms"]["requested"] == 1
assert local_stats["2025-02-25"]["sms"]["delivered"] == 2
assert local_stats["2025-02-25"]["sms"]["requested"] == 2
def test_no_timestamps_returns_zeroed_days():
stats_utc = {}
class MockDateTime(datetime):
@classmethod
def now(cls, tz=None):
return cls(2025, 2, 26, 8, 0, 0, tzinfo=tz)
with patch("app.main.views.dashboard.datetime", MockDateTime):
local_stats = get_local_daily_stats_for_last_x_days(
stats_utc, "America/New_York", days=3
)
assert list(local_stats.keys()) == ["2025-02-24", "2025-02-25", "2025-02-26"]
for day in local_stats:
for msg_type in ["sms", "email"]:
for status in ["delivered", "failure", "pending", "requested"]:
assert local_stats[day][msg_type][status] == 0
def test_timestamp_in_future_time_zone():
stats_utc = {
"2025-02-25T01:00:00Z": {
"sms": {"delivered": 5, "failure": 0, "pending": 0, "requested": 5},
"email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
}
}
class MockDateTime(datetime):
@classmethod
def now(cls, tz=None):
return cls(2025, 2, 25, 10, 0, 0, tzinfo=tz)
with patch("app.main.views.dashboard.datetime", MockDateTime):
local_stats = get_local_daily_stats_for_last_x_days(
stats_utc, "Asia/Shanghai", days=1
)
assert list(local_stats.keys()) == ["2025-02-25"]
assert local_stats["2025-02-25"]["sms"]["delivered"] == 5
def test_many_timestamps_one_local_day():
stats_utc = {
"2025-02-24T05:00:00Z": {
"sms": {"delivered": 2, "failure": 1, "pending": 0, "requested": 3},
"email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
},
"2025-02-24T09:30:00Z": {
"sms": {"delivered": 1, "failure": 0, "pending": 0, "requested": 1},
"email": {"delivered": 2, "failure": 0, "pending": 0, "requested": 2},
},
"2025-02-24T16:59:59Z": {
"sms": {"delivered": 4, "failure": 0, "pending": 0, "requested": 4},
"email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
},
}
class MockDateTime(datetime):
@classmethod
def now(cls, tz=None):
return cls(2025, 2, 24, 20, 0, 0, tzinfo=tz)
with patch("app.main.views.dashboard.datetime", MockDateTime):
local_stats = get_local_daily_stats_for_last_x_days(
stats_utc, "America/New_York", days=1
)
assert list(local_stats.keys()) == ["2025-02-24"]
assert local_stats["2025-02-24"]["sms"]["delivered"] == 7
assert local_stats["2025-02-24"]["sms"]["requested"] == 8
assert local_stats["2025-02-24"]["email"]["delivered"] == 2
assert local_stats["2025-02-24"]["email"]["requested"] == 2
def test_local_conversion_phoenix():
"""Test aggregator logic in Mountain Time, no DST (America/Phoenix)."""
stats_utc = {
"2025-02-25T01:00:00Z": {
"sms": {"delivered": 1, "failure": 0, "pending": 0, "requested": 1},
"email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
},
"2025-02-25T12:00:00Z": {
"sms": {"delivered": 2, "failure": 0, "pending": 0, "requested": 2},
"email": {"delivered": 1, "failure": 0, "pending": 0, "requested": 1},
},
}
with patch("app.main.views.dashboard.datetime", wraps=datetime) as mock_dt:
mock_dt.now.return_value = datetime(2025, 2, 25, 12, 0, 0, tzinfo=ZoneInfo("America/Phoenix"))
local_stats = get_local_daily_stats_for_last_x_days(stats_utc, "America/Phoenix", days=1)
assert len(local_stats) == 1
(day_key,) = local_stats.keys()
sms_delivered = local_stats[day_key]["sms"]["delivered"]
assert sms_delivered in (2, 3, 4)
def test_local_conversion_honolulu():
"""Test aggregator logic in Hawaii (Pacific/Honolulu)."""
stats_utc = {
"2025-02-25T03:00:00Z": {
"sms": {"delivered": 2, "failure": 0, "pending": 0, "requested": 2},
"email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
},
"2025-02-25T21:00:00Z": {
"sms": {"delivered": 1, "failure": 0, "pending": 0, "requested": 1},
"email": {"delivered": 2, "failure": 0, "pending": 0, "requested": 2},
},
}
with patch("app.main.views.dashboard.datetime", wraps=datetime) as mock_dt:
mock_dt.now.return_value = datetime(2025, 2, 25, 12, 0, 0, tzinfo=ZoneInfo("Pacific/Honolulu"))
local_stats = get_local_daily_stats_for_last_x_days(stats_utc, "Pacific/Honolulu", days=1)
assert len(local_stats) == 1
(day_key,) = local_stats.keys()
total_requested = local_stats[day_key]["sms"]["requested"]
assert total_requested in (3, 1, 4)

View File

@@ -199,7 +199,7 @@ test('Fetches data and creates chart and table correctly', async () => {
const data = await fetchData('service'); const data = await fetchData('service');
expect(global.fetch).toHaveBeenCalledWith(`/services/${currentServiceId}/daily-stats.json`); expect(global.fetch).toHaveBeenCalledWith(`/services/${currentServiceId}/daily-stats.json?timezone=UTC`);
expect(data).toEqual(mockResponse); expect(data).toEqual(mockResponse);
const labels = Object.keys(mockResponse).map(dateString => { const labels = Object.keys(mockResponse).map(dateString => {