diff --git a/app/config.py b/app/config.py index 0d2d7ed1f1..9b37a55798 100644 --- a/app/config.py +++ b/app/config.py @@ -80,7 +80,7 @@ class Config(object): # FEATURE FLAGS FF_SALESFORCE_CONTACT = env.bool("FF_SALESFORCE_CONTACT", True) FF_RTL = env.bool("FF_RTL", True) - FF_ANNUAL_LIMIT = env.bool("FF_ANNUAL_LIMIT", True) + FF_ANNUAL_LIMIT = env.bool("FF_ANNUAL_LIMIT", False) FREE_YEARLY_EMAIL_LIMIT = env.int("FREE_YEARLY_EMAIL_LIMIT", 20_000_000) FREE_YEARLY_SMS_LIMIT = env.int("FREE_YEARLY_SMS_LIMIT", 100_000) @@ -215,7 +215,7 @@ class Test(Development): NO_BRANDING_ID = "0af93cf1-2c49-485f-878f-f3e662e651ef" FF_RTL = True - FF_ANNUAL_LIMIT = False + FF_ANNUAL_LIMIT = True class ProductionFF(Config): diff --git a/app/extensions.py b/app/extensions.py index 8421b1e560..51bebe6e38 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -1,5 +1,6 @@ from flask_caching import Cache from notifications_utils.clients.antivirus.antivirus_client import AntivirusClient +from notifications_utils.clients.redis.annual_limit import RedisAnnualLimit from notifications_utils.clients.redis.bounce_rate import RedisBounceRate from notifications_utils.clients.redis.redis_client import RedisClient from notifications_utils.clients.statsd.statsd_client import StatsdClient @@ -10,4 +11,6 @@ zendesk_client = ZendeskClient() redis_client = RedisClient() bounce_rate_client = RedisBounceRate(redis_client) +annual_limit_client = RedisAnnualLimit(redis_client) + cache = Cache(config={"CACHE_TYPE": "simple"}) # TODO: pull config out to config.py later diff --git a/app/main/views/dashboard.py b/app/main/views/dashboard.py index f1bd214bfe..54c141a82d 100644 --- a/app/main/views/dashboard.py +++ b/app/main/views/dashboard.py @@ -24,7 +24,7 @@ service_api_client, template_statistics_client, ) -from app.extensions import bounce_rate_client +from app.extensions import annual_limit_client, bounce_rate_client from app.main import main from app.models.enum.bounce_rate_status import BounceRateStatus from app.models.enum.notification_statuses import NotificationStatuses @@ -229,16 +229,90 @@ def usage(service_id): @main.route("/services//monthly") @user_has_permissions("view_activity") def monthly(service_id): + def combine_daily_to_annual(daily, annual, mode): + if mode == "redis": + # the redis client omits properties if there are no counts yet, so account for this here\ + daily_redis = { + field: daily.get(field, 0) for field in ["sms_delivered", "sms_failed", "email_delivered", "email_failed"] + } + annual["sms"] += daily_redis["sms_delivered"] + daily_redis["sms_failed"] + annual["email"] += daily_redis["email_delivered"] + daily_redis["email_failed"] + elif mode == "db": + annual["sms"] += daily["sms"]["requested"] + annual["email"] += daily["email"]["requested"] + + return annual + + def combine_daily_to_monthly(daily, monthly, mode): + if mode == "redis": + # the redis client omits properties if there are no counts yet, so account for this here\ + daily_redis = { + field: daily.get(field, 0) for field in ["sms_delivered", "sms_failed", "email_delivered", "email_failed"] + } + + monthly[0]["sms_counts"]["failed"] += daily_redis["sms_failed"] + monthly[0]["sms_counts"]["requested"] += daily_redis["sms_failed"] + daily_redis["sms_delivered"] + monthly[0]["email_counts"]["failed"] += daily_redis["email_failed"] + monthly[0]["email_counts"]["requested"] += daily_redis["email_failed"] + daily_redis["email_delivered"] + elif mode == "db": + monthly[0]["sms_counts"]["failed"] += daily["sms"]["failed"] + monthly[0]["sms_counts"]["requested"] += daily["sms"]["requested"] + monthly[0]["email_counts"]["failed"] += daily["email"]["failed"] + monthly[0]["email_counts"]["requested"] += daily["email"]["requested"] + + return monthly + + def aggregate_by_type(notification_data): + counts = {"sms": 0, "email": 0, "letter": 0} + for month_data in notification_data["data"].values(): + for message_type, message_counts in month_data.items(): + if isinstance(message_counts, dict): + counts[message_type] += sum(message_counts.values()) + + # return the result + return counts + year, current_financial_year = requested_and_current_financial_year(request) + + # if FF_ANNUAL is on + if current_app.config["FF_ANNUAL_LIMIT"]: + monthly_data = service_api_client.get_monthly_notification_stats(service_id, year) + annual_data = aggregate_by_type(monthly_data) + + todays_data = annual_limit_client.get_all_notification_counts(current_service.id) + + # if redis is empty, query the db + if todays_data is None: + todays_data = service_api_client.get_service_statistics(service_id, limit_days=1, today_only=False) + annual_data_aggregate = combine_daily_to_annual(todays_data, annual_data, "db") + + months = (format_monthly_stats_to_list(monthly_data["data"]),) + monthly_data_aggregate = combine_daily_to_monthly(todays_data, months[0], "db") + else: + # aggregate daily + annual + current_app.logger.info("todays data" + str(todays_data)) + annual_data_aggregate = combine_daily_to_annual(todays_data, annual_data, "redis") + + months = (format_monthly_stats_to_list(monthly_data["data"]),) + monthly_data_aggregate = combine_daily_to_monthly(todays_data, months[0], "redis") + else: + monthly_data_aggregate = ( + format_monthly_stats_to_list(service_api_client.get_monthly_notification_stats(service_id, year)["data"]), + ) + monthly_data_aggregate = monthly_data_aggregate[0] + annual_data_aggregate = None + return render_template( "views/dashboard/monthly.html", - months=format_monthly_stats_to_list(service_api_client.get_monthly_notification_stats(service_id, year)["data"]), + months=monthly_data_aggregate, years=get_tuples_of_financial_years( partial_url=partial(url_for, ".monthly", service_id=service_id), start=current_financial_year - 2, end=current_financial_year, ), + annual_data=annual_data_aggregate, selected_year=year, + current_financial_year=current_financial_year, ) @@ -284,6 +358,21 @@ def aggregate_notifications_stats(template_statistics): def get_dashboard_partials(service_id): + def aggregate_by_type(data, daily_data): + counts = {"sms": 0, "email": 0, "letter": 0} + # flatten out this structure to match the above + for month_data in data["data"].values(): + for message_type, message_counts in month_data.items(): + if isinstance(message_counts, dict): + counts[message_type] += sum(message_counts.values()) + + # add todays data to the annual data + counts = { + "sms": counts["sms"] + daily_data["sms"]["requested"], + "email": counts["email"] + daily_data["email"]["requested"], + } + return counts + all_statistics_weekly = template_statistics_client.get_template_statistics_for_service(service_id, limit_days=7) template_statistics_weekly = aggregate_template_usage(all_statistics_weekly) @@ -300,6 +389,10 @@ def get_dashboard_partials(service_id): dashboard_totals_weekly = (get_dashboard_totals(stats_weekly),) bounce_rate_data = get_bounce_rate_data_from_redis(service_id) + # get annual data from fact table (all data this year except today) + annual_data = service_api_client.get_monthly_notification_stats(service_id, year=get_current_financial_year()) + annual_data = aggregate_by_type(annual_data, dashboard_totals_daily[0]) + return { "upcoming": render_template("views/dashboard/_upcoming.html", scheduled_jobs=scheduled_jobs), "daily_totals": render_template( @@ -308,6 +401,13 @@ def get_dashboard_partials(service_id): statistics=dashboard_totals_daily[0], column_width=column_width, ), + "annual_totals": render_template( + "views/dashboard/_totals_annual.html", + service_id=service_id, + statistics=dashboard_totals_daily[0], + statistics_annual=annual_data, + column_width=column_width, + ), "weekly_totals": render_template( "views/dashboard/_totals.html", service_id=service_id, @@ -329,6 +429,7 @@ def get_dashboard_partials(service_id): def _get_daily_stats(service_id): + # TODO: get from redis, else fallback to template_statistics_client.get_template_statistics_for_service all_statistics_daily = template_statistics_client.get_template_statistics_for_service(service_id, limit_days=1) stats_daily = aggregate_notifications_stats(all_statistics_daily) dashboard_totals_daily = (get_dashboard_totals(stats_daily),) diff --git a/app/notify_client/service_api_client.py b/app/notify_client/service_api_client.py index 6054c374d2..b1274337c7 100644 --- a/app/notify_client/service_api_client.py +++ b/app/notify_client/service_api_client.py @@ -1,5 +1,5 @@ import json -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from flask import current_app from flask_login import current_user @@ -9,6 +9,12 @@ from app.notify_client import NotifyAdminAPIClient, _attach_current_user, cache +def _seconds_until_midnight(): + now = datetime.now(timezone.utc) + midnight = datetime.combine(now + timedelta(days=1), datetime.min.time()) + return int((midnight - now).total_seconds()) + + class ServiceAPIClient(NotifyAdminAPIClient): @cache.delete("user-{user_id}") def create_service( @@ -377,8 +383,15 @@ def is_service_email_from_unique(self, service_id, email_from): def get_service_history(self, service_id): return self.get("/service/{0}/history".format(service_id)) + # TODO: cache this once the backend is updated to exlude data from the current day + # @flask_cache.memoize(timeout=_seconds_until_midnight()) def get_monthly_notification_stats(self, service_id, year): - return self.get(url="/service/{}/notifications/monthly?year={}".format(service_id, year)) + return self.get( + url="/service/{}/notifications/monthly?year={}".format( + service_id, + year, + ) + ) def get_safelist(self, service_id): return self.get(url="/service/{}/safelist".format(service_id)) @@ -622,5 +635,15 @@ def _use_case_data_name(self, service_id): def _tos_key_name(self, service_id): return f"tos-accepted-{service_id}" + def aggregate_by_type(self, notification_data): + counts = {"sms": 0, "email": 0, "letter": 0} + for month_data in notification_data["data"].values(): + for message_type, message_counts in month_data.items(): + if isinstance(message_counts, dict): + counts[message_type] += sum(message_counts.values()) + + # return the result + return counts + service_api_client = ServiceAPIClient() diff --git a/app/templates/views/dashboard/_totals_annual.html b/app/templates/views/dashboard/_totals_annual.html new file mode 100644 index 0000000000..4985ec80be --- /dev/null +++ b/app/templates/views/dashboard/_totals_annual.html @@ -0,0 +1,25 @@ +{% from "components/big-number.html" import big_number %} +{% from "components/message-count-label.html" import message_count_label %} +{% from 'components/remaining-messages.html' import remaining_messages %} +{% from "components/show-more.html" import show_more %} + +
+

