mirror of
https://github.com/GSA/notifications-api.git
synced 2025-12-10 07:12:20 -05:00
Merge pull request #1569 from GSA/feat/fe-tz-change
Updated endpoint to allow hourly updates to enable utc => local time conversion
This commit is contained in:
5
Makefile
5
Makefile
@@ -148,3 +148,8 @@ clean:
|
||||
# cf unmap-route notify-api-failwhale ${DNS_NAME} --hostname api
|
||||
# cf stop notify-api-failwhale
|
||||
# @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)
|
||||
|
||||
@@ -93,3 +93,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 generate_hourly_range(start_date, end_date=None, hours=0):
|
||||
if end_date:
|
||||
current_time = start_date
|
||||
while current_time <= end_date:
|
||||
try:
|
||||
yield current_time
|
||||
except ValueError:
|
||||
pass
|
||||
current_time += timedelta(hours=1)
|
||||
elif hours > 0:
|
||||
end_time = start_date + timedelta(hours=hours)
|
||||
current_time = start_date
|
||||
while current_time < end_time:
|
||||
try:
|
||||
yield current_time
|
||||
except ValueError:
|
||||
pass
|
||||
current_time += timedelta(hours=1)
|
||||
else:
|
||||
return "An end_date or number of hours must be specified"
|
||||
|
||||
@@ -8,7 +8,11 @@ from sqlalchemy.sql.expression import and_, asc, case, func
|
||||
|
||||
from app import db
|
||||
from app.dao.dao_utils import VersionOptions, autocommit, version_class
|
||||
from app.dao.date_util import generate_date_range, get_current_calendar_year
|
||||
from app.dao.date_util import (
|
||||
generate_date_range,
|
||||
generate_hourly_range,
|
||||
get_current_calendar_year,
|
||||
)
|
||||
from app.dao.organization_dao import dao_get_organization_by_email_address
|
||||
from app.dao.service_sms_sender_dao import insert_service_sms_sender
|
||||
from app.dao.service_user_dao import dao_get_service_user
|
||||
@@ -523,6 +527,68 @@ def dao_fetch_stats_for_service_from_days(service_id, start_date, end_date):
|
||||
return total_notifications, data
|
||||
|
||||
|
||||
def dao_fetch_stats_for_service_from_hours(service_id, start_date, end_date):
|
||||
start_date = get_midnight_in_utc(start_date)
|
||||
end_date = get_midnight_in_utc(end_date + timedelta(days=1))
|
||||
|
||||
# Update to group by HOUR instead of DAY
|
||||
total_substmt = (
|
||||
select(
|
||||
func.date_trunc("hour", NotificationAllTimeView.created_at).label("hour"), # UPDATED
|
||||
Job.notification_count.label("notification_count"),
|
||||
)
|
||||
.join(Job, NotificationAllTimeView.job_id == Job.id)
|
||||
.where(
|
||||
NotificationAllTimeView.service_id == service_id,
|
||||
NotificationAllTimeView.key_type != KeyType.TEST,
|
||||
NotificationAllTimeView.created_at >= start_date,
|
||||
NotificationAllTimeView.created_at < end_date,
|
||||
)
|
||||
.group_by(
|
||||
Job.id,
|
||||
Job.notification_count,
|
||||
func.date_trunc("hour", NotificationAllTimeView.created_at), # UPDATED
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# Also update this to group by hour
|
||||
total_stmt = select(
|
||||
total_substmt.c.hour, # UPDATED
|
||||
func.sum(total_substmt.c.notification_count).label("total_notifications"),
|
||||
).group_by(total_substmt.c.hour) # UPDATED
|
||||
|
||||
# Ensure we're using hourly timestamps in the response
|
||||
total_notifications = {
|
||||
row.hour: row.total_notifications for row in db.session.execute(total_stmt).all()
|
||||
}
|
||||
|
||||
# Update the second query to also use "hour"
|
||||
stmt = (
|
||||
select(
|
||||
NotificationAllTimeView.notification_type,
|
||||
NotificationAllTimeView.status,
|
||||
func.date_trunc("hour", NotificationAllTimeView.created_at).label("hour"), # UPDATED
|
||||
func.count(NotificationAllTimeView.id).label("count"),
|
||||
)
|
||||
.where(
|
||||
NotificationAllTimeView.service_id == service_id,
|
||||
NotificationAllTimeView.key_type != KeyType.TEST,
|
||||
NotificationAllTimeView.created_at >= start_date,
|
||||
NotificationAllTimeView.created_at < end_date,
|
||||
)
|
||||
.group_by(
|
||||
NotificationAllTimeView.notification_type,
|
||||
NotificationAllTimeView.status,
|
||||
func.date_trunc("hour", NotificationAllTimeView.created_at), # UPDATED
|
||||
)
|
||||
)
|
||||
|
||||
data = db.session.execute(stmt).all()
|
||||
|
||||
return total_notifications, data
|
||||
|
||||
|
||||
def dao_fetch_stats_for_service_from_days_for_user(
|
||||
service_id, start_date, end_date, user_id
|
||||
):
|
||||
@@ -827,3 +893,36 @@ def get_specific_days_stats(
|
||||
for day, rows in grouped_data.items()
|
||||
}
|
||||
return stats
|
||||
|
||||
|
||||
def get_specific_hours_stats(data, start_date, hours=None, end_date=None, total_notifications=None):
|
||||
if hours is not None and end_date is not None:
|
||||
raise ValueError("Only set hours OR set end_date, not both.")
|
||||
elif hours is not None:
|
||||
gen_range = [start_date + timedelta(hours=i) for i in range(hours)]
|
||||
elif end_date is not None:
|
||||
gen_range = generate_hourly_range(start_date, end_date=end_date)
|
||||
else:
|
||||
raise ValueError("Either hours or end_date must be set.")
|
||||
|
||||
# Ensure all hours exist in the output (even if empty)
|
||||
grouped_data = {hour: [] for hour in gen_range}
|
||||
|
||||
# Group notifications based on full hour timestamps
|
||||
for row in data:
|
||||
notification_type, status, timestamp, count = row
|
||||
|
||||
row_hour = timestamp.replace(minute=0, second=0, microsecond=0)
|
||||
if row_hour in grouped_data:
|
||||
grouped_data[row_hour].append(row)
|
||||
|
||||
# Format statistics, returning only hours with results
|
||||
stats = {
|
||||
hour.strftime("%Y-%m-%dT%H:00:00Z"): statistics.format_statistics(
|
||||
rows,
|
||||
total_notifications.get(hour, 0) if total_notifications else None
|
||||
)
|
||||
for hour, rows in grouped_data.items() if rows
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
@@ -71,6 +71,7 @@ from app.dao.services_dao import (
|
||||
dao_fetch_service_by_id,
|
||||
dao_fetch_stats_for_service_from_days,
|
||||
dao_fetch_stats_for_service_from_days_for_user,
|
||||
dao_fetch_stats_for_service_from_hours,
|
||||
dao_fetch_todays_stats_for_all_services,
|
||||
dao_fetch_todays_stats_for_service,
|
||||
dao_remove_user_from_service,
|
||||
@@ -80,6 +81,7 @@ from app.dao.services_dao import (
|
||||
fetch_notification_stats_for_service_by_month_by_user,
|
||||
get_services_by_partial_name,
|
||||
get_specific_days_stats,
|
||||
get_specific_hours_stats,
|
||||
)
|
||||
from app.dao.templates_dao import dao_get_template_by_id
|
||||
from app.dao.users_dao import get_user_by_id
|
||||
@@ -230,22 +232,24 @@ def get_service_notification_statistics_by_day(service_id, start, days):
|
||||
|
||||
|
||||
def get_service_statistics_for_specific_days(service_id, start, days=1):
|
||||
# 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
|
||||
# Calculate start and end date range
|
||||
end_date = datetime.strptime(start, "%Y-%m-%d")
|
||||
start_date = end_date - timedelta(days=days - 1)
|
||||
|
||||
total_notifications, results = dao_fetch_stats_for_service_from_days(
|
||||
# Fetch hourly stats from DB
|
||||
total_notifications, results = dao_fetch_stats_for_service_from_hours(
|
||||
service_id,
|
||||
start_date,
|
||||
end_date,
|
||||
)
|
||||
|
||||
stats = get_specific_days_stats(
|
||||
hours = days * 24
|
||||
|
||||
# Process data using new hourly stats function
|
||||
stats = get_specific_hours_stats(
|
||||
results,
|
||||
start_date,
|
||||
days=days,
|
||||
hours=hours,
|
||||
total_notifications=total_notifications,
|
||||
)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import date, datetime
|
||||
import pytest
|
||||
|
||||
from app.dao.date_util import (
|
||||
generate_hourly_range,
|
||||
get_calendar_year,
|
||||
get_calendar_year_for_datetime,
|
||||
get_month_start_and_end_date_in_utc,
|
||||
@@ -75,3 +76,46 @@ 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_generate_hourly_range_with_end_date():
|
||||
start_date = datetime(2025, 2, 18, 12, 0)
|
||||
end_date = datetime(2025, 2, 18, 15, 0)
|
||||
result = list(generate_hourly_range(start_date, end_date=end_date))
|
||||
|
||||
expected = [
|
||||
datetime(2025, 2, 18, 12, 0),
|
||||
datetime(2025, 2, 18, 13, 0),
|
||||
datetime(2025, 2, 18, 14, 0),
|
||||
datetime(2025, 2, 18, 15, 0),
|
||||
]
|
||||
|
||||
assert result == expected, f"Expected {expected}, but got {result}"
|
||||
|
||||
|
||||
def test_generate_hourly_range_with_hours():
|
||||
start_date = datetime(2025, 2, 18, 12, 0)
|
||||
result = list(generate_hourly_range(start_date, hours=3))
|
||||
|
||||
expected = [
|
||||
datetime(2025, 2, 18, 12, 0),
|
||||
datetime(2025, 2, 18, 13, 0),
|
||||
datetime(2025, 2, 18, 14, 0),
|
||||
]
|
||||
|
||||
assert result == expected, f"Expected {expected}, but got {result}"
|
||||
|
||||
|
||||
def test_generate_hourly_range_with_zero_hours():
|
||||
start_date = datetime(2025, 2, 18, 12, 0)
|
||||
result = list(generate_hourly_range(start_date, hours=0))
|
||||
|
||||
assert result == [], f"Expected an empty list, but got {result}"
|
||||
|
||||
|
||||
def test_generate_hourly_range_with_end_date_before_start():
|
||||
start_date = datetime(2025, 2, 18, 12, 0)
|
||||
end_date = datetime(2025, 2, 18, 10, 0)
|
||||
result = list(generate_hourly_range(start_date, end_date=end_date))
|
||||
|
||||
assert result == [], f"Expected empty list, but got {result}"
|
||||
|
||||
86
tests/app/dao/test_services_get_specific_hours.py
Normal file
86
tests/app/dao/test_services_get_specific_hours.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from collections import namedtuple
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.dao.services_dao import get_specific_hours_stats
|
||||
from app.enums import StatisticsType
|
||||
from app.models import TemplateType
|
||||
|
||||
NotificationRow = namedtuple("NotificationRow", ["notification_type", "status", "timestamp", "count"])
|
||||
|
||||
|
||||
def generate_expected_hourly_output(requested_sms_hours):
|
||||
return {
|
||||
hour: {
|
||||
TemplateType.SMS: {
|
||||
StatisticsType.REQUESTED: 1,
|
||||
StatisticsType.DELIVERED: 0,
|
||||
StatisticsType.FAILURE: 0,
|
||||
StatisticsType.PENDING: 0,
|
||||
},
|
||||
TemplateType.EMAIL: {
|
||||
StatisticsType.REQUESTED: 0,
|
||||
StatisticsType.DELIVERED: 0,
|
||||
StatisticsType.FAILURE: 0,
|
||||
StatisticsType.PENDING: 0,
|
||||
},
|
||||
}
|
||||
for hour in requested_sms_hours
|
||||
}
|
||||
|
||||
|
||||
def create_mock_notification(notification_type, status, timestamp, count=1):
|
||||
"""
|
||||
Creates a named tuple with the attributes required by format_statistics.
|
||||
"""
|
||||
return NotificationRow(
|
||||
notification_type=notification_type,
|
||||
status=status,
|
||||
timestamp=timestamp.replace(minute=0, second=0, microsecond=0),
|
||||
count=count
|
||||
)
|
||||
|
||||
|
||||
test_cases = [
|
||||
(
|
||||
[create_mock_notification(
|
||||
TemplateType.SMS,
|
||||
StatisticsType.REQUESTED,
|
||||
datetime(2025, 2, 18, 14, 15, 0),
|
||||
)],
|
||||
datetime(2025, 2, 18, 12, 0),
|
||||
6,
|
||||
generate_expected_hourly_output(["2025-02-18T14:00:00Z"]),
|
||||
),
|
||||
(
|
||||
[create_mock_notification(
|
||||
TemplateType.SMS,
|
||||
StatisticsType.REQUESTED,
|
||||
datetime(2025, 2, 18, 17, 59, 59),
|
||||
)],
|
||||
datetime(2025, 2, 18, 15, 0),
|
||||
3,
|
||||
generate_expected_hourly_output(["2025-02-18T17:00:00Z"]),
|
||||
),
|
||||
([], datetime(2025, 2, 18, 10, 0), 4, {}),
|
||||
(
|
||||
[
|
||||
create_mock_notification(TemplateType.SMS, StatisticsType.REQUESTED, datetime(2025, 2, 18, 9, 30, 0)),
|
||||
create_mock_notification(TemplateType.SMS, StatisticsType.REQUESTED, datetime(2025, 2, 18, 11, 45, 0)),
|
||||
],
|
||||
datetime(2025, 2, 18, 8, 0),
|
||||
5,
|
||||
generate_expected_hourly_output(["2025-02-18T09:00:00Z", "2025-02-18T11:00:00Z"]),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_notifications, start_date, hours, expected_output", test_cases)
|
||||
def test_get_specific_hours(mocked_notifications, start_date, hours, expected_output):
|
||||
results = get_specific_hours_stats(
|
||||
mocked_notifications,
|
||||
start_date,
|
||||
hours=hours
|
||||
)
|
||||
assert results == expected_output, f"Expected {expected_output}, but got {results}"
|
||||
Reference in New Issue
Block a user