+ {% 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,