+ {{ _('Annual usage') }} +
+ + {% set current_year = current_year or (now().year if now().month < 4 else now().year + 1) %} + {{ _('resets on April 1, ') ~ current_year }} + +

+
+
+ {{ remaining_messages(header=_('emails'), total=current_service.email_annual_limit, used=statistics_annual['email'], muted=true) }} +
+
+ {{ remaining_messages(header=_('text messages'), total=current_service.sms_annual_limit, used=statistics_annual['sms'], muted=true) }} +
+
+ {{ show_more(url_for('.monthly', service_id=current_service.id), _('Visit usage report')) }} +
+ diff --git a/app/templates/views/dashboard/_totals_daily.html b/app/templates/views/dashboard/_totals_daily.html index 8b9d9e0dcb..98195f1a5d 100644 --- a/app/templates/views/dashboard/_totals_daily.html +++ b/app/templates/views/dashboard/_totals_daily.html @@ -1,8 +1,29 @@ {% from "components/big-number.html" import big_number %} {% from "components/message-count-label.html" import message_count_label %} {% from 'components/remaining-messages.html' import remaining_messages %} +{% from "components/show-more.html" import show_more %} -
+{% if config["FF_ANNUAL_LIMIT"] %} +
+

+ {{ _('Daily usage') }} +
+ + {{ _('resets at 7pm Eastern Time') }} + +

