diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 71358535af..904b8a97c9 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -95,7 +95,7 @@ jobs: - name: Update images in staging run: | DOCKER_TAG=${GITHUB_SHA::7} - kubectl set image deployment.apps/admin admin=$DOCKER_SLUG:$DOCKER_TAG -n=notification-canada-ca --kubeconfig=$HOME/.kube/config + kubectl set image deployment.apps/notify-admin notify-admin=$DOCKER_SLUG:$DOCKER_TAG -n=notification-canada-ca --kubeconfig=$HOME/.kube/config - name: my-app-install token id: notify-pr-bot diff --git a/app/main/views/dashboard.py b/app/main/views/dashboard.py index 54c141a82d..2c40d1a2a1 100644 --- a/app/main/views/dashboard.py +++ b/app/main/views/dashboard.py @@ -282,7 +282,7 @@ def aggregate_by_type(notification_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: + if all(value == 0 for value in todays_data.values()): 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") diff --git a/app/main/views/send.py b/app/main/views/send.py index 2a5720e38b..53acc9ba63 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -53,6 +53,7 @@ ) from app.main.views.dashboard import aggregate_notifications_stats from app.models.user import Users +from app.notify_client.notification_counts_client import notification_counts_client from app.s3_client.s3_csv_client import ( copy_bulk_send_file_to_uploads, list_bulk_send_uploads, @@ -649,8 +650,8 @@ def _check_messages(service_id, template_id, upload_id, preview_row, letters_as_ sms_fragments_sent_today = daily_sms_fragment_count(service_id) emails_sent_today = daily_email_count(service_id) - remaining_sms_message_fragments = current_service.sms_daily_limit - sms_fragments_sent_today - remaining_email_messages = current_service.message_limit - emails_sent_today + remaining_sms_message_fragments_today = current_service.sms_daily_limit - sms_fragments_sent_today + remaining_email_messages_today = current_service.message_limit - emails_sent_today contents = s3download(service_id, upload_id) @@ -659,7 +660,7 @@ def _check_messages(service_id, template_id, upload_id, preview_row, letters_as_ email_reply_to = None sms_sender = None recipients_remaining_messages = ( - remaining_email_messages if db_template["template_type"] == "email" else remaining_sms_message_fragments + remaining_email_messages_today if db_template["template_type"] == "email" else remaining_sms_message_fragments_today ) if db_template["template_type"] == "email": @@ -743,8 +744,8 @@ def _check_messages(service_id, template_id, upload_id, preview_row, letters_as_ original_file_name=request.args.get("original_file_name", ""), upload_id=upload_id, form=CsvUploadForm(), - remaining_messages=remaining_email_messages, - remaining_sms_message_fragments=remaining_sms_message_fragments, + remaining_messages=remaining_email_messages_today, + remaining_sms_message_fragments=remaining_sms_message_fragments_today, sms_parts_to_send=sms_parts_to_send, is_sms_parts_estimated=is_sms_parts_estimated, choose_time_form=choose_time_form, @@ -783,7 +784,24 @@ def check_messages(service_id, template_id, upload_id, row_index=2): data["original_file_name"] = SanitiseASCII.encode(data.get("original_file_name", "")) data["sms_parts_requested"] = data["stats_daily"]["sms"]["requested"] data["sms_parts_remaining"] = current_service.sms_daily_limit - daily_sms_fragment_count(service_id) - data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"] + + if current_app.config["FF_ANNUAL_LIMIT"]: + data["send_exceeds_annual_limit"] = False + data["send_exceeds_daily_limit"] = False + # determine the remaining sends for daily + annual + limit_stats = notification_counts_client.get_limit_stats(current_service) + remaining_annual = limit_stats[data["template"].template_type]["annual"]["remaining"] + + if remaining_annual < data["count_of_recipients"]: + data["recipients_remaining_messages"] = remaining_annual + data["send_exceeds_annual_limit"] = True + else: + # if they arent over their limit, and its sms, check if they are over their daily limit + if data["template"].template_type == "sms": + data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"] + + else: + data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"] if ( data["recipients"].too_many_rows @@ -804,6 +822,10 @@ def check_messages(service_id, template_id, upload_id, row_index=2): if data["send_exceeds_daily_limit"]: return render_template("views/check/column-errors.html", **data) + if current_app.config["FF_ANNUAL_LIMIT"]: + if data["send_exceeds_annual_limit"]: + return render_template("views/check/column-errors.html", **data) + metadata_kwargs = { "notification_count": data["count_of_recipients"], "template_id": str(template_id), @@ -1085,6 +1107,10 @@ def get_template_error_dict(exception): error = "too-many-sms-messages" elif "Content for template has a character count greater than the limit of" in exception.message: error = "message-too-long" + elif "Exceeded annual email sending limit" in exception.message: + error = "too-many-email-annual" + elif "Exceeded annual SMS sending limit" in exception.message: + error = "too-many-sms-annual" else: raise exception diff --git a/app/main/views/templates.py b/app/main/views/templates.py index 6c7f8a9dce..28079cca77 100644 --- a/app/main/views/templates.py +++ b/app/main/views/templates.py @@ -57,6 +57,7 @@ TemplateList, TemplateLists, ) +from app.notify_client.notification_counts_client import notification_counts_client from app.template_previews import TemplatePreview, get_page_count_for_letter from app.utils import ( email_or_sms_not_enabled, @@ -125,6 +126,31 @@ def get_char_limit_error_msg(): return _("Too many characters") +def get_limit_stats(notification_type): + # get the limit stats for the current service + limit_stats = notification_counts_client.get_limit_stats(current_service) + + # transform the stats into a format that can be used in the template + limit_stats = { + "dailyLimit": limit_stats[notification_type]["daily"]["limit"], + "dailyUsed": limit_stats[notification_type]["daily"]["sent"], + "dailyRemaining": limit_stats[notification_type]["daily"]["remaining"], + "yearlyLimit": limit_stats[notification_type]["annual"]["limit"], + "yearlyUsed": limit_stats[notification_type]["annual"]["sent"], + "yearlyRemaining": limit_stats[notification_type]["annual"]["remaining"], + "notification_type": notification_type, + "heading": _("Ready to send?"), + } + + # determine ready to send heading + if limit_stats["yearlyRemaining"] == 0: + limit_stats["heading"] = _("Sending paused until annual limit resets") + elif limit_stats["dailyRemaining"] == 0: + limit_stats["heading"] = _("Sending paused until 7pm ET. You can schedule more messages to send later.") + + return limit_stats + + @main.route("/services//templates/") @user_has_permissions() def view_template(service_id, template_id): @@ -142,6 +168,7 @@ def view_template(service_id, template_id): template=get_email_preview_template(template, template_id, service_id), template_postage=template["postage"], user_has_template_permission=user_has_template_permission, + **get_limit_stats(template["template_type"]), ) @@ -1072,6 +1099,7 @@ def delete_service_template(service_id, template_id): "views/templates/template.html", template=get_email_preview_template(template, template["id"], service_id), user_has_template_permission=True, + **get_limit_stats(template["template_type"]), ) @@ -1085,6 +1113,7 @@ def confirm_redact_template(service_id, template_id): template=get_email_preview_template(template, template["id"], service_id), user_has_template_permission=True, show_redaction_message=True, + **get_limit_stats(template["template_type"]), ) diff --git a/app/notify_client/notification_counts_client.py b/app/notify_client/notification_counts_client.py new file mode 100644 index 0000000000..d97511efd0 --- /dev/null +++ b/app/notify_client/notification_counts_client.py @@ -0,0 +1,149 @@ +from datetime import datetime + +from notifications_utils.clients.redis import ( + email_daily_count_cache_key, + sms_daily_count_cache_key, +) + +from app import redis_client, service_api_client, template_statistics_client +from app.models.service import Service + + +class NotificationCounts: + def get_all_notification_counts_for_today(self, service_id): + # try to get today's stats from redis + todays_sms = redis_client.get(sms_daily_count_cache_key(service_id)) + todays_sms = int(todays_sms) if todays_sms is not None else None + + todays_email = redis_client.get(email_daily_count_cache_key(service_id)) + todays_email = int(todays_email) if todays_email is not None else None + + if todays_sms is not None and todays_email is not None: + return {"sms": todays_sms, "email": todays_email} + # fallback to the API if the stats are not in redis + else: + stats = template_statistics_client.get_template_statistics_for_service(service_id, limit_days=1) + transformed_stats = _aggregate_notifications_stats(stats) + + return transformed_stats + + def get_all_notification_counts_for_year(self, service_id, year): + """ + Get total number of notifications by type for the current service for the current year + + Return value: + { + 'sms': int, + 'email': int + } + + """ + stats_today = self.get_all_notification_counts_for_today(service_id) + stats_this_year = service_api_client.get_monthly_notification_stats(service_id, year)["data"] + stats_this_year = _aggregate_stats_from_service_api(stats_this_year) + # aggregate stats_today and stats_this_year + for template_type in ["sms", "email"]: + stats_this_year[template_type] += stats_today[template_type] + + return stats_this_year + + def get_limit_stats(self, service: Service): + """ + Get the limit stats for the current service, by notification type, including: + - how many notifications were sent today and this year + - the monthy and daily limits + - the number of notifications remaining today and this year + Returns: + dict: A dictionary containing the limit stats for email and SMS notifications. The structure is as follows: + { + "email": { + "annual": { + "limit": int, # The annual limit for email notifications + "sent": int, # The number of email notifications sent this year + "remaining": int, # The number of email notifications remaining this year + }, + "daily": { + "limit": int, # The daily limit for email notifications + "sent": int, # The number of email notifications sent today + "remaining": int, # The number of email notifications remaining today + }, + }, + "sms": { + "annual": { + "limit": int, # The annual limit for SMS notifications + "sent": int, # The number of SMS notifications sent this year + "remaining": int, # The number of SMS notifications remaining this year + }, + "daily": { + "limit": int, # The daily limit for SMS notifications + "sent": int, # The number of SMS notifications sent today + "remaining": int, # The number of SMS notifications remaining today + }, + } + } + """ + + sent_today = self.get_all_notification_counts_for_today(service.id) + sent_thisyear = self.get_all_notification_counts_for_year(service.id, datetime.now().year) + + limit_stats = { + "email": { + "annual": { + "limit": service.email_annual_limit, + "sent": sent_thisyear["email"], + "remaining": service.email_annual_limit - sent_thisyear["email"], + }, + "daily": { + "limit": service.message_limit, + "sent": sent_today["email"], + "remaining": service.message_limit - sent_today["email"], + }, + }, + "sms": { + "annual": { + "limit": service.sms_annual_limit, + "sent": sent_thisyear["sms"], + "remaining": service.sms_annual_limit - sent_thisyear["sms"], + }, + "daily": { + "limit": service.sms_daily_limit, + "sent": sent_today["sms"], + "remaining": service.sms_daily_limit - sent_today["sms"], + }, + }, + } + + return limit_stats + + +# TODO: consolidate this function and other functions that transform the results of template_statistics_client calls +def _aggregate_notifications_stats(template_statistics): + template_statistics = _filter_out_cancelled_stats(template_statistics) + notifications = {"sms": 0, "email": 0} + for stat in template_statistics: + notifications[stat["template_type"]] += stat["count"] + + return notifications + + +def _filter_out_cancelled_stats(template_statistics): + return [s for s in template_statistics if s["status"] != "cancelled"] + + +def _aggregate_stats_from_service_api(stats): + """Aggregate monthly notification stats excluding cancelled""" + total_stats = {"sms": {}, "email": {}} + + for month_data in stats.values(): + for msg_type in ["sms", "email"]: + if msg_type in month_data: + for status, count in month_data[msg_type].items(): + if status != "cancelled": + if status not in total_stats[msg_type]: + total_stats[msg_type][status] = 0 + total_stats[msg_type][status] += count + + return {msg_type: sum(counts.values()) for msg_type, counts in total_stats.items()} + + +notification_counts_client = NotificationCounts() diff --git a/app/templates/components/remaining-messages-summary.html b/app/templates/components/remaining-messages-summary.html index 42175d47fd..b685e03aff 100644 --- a/app/templates/components/remaining-messages-summary.html +++ b/app/templates/components/remaining-messages-summary.html @@ -1,4 +1,4 @@ -{% macro remaining_messages_summary(dailyLimit, dailyUsed, yearlyLimit, yearlyUsed, notification_type, textOnly=None) %} +{% macro remaining_messages_summary(dailyLimit, dailyUsed, yearlyLimit, yearlyUsed, notification_type, headingMode=False, textOnly=None) %} {% set textOnly_allowed_values = ['text', 'emoji'] %} {% if textOnly not in textOnly_allowed_values %} @@ -95,14 +95,14 @@ {% endif %} - {% if sections[0].skip %} + {% if not headingMode and sections[0].skip %}

