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..5a3414a260 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,83 @@ 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) + 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") + + # add today's data to monthly data + 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 +351,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 +382,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 +394,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 +422,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 23ba5ac52d..0f5e7e0d26 100644 --- a/app/translations/csv/fr.csv +++ b/app/translations/csv/fr.csv @@ -2004,3 +2004,12 @@ "Annual text message limit","(FR) Limite maximale de messages texte par exercice financier" "Annual email message limit","(FR) Limite maximale de messages électroniques par exercice financier" "Annual email limit","(FR) Limite maximale de courriels par exercice financier" +" and ends March 31, "," et se termine le 31 mars " +"Annual limit overview","Aperçu de la limite annuelle" +"Usage report","Rapport d’utilisation" +"Fiscal year begins April 1, ","Réinitialisation le 1er avril " +"resets on April 1, ","Réinitialisation le 1er avril " +"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" \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 9aae375237..6c39ee190a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1538,7 +1538,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" @@ -1574,8 +1574,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" @@ -2744,4 +2744,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "~3.10.9" -content-hash = "2a255ecaba02c0b01b8a429b847ce67f15aa0f0b5e2f9fd9c262f2e2a3154ad2" +content-hash = "5b24e44167b56523fd212c7b9c3129ef0bf08fec758975a88ce42769a74b24c4" diff --git a/pyproject.toml b/pyproject.toml index b688f6287a..bb6106e447 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(