+
+
+ {{ remaining_messages(header=_('emails'), total=current_service.message_limit, used=statistics['email']['requested'], muted=true) }} +
+
+ {{ remaining_messages(header=_('text messages'), total=current_service.sms_daily_limit, used=statistics['sms']['requested'], muted=true) }} +
+
+ {{ show_more(url_for('main.contact'), _('Request a daily limit increase')) }} +
+{% else %} +

{{ _('Daily usage') }}
@@ -18,4 +39,5 @@

{{ remaining_messages(header=_('text messages'), total=current_service.sms_daily_limit, used=statistics['sms']['requested']) }}

- + +{% endif %} diff --git a/app/templates/views/dashboard/dashboard.html b/app/templates/views/dashboard/dashboard.html index 110a94f241..4e005d86bb 100644 --- a/app/templates/views/dashboard/dashboard.html +++ b/app/templates/views/dashboard/dashboard.html @@ -26,7 +26,10 @@

{{ _("Scheduled sends") }}

{{ ajax_block(partials, updates_url, 'weekly_totals', interval=5) }} {{ ajax_block(partials, updates_url, 'daily_totals', interval=5) }} - + {% if config["FF_ANNUAL_LIMIT"] %} + {{ ajax_block(partials, updates_url, 'annual_totals', interval=5) }} + {% endif %} +
{% if partials['has_template_statistics'] %} diff --git a/app/templates/views/dashboard/monthly.html b/app/templates/views/dashboard/monthly.html index 7aacbb685a..5fe77f7253 100644 --- a/app/templates/views/dashboard/monthly.html +++ b/app/templates/views/dashboard/monthly.html @@ -1,22 +1,23 @@ -{% from "components/big-number.html" import big_number_with_status, big_number %} +{% from "components/big-number.html" import big_number_with_status, big_number, big_number_simple %} {% from "components/pill.html" import pill %} {% from "components/table.html" import list_table, field, hidden_field_heading, right_aligned_field_heading, row_heading %} {% from "components/message-count-label.html" import message_count_label %} +{% from 'components/remaining-messages.html' import remaining_messages %} {% extends "admin_template.html" %} {% block service_page_title %} - {{ _('Messages sent,') }} + {{ _('Usage report') }} {{ selected_year }} {{ _('to') }} {{ selected_year + 1 }} {{ _('fiscal year') }} {% endblock %} {% block maincolumn_content %}