- Sending paused until annual limit resets + {{ _('Sending paused until annual limit resets') }}

- {% elif sections[0].remaining == "0" %} + {% elif not headingMode and sections[0].remaining == "0" %}

- Sending paused until 7pm ET. You can schedule more messages to send later. + {{ _('Sending paused until 7pm ET. You can schedule more messages to send later.') }}

{% endif %} -{% endmacro %} +{% endmacro %} \ No newline at end of file diff --git a/app/templates/partials/check/too-many-email-messages.html b/app/templates/partials/check/too-many-email-messages.html index 987846c472..9f66acd9c3 100644 --- a/app/templates/partials/check/too-many-email-messages.html +++ b/app/templates/partials/check/too-many-email-messages.html @@ -1,6 +1,6 @@ {% from "components/links.html" import content_link %} -

+

{%- if current_service.trial_mode %} {{ _("Your service is in trial mode. To send more messages, request to go live").format(url_for('main.request_to_go_live', service_id=current_service.id)) }} {% else %} diff --git a/app/templates/partials/check/too-many-messages-annual.html b/app/templates/partials/check/too-many-messages-annual.html new file mode 100644 index 0000000000..86e4cb3ae9 --- /dev/null +++ b/app/templates/partials/check/too-many-messages-annual.html @@ -0,0 +1,24 @@ +{% from "components/links.html" import content_link %} + +{% if template.template_type == 'email' %} + {% set units = _('email messages') %} +{% else %} + {% set units = _('text messages') %} +{% endif %} + +

