Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions app/main/views/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,10 +522,46 @@ def aggregate_by_type_daily(data, daily_data: DashboardTotals) -> AnnualData:


def _get_daily_stats(service_id):
# TODO: get from redis, else fallback to template_statistics_client.get_template_statistics_for_service
"""Get today's notification stats, preferring Redis for speed with DB fallback.

When Redis has today's daily counts (seeded by the annual-limit send path), we can
derive the dashboard totals without hitting the template-statistics API endpoint,
saving a full DB round-trip.

Returns:
(dashboard_totals_daily, highest_notification_count_daily, all_statistics_daily)
When served from Redis, all_statistics_daily will be an empty list (it is not
used by the main dashboard rendering path).
"""
use_billable_units = current_app.config.get("FF_USE_BILLABLE_UNITS", False)

# Try Redis first via the annual_limit_client
if current_app.config.get("REDIS_ENABLED"):
counts = annual_limit_client.get_all_notification_counts(service_id)
if counts:
if use_billable_units:
sms_delivered = counts.get("sms_billable_units_delivered_today", 0)
sms_failed = counts.get("sms_billable_units_failed_today", 0)
else:
sms_delivered = counts.get("sms_delivered_today", 0)
sms_failed = counts.get("sms_failed_today", 0)

email_delivered = counts.get("email_delivered_today", 0)
email_failed = counts.get("email_failed_today", 0)
sms_requested = sms_delivered + sms_failed
email_requested = email_delivered + email_failed

stats = {
"sms": {"requested": sms_requested, "delivered": sms_delivered, "failed": sms_failed},
"email": {"requested": email_requested, "delivered": email_delivered, "failed": email_failed},
}
dashboard_totals_daily = get_dashboard_totals(stats)
highest_notification_count_daily = max(sms_requested, email_requested)
return dashboard_totals_daily, highest_notification_count_daily, []

# Fallback to the DB if Redis isn't available or data doesn't exist yet.
all_statistics_daily = template_statistics_client.get_template_statistics_for_service(service_id, limit_days=1)
# Use billable_units for daily stats (used for limit tracking)
stats_daily = aggregate_notifications_stats(all_statistics_daily, use_billable_units=True)
stats_daily = aggregate_notifications_stats(all_statistics_daily, use_billable_units=use_billable_units)
dashboard_totals_daily = get_dashboard_totals(stats_daily)

highest_notification_count_daily = max(
Expand Down
114 changes: 114 additions & 0 deletions tests/app/main/views/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -2281,3 +2281,117 @@ def test_get_annual_data_api_fallback_when_seeding_fails_with_ff_enabled(
mock_service_api_client.get_monthly_notification_stats.assert_called_once_with(mock_service_id, 2023)
# Result should still be returned from API fallback
assert result == {"sms": 40, "email": 60}


class TestGetDailyStats:
"""Unit tests for _get_daily_stats Redis-first + DB-fallback logic."""

@pytest.fixture(autouse=True)
def _import(self):
from app.main.views.dashboard import _get_daily_stats

self._get_daily_stats = _get_daily_stats

def test_returns_from_redis_when_seeded(self, mocker, app_):
"""When annual_limit_client has today's counts, return them without calling the API."""
mocker.patch(
"app.main.views.dashboard.annual_limit_client.get_all_notification_counts",
return_value={
"sms_delivered_today": 7,
"sms_failed_today": 3,
"email_delivered_today": 15,
"email_failed_today": 5,
},
)
mock_api = mocker.patch("app.template_statistics_client.get_template_statistics_for_service")

with app_.app_context():
with set_config(app_, "REDIS_ENABLED", True), set_config(app_, "FF_USE_BILLABLE_UNITS", False):
totals, highest, all_stats = self._get_daily_stats("service-id")

mock_api.assert_not_called()
assert all_stats == []
assert totals["sms"]["requested"] == 10
assert totals["sms"]["delivered"] == 7
assert totals["sms"]["failed"] == 3
assert totals["email"]["requested"] == 20
assert totals["email"]["delivered"] == 15
assert totals["email"]["failed"] == 5
assert highest == 20 # max(10, 20)

def test_returns_billable_units_fields_when_ff_enabled(self, mocker, app_):
"""When FF_USE_BILLABLE_UNITS is on, billable-unit fields drive the SMS totals."""
mocker.patch(
"app.main.views.dashboard.annual_limit_client.get_all_notification_counts",
return_value={
"sms_delivered_today": 7,
"sms_failed_today": 3,
"sms_billable_units_delivered_today": 12,
"sms_billable_units_failed_today": 4,
"email_delivered_today": 15,
"email_failed_today": 5,
},
)
mocker.patch("app.template_statistics_client.get_template_statistics_for_service")

with app_.app_context():
with set_config(app_, "REDIS_ENABLED", True), set_config(app_, "FF_USE_BILLABLE_UNITS", True):
totals, highest, all_stats = self._get_daily_stats("svc-123")

assert totals["sms"]["requested"] == 16 # 12 + 4 billable units
assert totals["sms"]["delivered"] == 12
assert totals["sms"]["failed"] == 4

def test_falls_back_to_api_when_counts_empty(self, mocker, app_):
"""When annual_limit_client returns {} (not yet seeded), fall back to the API."""
mocker.patch(
"app.main.views.dashboard.annual_limit_client.get_all_notification_counts",
return_value={},
)
mock_api = mocker.patch(
"app.template_statistics_client.get_template_statistics_for_service",
return_value=[],
)

with app_.app_context():
with set_config(app_, "REDIS_ENABLED", True), set_config(app_, "FF_USE_BILLABLE_UNITS", False):
self._get_daily_stats("service-id")

mock_api.assert_called_once_with("service-id", limit_days=1)

def test_skips_redis_and_calls_api_when_redis_disabled(self, mocker, app_):
"""When REDIS_ENABLED is False, never touch Redis."""
mock_annual_limit = mocker.patch("app.main.views.dashboard.annual_limit_client.get_all_notification_counts")
mock_api = mocker.patch(
"app.template_statistics_client.get_template_statistics_for_service",
return_value=[],
)

with app_.app_context():
with set_config(app_, "REDIS_ENABLED", False), set_config(app_, "FF_USE_BILLABLE_UNITS", False):
self._get_daily_stats("service-id")

mock_annual_limit.assert_not_called()
mock_api.assert_called_once_with("service-id", limit_days=1)

def test_db_fallback_returns_correct_shape(self, mocker, app_):
"""DB-fallback path aggregates template stats correctly into the totals shape."""
mocker.patch(
"app.template_statistics_client.get_template_statistics_for_service",
return_value=[
{"template_type": "sms", "status": "delivered", "count": 30, "billable_units": 30},
{"template_type": "sms", "status": "permanent-failure", "count": 5, "billable_units": 5},
{"template_type": "email", "status": "delivered", "count": 40, "billable_units": 0},
{"template_type": "email", "status": "permanent-failure", "count": 2, "billable_units": 0},
],
)

with app_.app_context():
with set_config(app_, "REDIS_ENABLED", False), set_config(app_, "FF_USE_BILLABLE_UNITS", False):
totals, highest, all_stats = self._get_daily_stats("service-id")

assert totals["sms"]["requested"] == 35
assert totals["sms"]["failed"] == 5
assert totals["email"]["requested"] == 42
assert totals["email"]["failed"] == 2
assert len(all_stats) == 4
Loading