- {{ _('Messages sent') }} + {{ _('Usage report') }}

-
+
{{ pill( items=years, current_value=selected_year, @@ -25,6 +26,50 @@

) }}

+ {% if config["FF_ANNUAL_LIMIT"] %} +

+ {% if selected_year == current_financial_year %} + {{ _('Annual limit overview') }} + {% else %} + {{ _('Annual overview') }} + {% endif %} +
+ + {{ _('Fiscal year begins April 1, ') ~ selected_year ~ _(' and ends March 31, ') ~ (selected_year + 1) }} + +

+
+ {% if selected_year == current_financial_year %} +
+ {{ remaining_messages(header=_('emails'), total=current_service.email_annual_limit, used=annual_data['email']) }} +
+
+ {{ remaining_messages(header=_('text messages'), total=current_service.sms_annual_limit, used=annual_data['sms']) }} +
+ {% else %} +
+ {{ big_number_simple( + annual_data['email'], + _('emails'), + + ) + }} +
+
+ {{ big_number_simple( + annual_data['sms'], + _('text messages'), + + ) + }} +
+ {% endif %} +
+

+ {{ _('Month by month totals') }} +

+ {% endif %} + {% if months %} {% set spend_txt = _('Total spend') %} {% set heading_1 = _('Month') %} diff --git a/app/translations/csv/fr.csv b/app/translations/csv/fr.csv index 1485bc4300..57aead4f21 100644 --- a/app/translations/csv/fr.csv +++ b/app/translations/csv/fr.csv @@ -2027,4 +2027,4 @@ "Annual usage","Utilisation annuelle" "resets at 7pm Eastern Time","Réinitialisation à 19 h, heure de l’Est" "Visit usage report","Consulter le rapport d’utilisation" -"Month by month totals","Totaux mensuels" +"Month by month totals","Totaux mensuels" \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 80bf69fe26..005883ce93 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1552,7 +1552,7 @@ requests = ">=2.0.0" [[package]] name = "notifications-utils" -version = "52.3.5" +version = "52.3.9" description = "Shared python code for Notification - Provides logging utils etc." optional = false python-versions = "~3.10.9" @@ -1588,8 +1588,8 @@ werkzeug = "3.0.4" [package.source] type = "git" url = "https://github.com/cds-snc/notifier-utils.git" -reference = "52.3.5" -resolved_reference = "953ee170b4c47465bef047f1060d17a7702edeeb" +reference = "52.3.9" +resolved_reference = "b344e5a74c79a8fa8ca4f722691850ac0d277959" [[package]] name = "openpyxl" @@ -2758,4 +2758,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "~3.10.9" -content-hash = "587e62c5c8f700ef83c6a39d3faca6dde4d10c92ee627c771ecdf538b405a77f" +content-hash = "443df8a67497588c1801bfac747fde95ecaffda93675b6f038906750e891316b" diff --git a/pyproject.toml b/pyproject.toml index 65718753e0..e44d4352e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ unidecode = "^1.3.8" # PaaS awscli-cwlogs = "^1.4.6" -notifications-utils = { git = "https://github.com/cds-snc/notifier-utils.git", tag = "52.3.5" } +notifications-utils = { git = "https://github.com/cds-snc/notifier-utils.git", tag = "52.3.9" } # Pinned dependencies diff --git a/tests/app/main/views/test_dashboard.py b/tests/app/main/views/test_dashboard.py index b87baa165b..a00980bc6c 100644 --- a/tests/app/main/views/test_dashboard.py +++ b/tests/app/main/views/test_dashboard.py @@ -1,7 +1,9 @@ import copy import re +from unittest.mock import ANY import pytest +from bs4 import BeautifulSoup from flask import url_for from freezegun import freeze_time @@ -21,6 +23,7 @@ create_active_caseworking_user, create_active_user_view_permissions, normalize_spaces, + set_config, ) stub_template_stats = [ @@ -137,6 +140,7 @@ def test_task_shortcuts_are_visible_based_on_permissions( mock_get_service_templates, mock_get_jobs, mock_get_template_statistics, + mock_get_service_statistics, permissions: list, text_in_page: list, text_not_in_page: list, @@ -170,6 +174,7 @@ def test_survey_widget_presence( mock_get_service_templates, mock_get_jobs, mock_get_template_statistics, + mock_get_service_statistics, mocker, admin_url, is_widget_present, @@ -193,6 +198,7 @@ def test_sending_link_has_query_param( mock_get_service_templates, mock_get_jobs, mock_get_template_statistics, + mock_get_service_statistics, ): active_user_with_permissions["permissions"][SERVICE_ONE_ID] = ["view_activity", "send_messages"] client_request.login(active_user_with_permissions) @@ -209,6 +215,7 @@ def test_no_sending_link_if_no_templates( client_request: ClientRequest, mock_get_service_templates_when_no_templates_exist, mock_get_template_statistics, + mock_get_service_statistics, mock_get_jobs, ): page = client_request.get("main.service_dashboard", service_id=SERVICE_ONE_ID) @@ -305,11 +312,7 @@ def test_should_show_monthly_breakdown_of_template_usage( def test_anyone_can_see_monthly_breakdown( - client, - api_user_active, - service_one, - mocker, - mock_get_monthly_notification_stats, + client, api_user_active, service_one, mocker, mock_get_monthly_notification_stats, mock_get_service_statistics ): validate_route_permission_with_client( mocker, @@ -324,16 +327,14 @@ def test_anyone_can_see_monthly_breakdown( def test_monthly_shows_letters_in_breakdown( - client_request, - service_one, - mock_get_monthly_notification_stats, + client_request, service_one, mock_get_monthly_notification_stats, mock_get_service_statistics ): page = client_request.get("main.monthly", service_id=service_one["id"]) columns = page.select(".table-field-left-aligned .big-number-label") - assert normalize_spaces(columns[0].text) == "emails" - assert normalize_spaces(columns[1].text) == "text messages" + assert normalize_spaces(columns[2].text) == "emails" + assert normalize_spaces(columns[3].text) == "text messages" @pytest.mark.parametrize( @@ -345,10 +346,7 @@ def test_monthly_shows_letters_in_breakdown( ) @freeze_time("2015-01-01 15:15:15.000000") def test_stats_pages_show_last_3_years( - client_request, - endpoint, - mock_get_monthly_notification_stats, - mock_get_monthly_template_usage, + client_request, endpoint, mock_get_monthly_notification_stats, mock_get_monthly_template_usage, mock_get_service_statistics ): page = client_request.get( endpoint, @@ -361,9 +359,7 @@ def test_stats_pages_show_last_3_years( def test_monthly_has_equal_length_tables( - client_request, - service_one, - mock_get_monthly_notification_stats, + client_request, service_one, mock_get_monthly_notification_stats, mock_get_service_statistics ): page = client_request.get("main.monthly", service_id=service_one["id"]) @@ -401,31 +397,6 @@ def test_should_show_upcoming_jobs_on_dashboard( assert table_rows[1].find_all("td")[0].text.strip() == "Scheduled to send to 30 recipients" -@pytest.mark.parametrize( - "permissions, column_name, expected_column_count", - [ - (["email", "sms"], ".w-1\\/2", 6), - (["email", "sms"], ".w-1\\/2", 6), - ], -) -def test_correct_columns_display_on_dashboard_v15( - client_request: ClientRequest, - mock_get_service_templates, - mock_get_template_statistics, - mock_get_service_statistics, - mock_get_jobs, - service_one, - permissions, - expected_column_count, - column_name, - app_, -): - service_one["permissions"] = permissions - - page = client_request.get("main.service_dashboard", service_id=service_one["id"]) - assert len(page.select(column_name)) == expected_column_count - - def test_daily_usage_section_shown( client_request, mocker, @@ -1424,3 +1395,201 @@ def test_dashboard_daily_limits( ) == 2 ) + + +class TestAnnualLimits: + def test_daily_usage_uses_muted_component( + self, + logged_in_client, + mocker, + mock_get_service_templates_when_no_templates_exist, + mock_get_jobs, + mock_get_service_statistics, + mock_get_usage, + app_, + ): + with set_config(app_, "FF_ANNUAL_LIMIT", True): # REMOVE LINE WHEN FF REMOVED + mocker.patch( + "app.template_statistics_client.get_template_statistics_for_service", + return_value=copy.deepcopy(stub_template_stats), + ) + + url = url_for("main.service_dashboard", service_id=SERVICE_ONE_ID) + response = logged_in_client.get(url) + page = BeautifulSoup(response.data.decode("utf-8"), "html.parser") + + # ensure both email + sms widgets are muted + assert len(page.select("[data-testid='daily-usage'] .remaining-messages.muted")) == 2 + + def test_annual_usage_uses_muted_component( + self, + logged_in_client, + mocker, + mock_get_service_templates_when_no_templates_exist, + mock_get_jobs, + mock_get_service_statistics, + mock_get_usage, + app_, + ): + with set_config(app_, "FF_ANNUAL_LIMIT", True): # REMOVE LINE WHEN FF REMOVED + mocker.patch( + "app.template_statistics_client.get_template_statistics_for_service", + return_value=copy.deepcopy(stub_template_stats), + ) + + url = url_for("main.service_dashboard", service_id=SERVICE_ONE_ID) + response = logged_in_client.get(url) + page = BeautifulSoup(response.data.decode("utf-8"), "html.parser") + + # ensure both email + sms widgets are muted + assert len(page.select("[data-testid='annual-usage'] .remaining-messages.muted")) == 2 + + @freeze_time("2024-11-25 12:12:12") + @pytest.mark.parametrize( + "redis_daily_data, monthly_data, expected_data", + [ + ( + {"sms_delivered": 100, "email_delivered": 50, "sms_failed": 1000, "email_failed": 500}, + { + "data": { + "2024-04": {"sms": {}, "email": {}, "letter": {}}, + "2024-05": {"sms": {}, "email": {}, "letter": {}}, + "2024-06": {"sms": {}, "email": {}, "letter": {}}, + "2024-07": {"sms": {}, "email": {}, "letter": {}}, + "2024-08": {"sms": {}, "email": {}, "letter": {}}, + "2024-09": {"sms": {}, "email": {}, "letter": {}}, + "2024-10": { + "sms": {"delivered": 5, "permanent-failure": 50, "sending": 5, "technical-failure": 100}, + "email": {"delivered": 10, "permanent-failure": 110, "sending": 50, "technical-failure": 50}, + "letter": {}, + }, + "2024-11": { + "sms": {"delivered": 5, "permanent-failure": 50, "sending": 5, "technical-failure": 100}, + "email": {"delivered": 10, "permanent-failure": 110, "sending": 50, "technical-failure": 50}, + "letter": {}, + }, + } + }, + {"email": 990, "letter": 0, "sms": 1420}, + ), + ( + {"sms_delivered": 6, "email_delivered": 6, "sms_failed": 6, "email_failed": 6}, + { + "data": { + "2024-10": { + "sms": {"delivered": 6, "permanent-failure": 6, "sending": 6, "technical-failure": 6}, + "email": {"delivered": 6, "permanent-failure": 6, "sending": 6, "technical-failure": 6}, + "letter": {}, + }, + } + }, + {"email": 36, "letter": 0, "sms": 36}, + ), + ], + ) + def test_usage_report_aggregates_calculated_properly_with_redis( + self, + logged_in_client, + mocker, + mock_get_service_templates_when_no_templates_exist, + mock_get_jobs, + mock_get_service_statistics, + mock_get_usage, + app_, + redis_daily_data, + monthly_data, + expected_data, + ): + with set_config(app_, "FF_ANNUAL_LIMIT", True): # REMOVE LINE WHEN FF REMOVED + # mock annual_limit_client.get_all_notification_counts + mocker.patch( + "app.main.views.dashboard.annual_limit_client.get_all_notification_counts", + return_value=redis_daily_data, + ) + + mocker.patch( + "app.service_api_client.get_monthly_notification_stats", + return_value=copy.deepcopy(monthly_data), + ) + + mock_render_template = mocker.patch("app.main.views.dashboard.render_template") + + url = url_for("main.monthly", service_id=SERVICE_ONE_ID) + logged_in_client.get(url) + + mock_render_template.assert_called_with( + ANY, months=ANY, years=ANY, annual_data=expected_data, selected_year=ANY, current_financial_year=ANY + ) + + @freeze_time("2024-11-25 12:12:12") + @pytest.mark.parametrize( + "daily_data, monthly_data, expected_data", + [ + ( + { + "sms": {"requested": 100, "delivered": 50, "failed": 50}, + "email": {"requested": 100, "delivered": 50, "failed": 50}, + "letter": {"requested": 0, "delivered": 0, "failed": 0}, + }, + { + "data": { + "2024-04": {"sms": {}, "email": {}, "letter": {}}, + "2024-05": {"sms": {}, "email": {}, "letter": {}}, + "2024-06": {"sms": {}, "email": {}, "letter": {}}, + "2024-07": {"sms": {}, "email": {}, "letter": {}}, + "2024-08": {"sms": {}, "email": {}, "letter": {}}, + "2024-09": {"sms": {}, "email": {}, "letter": {}}, + "2024-10": { + "sms": {"delivered": 5, "permanent-failure": 50, "sending": 5, "technical-failure": 100}, + "email": {"delivered": 10, "permanent-failure": 110, "sending": 50, "technical-failure": 50}, + "letter": {}, + }, + "2024-11": { + "sms": {"delivered": 5, "permanent-failure": 50, "sending": 5, "technical-failure": 100}, + "email": {"delivered": 10, "permanent-failure": 110, "sending": 50, "technical-failure": 50}, + "letter": {}, + }, + } + }, + {"email": 540, "letter": 0, "sms": 420}, + ) + ], + ) + def test_usage_report_aggregates_calculated_properly_without_redis( + self, + logged_in_client, + mocker, + mock_get_service_templates_when_no_templates_exist, + mock_get_jobs, + mock_get_service_statistics, + mock_get_usage, + app_, + daily_data, + monthly_data, + expected_data, + ): + with set_config(app_, "FF_ANNUAL_LIMIT", True): # REMOVE LINE WHEN FF REMOVED + # mock annual_limit_client.get_all_notification_counts + mocker.patch( + "app.main.views.dashboard.annual_limit_client.get_all_notification_counts", + return_value=None, + ) + + mocker.patch( + "app.service_api_client.get_service_statistics", + return_value=copy.deepcopy(daily_data), + ) + + mocker.patch( + "app.service_api_client.get_monthly_notification_stats", + return_value=copy.deepcopy(monthly_data), + ) + + mock_render_template = mocker.patch("app.main.views.dashboard.render_template") + + url = url_for("main.monthly", service_id=SERVICE_ONE_ID) + logged_in_client.get(url) + + mock_render_template.assert_called_with( + ANY, months=ANY, years=ANY, annual_data=expected_data, selected_year=ANY, current_financial_year=ANY + ) diff --git a/tests/conftest.py b/tests/conftest.py index a0c964cb90..94ff0ca5be 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -578,9 +578,42 @@ def _get(service_id, today_only, limit_days=None): "letter": {"requested": 0, "delivered": 0, "failed": 0}, } + # mock these stats at the same time + def _get_monthly_stats(service_id, year): + return { + "data": { + "2024-04": {"sms": {}, "email": {}, "letter": {}}, + "2024-05": {"sms": {}, "email": {}, "letter": {}}, + "2024-06": {"sms": {}, "email": {}, "letter": {}}, + "2024-07": {"sms": {}, "email": {}, "letter": {}}, + "2024-08": {"sms": {}, "email": {}, "letter": {}}, + "2024-09": {"sms": {}, "email": {}, "letter": {}}, + "2024-10": {"sms": {}, "email": {}, "letter": {}}, + "2024-11": { + "sms": {"sent": 1}, + "email": {"delivered": 1, "permanent-failure": 1, "sending": 3, "technical-failure": 1}, + "letter": {}, + }, + } + } + + mocker.patch("app.service_api_client.get_monthly_notification_stats", side_effect=_get_monthly_stats) + return mocker.patch("app.service_api_client.get_service_statistics", side_effect=_get) +@pytest.fixture(scope="function") +def mock_get_annual_statistics(mocker, api_user_active): + def _get(service_id, year): + return { + "email": 100, + "sms": 200, + "letter": 300, + } + + return mocker.patch("app.service_api_client.get_monthly_notification_stats", side_effect=_get) + + @pytest.fixture(scope="function") def mock_get_detailed_services(mocker, fake_uuid): service_one = service_json(