mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-09-05 16:18:26 -04:00
Merge pull request #2362 from GSA/feat/fe-tz-change
Added local/utc time conversion for text metrics
This commit is contained in:
5
Makefile
5
Makefile
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
Reference in New Issue
Block a user