+ {%- if current_service.trial_mode %} + {{ _("Your service is in trial mode. To send more messages, request to go live").format(url_for('main.request_to_go_live', service_id=current_service.id)) }} + {% else %} + {% if recipients_remaining_messages > 0 %} +

{{ _('{} can only send {} more {} until annual limit resets'.format(current_service.name, recipients_remaining_messages, units)) }}

+

+ {{ _('To send some of these messages now, edit the spreadsheet to {} recipients maximum. '.format(recipients_remaining_messages)) }} + {{ _('To send to recipients you removed, wait until April 1, {} or contact them some other way.'.format(now().year)) }} +

+ {% else %} +

{{ _('{} cannot send any more {} until April 1, {}'.format(current_service.name, units, now().year)) }}

+

{{ _('For more information, visit the usage report for {}.'.format(url_for('.monthly', service_id=current_service.id), current_service.name)) }}

+ {% endif %} + {%- endif -%} +

\ No newline at end of file diff --git a/app/templates/partials/check/too-many-sms-message-parts.html b/app/templates/partials/check/too-many-sms-message-parts.html index 1000fe1ac5..71cc5693a0 100644 --- a/app/templates/partials/check/too-many-sms-message-parts.html +++ b/app/templates/partials/check/too-many-sms-message-parts.html @@ -1,9 +1,9 @@ {% from "components/links.html" import content_link %} -

+

{%- if current_service.trial_mode %} {{ _("Your service is in trial mode. To send more messages, request to go live").format(url_for('main.request_to_go_live', service_id=current_service.id)) }} {% else %} {{ _("To request a daily limit above {} text messages, {}").format(current_service.sms_daily_limit, content_link(_("contact us"), url_for('main.contact'), is_external_link=true)) }} {%- endif -%} -

\ No newline at end of file +

diff --git a/app/templates/views/check/column-errors.html b/app/templates/views/check/column-errors.html index 32f2ae9e8e..1e46c28333 100644 --- a/app/templates/views/check/column-errors.html +++ b/app/templates/views/check/column-errors.html @@ -10,7 +10,9 @@ {% set prefix_txt = _('a column called') %} {% set prefix_plural_txt = _('columns called') %} -{% if send_exceeds_daily_limit and (sms_parts_remaining <= 0) %} +{% if send_exceeds_annual_limit %} + {% set page_title = _('These messages exceed the annual limit') %} +{% elif send_exceeds_daily_limit and (sms_parts_remaining <= 0) %} {% set page_title = _('These messages exceed your daily limit') %} {% elif send_exceeds_daily_limit or recipients.more_rows_than_can_send %} {% set page_title = _('These messages exceed your daily limit') %} @@ -164,24 +166,20 @@

{{ _('You cannot send all these text messages today') {{ _("You can try sending these messages after {} Eastern Time. Check {}.").format(time_to_reset[current_lang], content_link(_("your current local time"), _('https://nrc.canada.ca/en/web-clock/'), is_external_link=true))}}

- {% elif recipients.more_rows_than_can_send and false %} + {% elif send_exceeds_annual_limit %} {% call banner_wrapper(type='dangerous') %} - {% include "partials/check/too-many-email-messages.html" %} + {% include "partials/check/too-many-messages-annual.html" %} {% endcall %} {% elif recipients.more_rows_than_can_send %} - {% call banner_wrapper(type='dangerous') %} - {% include "partials/check/too-many-email-messages.html" %} - {% endcall %} -

{{ _('You cannot send all these email messages today') }}

-

- {{ _("You can try sending these messages after {} Eastern Time. Check {}.").format(time_to_reset[current_lang], - content_link(_("your current local time"), _('https://nrc.canada.ca/en/web-clock/'), is_external_link=true))}} -

- - + {% call banner_wrapper(type='dangerous') %} + {% include "partials/check/too-many-email-messages.html" %} + {% endcall %} +

{{ _('You cannot send all these email messages today') }}

+

+ {{ _("You can try sending these messages after {} Eastern Time. Check {}.").format(time_to_reset[current_lang], + content_link(_("your current local time"), _('https://nrc.canada.ca/en/web-clock/'), is_external_link=true))}} +

{% endif %} - - {% if not send_exceeds_daily_limit %} diff --git a/app/templates/views/notifications/check.html b/app/templates/views/notifications/check.html index 17c668b0c8..0161a74f17 100644 --- a/app/templates/views/notifications/check.html +++ b/app/templates/views/notifications/check.html @@ -66,6 +66,13 @@

{{_('You cannot send this email message today') }} + {% elif error == 'too-many-email-annual' or error == 'too-many-sms-annual' %} + {{ page_header(_('These messages exceed the annual limit'), back_link=back_link) }} +
+ {% call banner_wrapper(type='dangerous') %} + {% set recipients_remaining_messages = 0 %} + {% include "partials/check/too-many-messages-annual.html" %} + {% endcall %} {% elif error == 'message-too-long' %} {# the only row_errors we can get when sending one off messages is that the message is too long #} {{ govuk_back_link(back_link) }} diff --git a/app/templates/views/storybook/remaining-messages-summary.html b/app/templates/views/storybook/remaining-messages-summary.html index 694ec40b4c..f5baec4cdd 100644 --- a/app/templates/views/storybook/remaining-messages-summary.html +++ b/app/templates/views/storybook/remaining-messages-summary.html @@ -53,33 +53,33 @@

Mixed

Text only

below limit
- {{ remaining_messages_summary(10000, 700, 10000, 750, "email", "text") }} + {{ remaining_messages_summary(10000, 700, 10000, 750, "email", False, "text") }}
near limit
- {{ remaining_messages_summary(1000, 800, 1000, 900, "email", "text") }} + {{ remaining_messages_summary(1000, 800, 1000, 900, "email", False, "text") }}
at limit
- {{ remaining_messages_summary(1000, 1000, 1000, 1000, "email", "text") }} + {{ remaining_messages_summary(1000, 1000, 1000, 1000, "email", False, "text") }}

Text only emoji

below limit
- {{ remaining_messages_summary(1000, 700, 1000, 750, "email", "emoji") }} + {{ remaining_messages_summary(1000, 700, 1000, 750, "email", False, "emoji") }}
near limit
- {{ remaining_messages_summary(1000, 800, 1000, 900, "email", "emoji") }} + {{ remaining_messages_summary(1000, 800, 1000, 900, "email", False, "emoji") }}
at limit
- {{ remaining_messages_summary(1000, 1000, 1000, 1000, "email", "emoji") }} + {{ remaining_messages_summary(1000, 1000, 1000, 1000, "email", False, "emoji") }}
diff --git a/app/templates/views/templates/_template.html b/app/templates/views/templates/_template.html index 86c19a127e..918f52efc9 100644 --- a/app/templates/views/templates/_template.html +++ b/app/templates/views/templates/_template.html @@ -1,4 +1,5 @@ {% from 'components/message-count-label.html' import message_count_label %} +{% from 'components/remaining-messages-summary.html' import remaining_messages_summary with context %}
{% if template._template.archived %} @@ -13,20 +14,23 @@

{% else %} {% if current_user.has_permissions('send_messages', restrict_admin_usage=True) %} -

{{ _('Ready to send?') }}

+

{{ heading }}

- + {% if config["FF_ANNUAL_LIMIT"] %} + {{ remaining_messages_summary(dailyLimit, dailyUsed, yearlyLimit, yearlyUsed, notification_type, yearlyRemaining == 0 or dailyRemaining == 0) }} + {% endif %} + {% if not config["FF_ANNUAL_LIMIT"] or (yearlyRemaining > 0 and dailyRemaining > 0) %} + + {% endif %} {% endif %} {% endif %}
-
+
{{ template|string|translate_preview_template }} -
- - +
\ No newline at end of file diff --git a/app/translations/csv/fr.csv b/app/translations/csv/fr.csv index 57aead4f21..5c61bbcea0 100644 --- a/app/translations/csv/fr.csv +++ b/app/translations/csv/fr.csv @@ -2027,4 +2027,8 @@ "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 +"Month by month totals","Totaux mensuels" +"email messages","courriels" +"Sending paused until 7pm ET. You can schedule more messages to send later.","FR: Sending paused until 7pm ET. You can schedule more messages to send later." +"Sending paused until annual limit resets","FR: Sending paused until annual limit resets" +"These messages exceed the annual limit","FR: These messages exceed the annual limit" \ No newline at end of file diff --git a/tests/app/main/views/test_dashboard.py b/tests/app/main/views/test_dashboard.py index e972baebe8..3be02e81d6 100644 --- a/tests/app/main/views/test_dashboard.py +++ b/tests/app/main/views/test_dashboard.py @@ -1582,7 +1582,7 @@ def test_usage_report_aggregates_calculated_properly_without_redis( # mock annual_limit_client.get_all_notification_counts mocker.patch( "app.main.views.dashboard.annual_limit_client.get_all_notification_counts", - return_value=None, + return_value={"sms_delivered": 0, "email_delivered": 0, "sms_failed": 0, "email_failed": 0}, ) mocker.patch( diff --git a/tests/app/main/views/test_send.py b/tests/app/main/views/test_send.py index 78df4b10cc..a5aeb2db72 100644 --- a/tests/app/main/views/test_send.py +++ b/tests/app/main/views/test_send.py @@ -6,6 +6,7 @@ from io import BytesIO from itertools import repeat from os import path +from unittest.mock import patch from uuid import uuid4 from zipfile import BadZipFile @@ -41,6 +42,7 @@ mock_get_service_letter_template, mock_get_service_template, normalize_spaces, + set_config, ) template_types = ["email", "sms"] @@ -2543,6 +2545,7 @@ def test_check_messages_shows_too_many_sms_messages_errors( mock_get_jobs, mock_s3_download, mock_s3_set_metadata, + mock_get_limit_stats, fake_uuid, num_requested, expected_msg, @@ -2584,6 +2587,30 @@ def test_check_messages_shows_too_many_sms_messages_errors( assert details == expected_msg +@pytest.fixture +def mock_notification_counts_client(): + with patch("app.main.views.send.notification_counts_client") as mock: + yield mock + + +@pytest.fixture +def mock_daily_sms_fragment_count(): + with patch("app.main.views.send.daily_sms_fragment_count") as mock: + yield mock + + +@pytest.fixture +def mock_daily_email_count(): + with patch("app.main.views.send.daily_email_count") as mock: + yield mock + + +@pytest.fixture +def mock_get_service_template_annual_limits(): + with patch("app.service_api_client.get_service_template") as mock: + yield mock + + @pytest.mark.parametrize( "num_requested,expected_msg", [ @@ -2601,6 +2628,7 @@ def test_check_messages_shows_too_many_email_messages_errors( mock_get_template_statistics, mock_get_job_doesnt_exist, mock_get_jobs, + mock_get_limit_stats, fake_uuid, num_requested, expected_msg, @@ -2723,49 +2751,6 @@ def test_warns_if_file_sent_already( mock_get_jobs.assert_called_once_with(SERVICE_ONE_ID, limit_days=0) -def test_check_messages_column_error_doesnt_show_optional_columns( - mocker, - client_request, - mock_get_service_letter_template, - mock_has_permissions, - fake_uuid, - mock_get_users_by_service, - mock_get_service_statistics, - mock_get_template_statistics, - mock_get_job_doesnt_exist, - mock_get_jobs, -): - mocker.patch( - "app.main.views.send.s3download", - return_value="\n".join(["address_line_1,address_line_2,foo"] + ["First Lastname,1 Example Road,SW1 1AA"]), - ) - - mocker.patch( - "app.main.views.send.get_page_count_for_letter", - return_value=5, - ) - - with client_request.session_transaction() as session: - session["file_uploads"] = { - fake_uuid: { - "template_id": "", - "original_file_name": "", - } - } - - page = client_request.get( - "main.check_messages", - service_id=SERVICE_ONE_ID, - template_id=fake_uuid, - upload_id=fake_uuid, - _test_page_title=False, - ) - - assert normalize_spaces(page.select_one(".banner-dangerous").text) == ( - "Your spreadsheet is missing a column called ‘postcode’. " "Add the missing column." - ) - - def test_check_messages_adds_sender_id_in_session_to_metadata( client_request, mocker, @@ -3401,3 +3386,296 @@ class Object(object): multiple_choise_options = [x.text.strip() for x in options] assert multiple_choise_options == expected_filenames + + +class TestAnnualLimitsSend: + @pytest.mark.parametrize( + "num_being_sent, num_sent_today, num_sent_this_year, expect_to_see_annual_limit_msg, expect_to_see_daily_limit_msg", + [ + # annual limit for mock_get_live_service is 10,000email/10,000sms + # daily limit for mock_get_live_service is 1,000email/1,000sms + # 1000 have already been sent today, trying to send 100 more [over both limits] + (100, 1000, 10000, True, False), + # No sent yet today or this year, trying to send 1001 [over both limits] + (10001, 0, 0, True, False), + # 600 have already been sent this year, trying to send 500 more [over annual limit but not daily] + (500, 0, 9600, True, False), + # No sent yet today or this year, trying to send 1001 [over daily limit but not annual] + (1001, 0, 0, False, True), + # No sent yet today or this year, trying to send 100 [over neither limit] + (100, 0, 0, False, False), + ], + ids=[ + "email_over_both_limits", + "email_over_both_limits2", + "email_over_annual_but_not_daily", + "email_over_daily_but_not_annual", + "email_over_neither", + ], + ) + def test_email_send_fails_approrpiately_when_over_limits( + self, + mocker, + client_request, + mock_get_live_service, # set email_annual_limit and sms_annual_limit to 1000 + mock_get_users_by_service, + mock_get_service_email_template_without_placeholders, + mock_get_template_statistics, + mock_get_job_doesnt_exist, + mock_get_jobs, + mock_s3_set_metadata, + mock_notification_counts_client, + mock_daily_sms_fragment_count, + mock_daily_email_count, + fake_uuid, + num_being_sent, + num_sent_today, + num_sent_this_year, + expect_to_see_annual_limit_msg, + expect_to_see_daily_limit_msg, + app_, + ): + with set_config(app_, "FF_ANNUAL_LIMIT", True): + mocker.patch( + "app.main.views.send.s3download", + return_value=",\n".join( + ["email address"] + ([mock_get_users_by_service(None)[0]["email_address"]] * num_being_sent) + ), + ) + + mock_notification_counts_client.get_limit_stats.return_value = { + "email": { + "annual": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": 10000 + - num_sent_this_year + - num_sent_today, # The number of email notifications remaining this year + }, + "daily": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": 1000 - num_sent_today, # The number of email notifications remaining today + }, + } + } + + # mock that we've already sent `emails_sent_today` emails today + mock_daily_email_count.return_value = num_sent_today + mock_daily_sms_fragment_count.return_value = 900 # not used in test but needs a value + + with client_request.session_transaction() as session: + session["file_uploads"] = { + fake_uuid: { + "template_id": fake_uuid, + "notification_count": 1, + "valid": True, + } + } + + page = client_request.get( + "main.check_messages", + service_id=SERVICE_ONE_ID, + template_id=fake_uuid, + upload_id=fake_uuid, + original_file_name="valid.csv", + _test_page_title=False, + ) + + if expect_to_see_annual_limit_msg: + assert page.find(attrs={"data-testid": "exceeds-annual"}) is not None + else: + assert page.find(attrs={"data-testid": "exceeds-annual"}) is None + + if expect_to_see_daily_limit_msg: + assert page.find(attrs={"data-testid": "exceeds-daily"}) is not None + else: + assert page.find(attrs={"data-testid": "exceeds-daily"}) is None + + @pytest.mark.parametrize( + "num_being_sent, num_sent_today, num_sent_this_year, expect_to_see_annual_limit_msg, expect_to_see_daily_limit_msg", + [ + # annual limit for mock_get_live_service is 10,000email/10,000sms + # daily limit for mock_get_live_service is 1,000email/1,000sms + # 1000 have already been sent today, trying to send 100 more [over both limits] + (100, 1000, 10000, True, False), + # No sent yet today or this year, trying to send 1001 [over both limits] + (10001, 0, 0, True, False), + # 600 have already been sent this year, trying to send 500 more [over annual limit but not daily] + (500, 0, 9600, True, False), + # No sent yet today or this year, trying to send 1001 [over daily limit but not annual] + (1001, 0, 0, False, True), + # No sent yet today or this year, trying to send 100 [over neither limit] + (100, 0, 0, False, False), + ], + ids=[ + "sms_over_both_limits", + "sms_over_both_limits2", + "sms_over_annual_but_not_daily", + "sms_over_daily_but_not_annual", + "sms_over_neither", + ], + ) + def test_sms_send_fails_approrpiately_when_over_limits( + self, + mocker, + client_request, + mock_get_live_service, # set email_annual_limit and sms_annual_limit to 1000 + mock_get_users_by_service, + mock_get_service_sms_template_without_placeholders, + mock_get_template_statistics, + mock_get_job_doesnt_exist, + mock_get_jobs, + mock_s3_set_metadata, + mock_notification_counts_client, + mock_daily_sms_fragment_count, + mock_daily_email_count, + fake_uuid, + num_being_sent, + num_sent_today, + num_sent_this_year, + expect_to_see_annual_limit_msg, + expect_to_see_daily_limit_msg, + app_, + ): + with set_config(app_, "FF_ANNUAL_LIMIT", True): # REMOVE LINE WHEN FF REMOVED + mocker.patch( + "app.main.views.send.s3download", + return_value=",\n".join( + ["phone number"] + ([mock_get_users_by_service(None)[0]["mobile_number"]] * num_being_sent) + ), + ) + mock_notification_counts_client.get_limit_stats.return_value = { + "sms": { + "annual": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": 10000 + - num_sent_this_year + - num_sent_today, # The number of email notifications remaining this year + }, + "daily": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": 1000 - num_sent_today, # The number of email notifications remaining today + }, + } + } + # mock that we've already sent `num_sent_today` emails today + mock_daily_email_count.return_value = 900 # not used in test but needs a value + mock_daily_sms_fragment_count.return_value = num_sent_today + + with client_request.session_transaction() as session: + session["file_uploads"] = { + fake_uuid: { + "template_id": fake_uuid, + "notification_count": 1, + "valid": True, + } + } + + page = client_request.get( + "main.check_messages", + service_id=SERVICE_ONE_ID, + template_id=fake_uuid, + upload_id=fake_uuid, + original_file_name="valid.csv", + _test_page_title=False, + ) + + if expect_to_see_annual_limit_msg: + assert page.find(attrs={"data-testid": "exceeds-annual"}) is not None + else: + assert page.find(attrs={"data-testid": "exceeds-annual"}) is None + + if expect_to_see_daily_limit_msg: + assert page.find(attrs={"data-testid": "exceeds-daily"}) is not None + else: + assert page.find(attrs={"data-testid": "exceeds-daily"}) is None + + @pytest.mark.parametrize( + "num_to_send, remaining_daily, remaining_annual, error_shown", + [ + (2, 2, 2, "none"), + (5, 5, 4, "annual"), + (5, 4, 5, "daily"), + (5, 4, 4, "annual"), + ], + ) + def test_correct_error_displayed( + self, + mocker, + client_request, + mock_get_live_service, # set email_annual_limit and sms_annual_limit to 1000 + mock_get_users_by_service, + mock_get_service_email_template_without_placeholders, + mock_get_template_statistics, + mock_get_job_doesnt_exist, + mock_get_jobs, + mock_s3_set_metadata, + mock_daily_email_count, + mock_notification_counts_client, + fake_uuid, + num_to_send, + remaining_daily, + remaining_annual, + error_shown, + app_, + ): + with set_config(app_, "FF_ANNUAL_LIMIT", True): # REMOVE LINE WHEN FF REMOVED + # mock that `num_sent_this_year` have already been sent this year + mock_notification_counts_client.get_limit_stats.return_value = { + "email": { + "annual": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": remaining_annual, # The number of email notifications remaining this year + }, + "daily": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": remaining_daily, # The number of email notifications remaining today + }, + } + } + + # only change this value when we're expecting an error + if error_shown != "none": + mock_daily_email_count.return_value = 1000 - ( + num_to_send - 1 + ) # svc limit is 1000 - exceeding the daily limit is calculated based off of this + else: + mock_daily_email_count.return_value = 0 # none sent + + mocker.patch( + "app.main.views.send.s3download", + return_value=",\n".join( + ["email address"] + ([mock_get_users_by_service(None)[0]["email_address"]] * num_to_send) + ), + ) + with client_request.session_transaction() as session: + session["file_uploads"] = { + fake_uuid: { + "template_id": fake_uuid, + "notification_count": 1, + "valid": True, + } + } + page = client_request.get( + "main.check_messages", + service_id=SERVICE_ONE_ID, + template_id=fake_uuid, + upload_id=fake_uuid, + original_file_name="valid.csv", + _test_page_title=False, + ) + + if error_shown == "annual": + assert page.find(attrs={"data-testid": "exceeds-annual"}) is not None + assert page.find(attrs={"data-testid": "exceeds-daily"}) is None + elif error_shown == "daily": + assert page.find(attrs={"data-testid": "exceeds-annual"}) is None + assert page.find(attrs={"data-testid": "exceeds-daily"}) is not None + elif error_shown == "none": + assert page.find(attrs={"data-testid": "exceeds-annual"}) is None + assert page.find(attrs={"data-testid": "exceeds-daily"}) is None diff --git a/tests/app/main/views/test_templates.py b/tests/app/main/views/test_templates.py index b48234af1f..333dced2a8 100644 --- a/tests/app/main/views/test_templates.py +++ b/tests/app/main/views/test_templates.py @@ -1,6 +1,6 @@ from datetime import datetime from functools import partial -from unittest.mock import ANY, MagicMock, Mock +from unittest.mock import ANY, MagicMock, Mock, patch import pytest from flask import url_for @@ -49,11 +49,18 @@ fake_uuid, mock_get_service_template_with_process_type, normalize_spaces, + set_config, ) DEFAULT_PROCESS_TYPE = TemplateProcessTypes.BULK.value +@pytest.fixture +def mock_notification_counts_client(): + with patch("app.main.views.templates.notification_counts_client") as mock: + yield mock + + class TestRedisPreviewUtilities: def test_set_get(self, fake_uuid, mocker): mock_redis_obj = MockRedis() @@ -113,6 +120,7 @@ def test_create_email_template_cat_other_to_freshdesk( mock_get_service_template_when_no_template_exists, mock_get_template_categories, mock_send_other_category_to_freshdesk, + mock_get_limit_stats, active_user_with_permissions, fake_uuid, app_, @@ -147,6 +155,7 @@ def test_edit_email_template_cat_other_to_freshdesk( mock_get_template_categories, mock_update_service_template, mock_send_other_category_to_freshdesk, + mock_get_limit_stats, active_user_with_permissions, fake_uuid, app_, @@ -490,7 +499,13 @@ def test_should_show_page_for_one_template( def test_caseworker_redirected_to_one_off( - client_request, mock_get_service_templates, mock_get_service_template, mocker, fake_uuid, active_caseworking_user + client_request, + mock_get_service_templates, + mock_get_service_template, + mock_get_limit_stats, + mocker, + fake_uuid, + active_caseworking_user, ): client_request.login(active_caseworking_user) client_request.get( @@ -510,6 +525,7 @@ def test_user_with_only_send_and_view_redirected_to_one_off( client_request, mock_get_service_templates, mock_get_service_template, + mock_get_limit_stats, active_user_with_permissions, mocker, fake_uuid, @@ -532,40 +548,6 @@ def test_user_with_only_send_and_view_redirected_to_one_off( ) -@pytest.mark.parametrize( - "permissions", - ( - {"send_messages", "view_activity"}, - {"send_messages"}, - {"view_activity"}, - {}, - ), -) -def test_user_with_only_send_and_view_sees_letter_page( - client_request, - mock_get_service_templates, - mock_get_template_folders, - mock_get_service_letter_template, - single_letter_contact_block, - mock_has_jobs, - active_user_with_permissions, - mocker, - fake_uuid, - permissions, -): - mocker.patch("app.main.views.templates.get_page_count_for_letter", return_value=1) - active_user_with_permissions["permissions"][SERVICE_ONE_ID] = permissions - client_request.login(active_user_with_permissions) - page = client_request.get( - "main.view_template", - service_id=SERVICE_ONE_ID, - template_id=fake_uuid, - _test_page_title=False, - ) - assert normalize_spaces(page.select_one("h1").text) == ("Two week reminder") - assert normalize_spaces(page.select_one("title").text) == ("Two week reminder – Templates - service one – Notify") - - @pytest.mark.parametrize( "letter_branding, expected_link, expected_link_text", ( @@ -610,46 +592,11 @@ def test_letter_with_default_branding_has_add_logo_button( assert first_edit_link.text == expected_link_text -@pytest.mark.parametrize( - "template_postage,expected_result", - [ - ("first", "Postage: first class"), - ("second", "Postage: second class"), - ], -) -def test_view_letter_template_displays_postage( - client_request, - service_one, - mock_get_service_templates, - mock_get_template_folders, - single_letter_contact_block, - mock_has_jobs, - active_user_with_permissions, - mocker, - fake_uuid, - template_postage, - expected_result, -): - mocker.patch("app.main.views.templates.get_page_count_for_letter", return_value=1) - client_request.login(active_user_with_permissions) - - template = create_letter_template(postage=template_postage) - mocker.patch("app.service_api_client.get_service_template", return_value=template) - - page = client_request.get( - "main.view_template", - service_id=SERVICE_ONE_ID, - template_id=template["data"]["id"], - _test_page_title=False, - ) - - assert normalize_spaces(page.select_one(".letter-postage").text) == expected_result - - def test_view_non_letter_template_does_not_display_postage( client_request, mock_get_service_template, mock_get_template_folders, + mock_get_limit_stats, fake_uuid, ): page = client_request.get( @@ -740,6 +687,7 @@ def test_should_be_able_to_view_a_template_with_links( client_request, mock_get_service_template, mock_get_template_folders, + mock_get_limit_stats, active_user_with_permissions, single_letter_contact_block, fake_uuid, @@ -777,6 +725,7 @@ def test_should_show_template_id_on_template_page( mock_get_service_template, mock_get_template_folders, fake_uuid, + mock_get_limit_stats, ): page = client_request.get( ".view_template", @@ -792,6 +741,7 @@ def test_should_show_logos_on_template_page( fake_uuid, mocker, service_one, + mock_get_limit_stats, app_, ): mocker.patch( @@ -817,6 +767,7 @@ def test_should_not_show_send_buttons_on_template_page_for_user_without_permissi client_request, fake_uuid, mock_get_service_template, + mock_get_limit_stats, active_user_view_permissions, ): client_request.login(active_user_view_permissions) @@ -838,6 +789,7 @@ def test_should_show_sms_template_with_downgraded_unicode_characters( service_one, single_letter_contact_block, mock_get_template_folders, + mock_get_limit_stats, fake_uuid, ): msg = "here:\tare some “fancy quotes” and zero\u200bwidth\u200bspaces" @@ -1335,6 +1287,7 @@ def test_should_redirect_when_saving_a_template( client_request, mock_get_template_categories, mock_update_service_template, + mock_get_limit_stats, fake_uuid, app_, mocker, @@ -2032,6 +1985,7 @@ def test_should_show_delete_template_page_with_time_block( client_request, mock_get_service_template, mock_get_template_folders, + mock_get_limit_stats, mocker, fake_uuid, ): @@ -2060,11 +2014,7 @@ def test_should_show_delete_template_page_with_time_block( def test_should_show_delete_template_page_with_time_block_for_empty_notification( - client_request, - mock_get_service_template, - mock_get_template_folders, - mocker, - fake_uuid, + client_request, mock_get_service_template, mock_get_template_folders, mocker, fake_uuid, mock_get_limit_stats ): with freeze_time("2012-01-08 12:00:00"): template = template_json("1234", "1234", "Test template", "sms", "Something very interesting") @@ -2095,6 +2045,7 @@ def test_should_show_delete_template_page_with_never_used_block( client_request, mock_get_service_template, mock_get_template_folders, + mock_get_limit_stats, fake_uuid, mocker, ): @@ -2168,6 +2119,7 @@ def test_should_show_page_for_a_deleted_template( mock_get_user, mock_get_user_by_email, mock_has_permissions, + mock_notification_counts_client, fake_uuid, ): template_id = fake_uuid @@ -2214,6 +2166,7 @@ def test_route_permissions( mock_get_template_folders, mock_get_template_statistics_for_template, mock_get_template_categories, + mock_get_limit_stats, fake_uuid, ): validate_route_permission( @@ -2323,6 +2276,7 @@ def test_can_create_email_template_with_emoji( mock_get_template_folders, mock_get_service_template_when_no_template_exists, mock_get_template_categories, + mock_get_limit_stats, app_, ): page = client_request.post( @@ -2365,6 +2319,7 @@ def test_create_template_with_process_types( mock_get_template_folders, mock_get_service_template_when_no_template_exists, mock_get_template_categories, + mock_get_limit_stats, app_, mocker, platform_admin_user, @@ -2478,6 +2433,7 @@ def test_should_create_sms_template_without_downgrading_unicode_characters( def test_should_show_message_before_redacting_template( client_request, mock_get_service_template, + mock_get_limit_stats, service_one, fake_uuid, ): @@ -2501,6 +2457,7 @@ def test_should_show_redact_template( mock_get_service_template, mock_get_template_folders, mock_redact_template, + mock_get_limit_stats, single_letter_contact_block, service_one, fake_uuid, @@ -2524,6 +2481,7 @@ def test_should_show_hint_once_template_redacted( mocker, service_one, mock_get_template_folders, + mock_get_limit_stats, fake_uuid, ): template = create_template(redact_personalisation=True) @@ -2539,27 +2497,6 @@ def test_should_show_hint_once_template_redacted( assert page.select(".hint")[0].text.strip() == "Recipients' information will be redacted from system" -def test_should_not_show_redaction_stuff_for_letters( - client_request, - mocker, - fake_uuid, - mock_get_service_letter_template, - mock_get_template_folders, - single_letter_contact_block, -): - mocker.patch("app.main.views.templates.get_page_count_for_letter", return_value=1) - - page = client_request.get( - "main.view_template", - service_id=SERVICE_ONE_ID, - template_id=fake_uuid, - _test_page_title=False, - ) - - assert page.select(".hint") == [] - assert "personalisation" not in " ".join(link.text.lower() for link in page.select("a")) - - def test_set_template_sender( client_request, fake_uuid, @@ -2677,6 +2614,7 @@ def test_template_should_show_email_address_in_correct_language( client_request, mock_get_service_email_template, mock_get_template_folders, + mock_get_limit_stats, fake_uuid, ): # check english @@ -2705,6 +2643,7 @@ def test_template_should_show_phone_number_in_correct_language( client_request, mock_get_service_template, mock_get_template_folders, + mock_get_limit_stats, fake_uuid, ): # check english @@ -2742,3 +2681,66 @@ def test_should_hide_category_name_from_template_list_if_marked_hidden( # assert that "HIDDEN_CATEGORY" is not found anywhere in the page using beautifulsoup assert "HIDDEN_CATEGORY" not in page.text assert not page.find(text="HIDDEN_CATEGORY") + + +class TestAnnualLimits: + @pytest.mark.parametrize( + "remaining_daily, remaining_annual, buttons_shown", + [ + (10, 100, True), # Within both limits + (0, 100, False), # Exceeds daily limit + (10, 0, False), # Exceeds annual limit + (0, 0, False), # Exceeds both limits + (1, 1, True), # Exactly at both limits + ], + ) + def test_should_hide_send_buttons_when_appropriate( + self, + client_request, + mock_get_service_template, + mock_get_template_folders, + mock_notification_counts_client, + fake_uuid, + remaining_daily, + remaining_annual, + buttons_shown, + app_, + ): + with set_config(app_, "FF_ANNUAL_LIMIT", True): # REMOVE LINE WHEN FF REMOVED + mock_notification_counts_client.get_limit_stats.return_value = { + "email": { + "annual": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": remaining_annual, # The number of email notifications remaining this year + }, + "daily": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": remaining_daily, # The number of email notifications remaining today + }, + }, + "sms": { + "annual": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": remaining_annual, # The number of email notifications remaining this year + }, + "daily": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": remaining_daily, # The number of email notifications remaining today + }, + }, + } + + page = client_request.get( + ".view_template", + service_id=SERVICE_ONE_ID, + template_id=fake_uuid, + _test_page_title=False, + ) + if buttons_shown: + assert page.find(attrs={"data-testid": "send-buttons"}) is not None + else: + assert page.find(attrs={"data-testid": "send-buttons"}) is None diff --git a/tests/app/notify_client/test_notification_counts_client.py b/tests/app/notify_client/test_notification_counts_client.py new file mode 100644 index 0000000000..3d4f510bca --- /dev/null +++ b/tests/app/notify_client/test_notification_counts_client.py @@ -0,0 +1,202 @@ +from datetime import datetime +from unittest.mock import Mock, patch + +import pytest + +from app.notify_client.notification_counts_client import NotificationCounts + + +@pytest.fixture +def mock_redis(): + with patch("app.notify_client.notification_counts_client.redis_client") as mock: + yield mock + + +@pytest.fixture +def mock_template_stats(): + with patch("app.notify_client.notification_counts_client.template_statistics_client") as mock: + yield mock + + +@pytest.fixture +def mock_service_api(): + with patch("app.notify_client.notification_counts_client.service_api_client") as mock: + yield mock + + +@pytest.fixture +def mock_get_all_notification_counts_for_today(): + with patch("app.notify_client.notification_counts_client.get_all_notification_counts_for_today") as mock: + yield mock + + +class TestNotificationCounts: + def test_get_all_notification_counts_for_today_redis_has_data(self, mock_redis): + # Setup + mock_redis.get.side_effect = [5, 10] # sms, email + wrapper = NotificationCounts() + + # Execute + result = wrapper.get_all_notification_counts_for_today("service-123") + + # Assert + assert result == {"sms": 5, "email": 10} + assert mock_redis.get.call_count == 2 + + @pytest.mark.parametrize( + "redis_side_effect, expected_result", + [ + ([None, None], {"sms": 10, "email": 10}), + ([None, 10], {"sms": 10, "email": 10}), # Falls back to API if either is None + ([10, None], {"sms": 10, "email": 10}), # Falls back to API if either is None + ([25, 25], {"sms": 25, "email": 25}), # Falls back to API if either is None + ], + ) + def test_get_all_notification_counts_for_today_redis_missing_data( + self, mock_redis, mock_template_stats, redis_side_effect, expected_result + ): + # Setup + mock_redis.get.side_effect = redis_side_effect + mock_template_stats.get_template_statistics_for_service.return_value = [ + {"template_id": "a1", "template_type": "sms", "count": 3, "status": "delivered"}, + {"template_id": "a2", "template_type": "email", "count": 7, "status": "temporary-failure"}, + {"template_id": "a3", "template_type": "email", "count": 3, "status": "delivered"}, + {"template_id": "a4", "template_type": "sms", "count": 7, "status": "delivered"}, + ] + + wrapper = NotificationCounts() + + # Execute + result = wrapper.get_all_notification_counts_for_today("service-123") + + # Assert + assert result == expected_result + + if None in redis_side_effect: + mock_template_stats.get_template_statistics_for_service.assert_called_once() + + def test_get_all_notification_counts_for_year(self, mock_service_api): + # Setup + mock_service_api.get_monthly_notification_stats.return_value = { + "data": { + "2024-01": { + "sms": {"sent": 1, "temporary-failure:": 22}, + "email": {"delivered": 1, "permanent-failure": 1, "sending": 12, "technical-failure": 1}, + }, + "2024-02": {"sms": {"sent": 1}, "email": {"delivered": 1}}, + } + } + wrapper = NotificationCounts() + + with patch.object(wrapper, "get_all_notification_counts_for_today") as mock_today: + mock_today.return_value = {"sms": 5, "email": 5} + + # Execute + result = wrapper.get_all_notification_counts_for_year("service-123", 2024) + + # Assert + assert result["sms"] == 29 # 1 + 22 + 1 + 5 + assert result["email"] == 21 # 1 + 1 + 12 + 1 + 1 + 5 + + def test_get_limit_stats(self, mocker): + # Setup + mock_service = Mock(id="service-1", email_annual_limit=1000, sms_annual_limit=500, message_limit=100, sms_daily_limit=50) + + mock_notification_client = NotificationCounts() + + # Mock the dependency methods + + mocker.patch.object( + mock_notification_client, "get_all_notification_counts_for_today", return_value={"email": 20, "sms": 10} + ) + mocker.patch.object( + mock_notification_client, "get_all_notification_counts_for_year", return_value={"email": 200, "sms": 100} + ) + + # Execute + result = mock_notification_client.get_limit_stats(mock_service) + + # Assert + assert result == { + "email": { + "annual": { + "limit": 1000, + "sent": 200, + "remaining": 800, + }, + "daily": { + "limit": 100, + "sent": 20, + "remaining": 80, + }, + }, + "sms": { + "annual": { + "limit": 500, + "sent": 100, + "remaining": 400, + }, + "daily": { + "limit": 50, + "sent": 10, + "remaining": 40, + }, + }, + } + + @pytest.mark.parametrize( + "today_counts,year_counts,expected_remaining", + [ + ( + {"email": 0, "sms": 0}, + {"email": 0, "sms": 0}, + {"email": {"annual": 1000, "daily": 100}, "sms": {"annual": 500, "daily": 50}}, + ), + ( + {"email": 100, "sms": 50}, + {"email": 1000, "sms": 500}, + {"email": {"annual": 0, "daily": 0}, "sms": {"annual": 0, "daily": 0}}, + ), + ( + {"email": 50, "sms": 25}, + {"email": 500, "sms": 250}, + {"email": {"annual": 500, "daily": 50}, "sms": {"annual": 250, "daily": 25}}, + ), + ], + ) + def test_get_limit_stats_remaining_calculations(self, mocker, today_counts, year_counts, expected_remaining): + # Setup + mock_service = Mock(id="service-1", email_annual_limit=1000, sms_annual_limit=500, message_limit=100, sms_daily_limit=50) + + mock_notification_client = NotificationCounts() + + mocker.patch.object(mock_notification_client, "get_all_notification_counts_for_today", return_value=today_counts) + mocker.patch.object(mock_notification_client, "get_all_notification_counts_for_year", return_value=year_counts) + + # Execute + result = mock_notification_client.get_limit_stats(mock_service) + + # Assert remaining counts + assert result["email"]["annual"]["remaining"] == expected_remaining["email"]["annual"] + assert result["email"]["daily"]["remaining"] == expected_remaining["email"]["daily"] + assert result["sms"]["annual"]["remaining"] == expected_remaining["sms"]["annual"] + assert result["sms"]["daily"]["remaining"] == expected_remaining["sms"]["daily"] + + def test_get_limit_stats_dependencies_called(self, mocker): + # Setup + mock_service = Mock(id="service-1", email_annual_limit=1000, sms_annual_limit=500, message_limit=100, sms_daily_limit=50) + mock_notification_client = NotificationCounts() + + mock_today = mocker.patch.object( + mock_notification_client, "get_all_notification_counts_for_today", return_value={"email": 0, "sms": 0} + ) + mock_year = mocker.patch.object( + mock_notification_client, "get_all_notification_counts_for_year", return_value={"email": 0, "sms": 0} + ) + + # Execute + mock_notification_client.get_limit_stats(mock_service) + + # Assert dependencies called + mock_today.assert_called_once_with(mock_service.id) + mock_year.assert_called_once_with(mock_service.id, datetime.now().year) diff --git a/tests/conftest.py b/tests/conftest.py index 94ff0ca5be..a491717587 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -550,7 +550,14 @@ def fake_uuid(): @pytest.fixture(scope="function") def mock_get_service(mocker, api_user_active): def _get(service_id): - service = service_json(service_id, users=[api_user_active["id"]], message_limit=50, sms_daily_limit=20) + service = service_json( + service_id, + users=[api_user_active["id"]], + message_limit=50, + sms_daily_limit=20, + email_annual_limit=1000, + sms_annual_limit=1000, + ) return {"data": service} return mocker.patch("app.service_api_client.get_service", side_effect=_get) @@ -675,7 +682,9 @@ def mock_service_email_from_is_unique(mocker): @pytest.fixture(scope="function") def mock_get_live_service(mocker, api_user_active): def _get(service_id): - service = service_json(service_id, users=[api_user_active["id"]], restricted=False) + service = service_json( + service_id, users=[api_user_active["id"]], restricted=False, sms_annual_limit=10000, email_annual_limit=10000 + ) return {"data": service} return mocker.patch("app.service_api_client.get_service", side_effect=_get) @@ -971,6 +980,21 @@ def _get(service_id, template_id, version=None): return mocker.patch("app.service_api_client.get_service_template", side_effect=_get) +@pytest.fixture(scope="function") +def mock_get_service_sms_template_without_placeholders(mocker): + def _get(service_id, template_id, version=None): + template = template_json( + service_id, + template_id, + "Two week reminder", + "sms", + "Yo.", + ) + return {"data": template} + + return mocker.patch("app.service_api_client.get_service_template", side_effect=_get) + + @pytest.fixture(scope="function") def mock_get_service_letter_template(mocker, content=None, subject=None, postage="second"): def _get(service_id, template_id, version=None, postage=postage): @@ -1123,6 +1147,39 @@ def _update( return mocker.patch("app.service_api_client.update_service_template", side_effect=_update) +@pytest.fixture(scope="function") +def mock_get_limit_stats(mocker): + def _get_data(svc): + return { + "email": { + "annual": { + "limit": 1000, + "sent": 10, + "remaining": 990, + }, + "daily": { + "limit": 100, + "sent": 5, + "remaining": 95, + }, + }, + "sms": { + "annual": { + "limit": 1000, + "sent": 10, + "remaining": 990, + }, + "daily": { + "limit": 100, + "sent": 5, + "remaining": 95, + }, + }, + } + + return mocker.patch("app.main.views.templates.notification_counts_client.get_limit_stats", side_effect=_get_data) + + def create_template( service_id=SERVICE_ONE_ID, template_id=None,