From 431772c95c22d242376c4d1c4a2f90cd9802c778 Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Tue, 26 Nov 2024 18:30:15 +0000
Subject: [PATCH 01/28] feat: validate annual limit
---
app/main/views/send.py | 43 +++++-
app/notify_client/annual_limit_client.py | 134 ++++++++++++++++++
.../template_statistics_api_client.py | 46 ++++++
.../check/too-many-email-messages.html | 8 +-
app/templates/views/check/column-errors.html | 6 +-
pyproject.toml | 2 +-
6 files changed, 227 insertions(+), 12 deletions(-)
create mode 100644 app/notify_client/annual_limit_client.py
diff --git a/app/main/views/send.py b/app/main/views/send.py
index 2a5720e38b..bc7ce03cfb 100644
--- a/app/main/views/send.py
+++ b/app/main/views/send.py
@@ -1,5 +1,6 @@
import itertools
import json
+from datetime import datetime, timezone
from string import ascii_uppercase
from zipfile import BadZipFile
@@ -53,6 +54,7 @@
)
from app.main.views.dashboard import aggregate_notifications_stats
from app.models.user import Users
+from app.notify_client.annual_limit_client import annual_limit_client
from app.s3_client.s3_csv_client import (
copy_bulk_send_file_to_uploads,
list_bulk_send_uploads,
@@ -649,8 +651,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,9 +661,27 @@ 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
)
+
+ # Override the remaining messages counts with the remaining annual counts, if the latter are lower
+ over_annual_limit = False
+ if current_app.config["FF_ANNUAL_LIMIT"]:
+ stats_ytd = annual_limit_client.get_all_notification_counts_ytd(service_id, datetime.now(timezone.utc).year)
+ remaining_sms_this_year = current_service.sms_annual_limit - stats_ytd["sms"]["requested"]
+ remaining_email_this_year = current_service.email_annual_limit - stats_ytd["email"]["requested"]
+
+ # if this is an email send
+ if db_template["template_type"] == "email":
+ if remaining_email_this_year < remaining_email_messages_today:
+ recipients_remaining_messages = remaining_email_this_year
+ over_annual_limit = True
+ else:
+ if remaining_sms_this_year < remaining_sms_message_fragments_today:
+ recipients_remaining_messages = remaining_sms_this_year
+ over_annual_limit = True
+
if db_template["template_type"] == "email":
email_reply_to = get_email_reply_to_address_from_session()
elif db_template["template_type"] == "sms":
@@ -743,8 +763,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,
@@ -764,6 +784,7 @@ def _check_messages(service_id, template_id, upload_id, preview_row, letters_as_
db_template["version"],
request.args.get("original_file_name", ""),
),
+ over_annual_limit=over_annual_limit
)
@@ -783,7 +804,15 @@ 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"]:
+ # Show annual limit validation over the daily one (even if both are true)
+ if data["over_annual_limit"]:
+ data["send_exceeds_annual_limit"] = True
+ else:
+ 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
@@ -801,7 +830,7 @@ def check_messages(service_id, template_id, upload_id, row_index=2):
if data["errors"] or data["trying_to_send_letters_in_trial_mode"]:
return render_template("views/check/column-errors.html", **data)
- if data["send_exceeds_daily_limit"]:
+ if data["send_exceeds_daily_limit"] or data["send_exceeds_annual_limit"]:
return render_template("views/check/column-errors.html", **data)
metadata_kwargs = {
diff --git a/app/notify_client/annual_limit_client.py b/app/notify_client/annual_limit_client.py
new file mode 100644
index 0000000000..f47411051b
--- /dev/null
+++ b/app/notify_client/annual_limit_client.py
@@ -0,0 +1,134 @@
+from notifications_utils.clients.redis.annual_limit import RedisAnnualLimit
+from notifications_utils.clients.redis.redis_client import RedisClient
+
+from app import service_api_client, template_statistics_client
+from app.utils import DELIVERED_STATUSES, FAILURE_STATUSES
+
+
+class AnnualLimitWrapper:
+ def __init__(self, client):
+ self.client = client
+
+
+ def get_all_notification_counts_for_today(self, service_id):
+ """
+ Get total number of notifications by type for the current service
+
+ Return value:
+ {
+ 'sms': {'requested': int, 'delivered': int, 'failed': int}
+ 'email': {'requested': int, 'delivered': int, 'failed': int}
+ }
+
+ """
+ if self.client.was_seeded_today(service_id):
+ # annual limits client returns this data structure:
+ # {
+ # sms_delivered: int,
+ # email_delivered: int,
+ # sms_failed: int,
+ # email_failed: int
+ # }
+ stats = self.client.get_all_notification_counts(service_id)
+
+ return {
+ "sms": {
+ "requested": stats["sms_delivered"] + stats["sms_failed"],
+ "delivered": stats["sms_delivered"],
+ "failed": stats["sms_failed"]
+ },
+ "email": {
+ "requested": stats["email_delivered"] + stats["email_failed"],
+ "delivered": stats["email_delivered"],
+ "failed": stats["email_failed"]
+ }
+ }
+ 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': {'requested': int, 'delivered': int, 'failed': int}
+ 'email': {'requested': int, 'delivered': int, 'failed': 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"]:
+ for status in ["requested", "delivered", "failed"]:
+ stats_this_year[template_type][status] += stats_today[template_type][status]
+
+ return stats_this_year
+
+ def get_all_notification_counts_ytd(self, service_id, year):
+ """
+ Get total number of notifications by type for the current service for the year to date
+
+ Return value:
+ {
+ 'sms': {'requested': int, 'delivered': int, 'failed': int}
+ 'email': {'requested': int, 'delivered': int, 'failed': int}
+ }
+
+ """
+ stats_today = self.get_all_notification_counts_for_today(service_id)
+ stats_this_year = self.get_all_notification_counts_for_year(service_id, year)
+
+ # aggregate stats_today and stats_this_year
+ for template_type in ["sms", "email"]:
+ for status in ["requested", "delivered", "failed"]:
+ stats_this_year[template_type][status] += stats_today[template_type][status]
+
+ return stats_this_year
+
+# 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 = {
+ template_type: {status: 0 for status in ("requested", "delivered", "failed")} for template_type in ["sms", "email"]
+ }
+ for stat in template_statistics:
+ notifications[stat["template_type"]]["requested"] += stat["count"]
+ if stat["status"] in DELIVERED_STATUSES:
+ notifications[stat["template_type"]]["delivered"] += stat["count"]
+ elif stat["status"] in FAILURE_STATUSES:
+ notifications[stat["template_type"]]["failed"] += 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: {
+ "requested": sum(counts.values()),
+ "delivered": sum(counts.get(status, 0) for status in DELIVERED_STATUSES),
+ "failed": sum(counts.get(status, 0) for status in FAILURE_STATUSES)
+ }
+ for msg_type, counts in total_stats.items()
+ }
+
+annual_limit_client = AnnualLimitWrapper(RedisAnnualLimit(RedisClient()))
diff --git a/app/notify_client/template_statistics_api_client.py b/app/notify_client/template_statistics_api_client.py
index 132f8938fa..429bdcd313 100644
--- a/app/notify_client/template_statistics_api_client.py
+++ b/app/notify_client/template_statistics_api_client.py
@@ -1,4 +1,7 @@
+from itertools import groupby
+
from app.notify_client import NotifyAdminAPIClient
+from app.utils import DELIVERED_STATUSES, FAILURE_STATUSES
class TemplateStatisticsApiClient(NotifyAdminAPIClient):
@@ -16,4 +19,47 @@ def get_template_statistics_for_template(self, service_id, template_id):
return self.get(url="/service/{}/template-statistics/{}".format(service_id, template_id))["data"]
+
template_statistics_client = TemplateStatisticsApiClient()
+
+class TemplateStatistics:
+ def __init__(self, stats):
+ self.stats = stats
+
+ def as_aggregates(self):
+ template_statistics = self._filter_out_cancelled_stats(self.stats)
+ notifications = {
+ template_type: {status: 0 for status in ("requested", "delivered", "failed")} for template_type in ["sms", "email"]
+ }
+ for stat in template_statistics:
+ notifications[stat["template_type"]]["requested"] += stat["count"]
+ if stat["status"] in DELIVERED_STATUSES:
+ notifications[stat["template_type"]]["delivered"] += stat["count"]
+ elif stat["status"] in FAILURE_STATUSES:
+ notifications[stat["template_type"]]["failed"] += stat["count"]
+
+ return notifications
+
+ def as_template_usage(self, sort_key="count"):
+ template_statistics = self._filter_out_cancelled_stats(self.stats)
+ templates = []
+ for k, v in groupby(
+ sorted(template_statistics, key=lambda x: x["template_id"]),
+ key=lambda x: x["template_id"],
+ ):
+ template_stats = list(v)
+
+ templates.append(
+ {
+ "template_id": k,
+ "template_name": template_stats[0]["template_name"],
+ "template_type": template_stats[0]["template_type"],
+ "is_precompiled_letter": template_stats[0]["is_precompiled_letter"],
+ "count": sum(s["count"] for s in template_stats),
+ }
+ )
+
+ return sorted(templates, key=lambda x: x[sort_key], reverse=True)
+
+ def _filter_out_cancelled_stats(self, template_statistics):
+ return [s for s in template_statistics if s["status"] != "cancelled"]
diff --git a/app/templates/partials/check/too-many-email-messages.html b/app/templates/partials/check/too-many-email-messages.html
index 987846c472..67c2b6b4cd 100644
--- a/app/templates/partials/check/too-many-email-messages.html
+++ b/app/templates/partials/check/too-many-email-messages.html
@@ -4,6 +4,12 @@
{%- 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 {} emails, {}").format(current_service.message_limit, content_link(_("contact us"), url_for('main.contact'), is_external_link=true)) }}
+ {% if send_exceeds_annual_limit %}
+ {{ _("To request an annual limit above {} emails, {}").format(current_service.email_annual_limit, content_link(_("contact us"), url_for('main.contact'), is_external_link=true)) }}
+ {% endif %}
+ {% if send_exceeds_daily_limit %}
+ {{ _("To request a daily limit above {} emails, {}").format(current_service.message_limit, content_link(_("contact us"), url_for('main.contact'), is_external_link=true)) }}
+ {% endif %}
+
{%- 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..aca0d8950b 100644
--- a/app/templates/views/check/column-errors.html
+++ b/app/templates/views/check/column-errors.html
@@ -10,10 +10,10 @@
{% set prefix_txt = _('a column called') %}
{% set prefix_plural_txt = _('columns called') %}
-{% if 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 %}
+{% if send_exceeds_daily_limit %}
{% set page_title = _('These messages exceed your daily limit') %}
+{% elif send_exceeds_annual_limit %}
+ {% set page_title = _('These messages exceed the annual limit') %}
{% else %}
{% set page_title = _('Check there’s a column for each variable') %}
{% endif %}
diff --git a/pyproject.toml b/pyproject.toml
index 65718753e0..dd16bc94af 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -246,7 +246,7 @@ select = [
"I001",
"I002"
]
-ignore = ["E203", "E501", "E402"]
+ignore = ["E203", "E501", "E402", "F401"]
# Provide line length leeway for docstrings
[tool.ruff.lint.pycodestyle]
From 76faa15dcb7bb9cf75aded3aee5982af5a5cd38b Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Wed, 27 Nov 2024 20:22:51 +0000
Subject: [PATCH 02/28] fix: modify wrapper to work with older redis keys
---
app/notify_client/annual_limit_client.py | 72 ++++++++----------------
1 file changed, 23 insertions(+), 49 deletions(-)
diff --git a/app/notify_client/annual_limit_client.py b/app/notify_client/annual_limit_client.py
index f47411051b..5d9881ecde 100644
--- a/app/notify_client/annual_limit_client.py
+++ b/app/notify_client/annual_limit_client.py
@@ -1,7 +1,12 @@
+
+from notifications_utils.clients.redis import (
+ email_daily_count_cache_key,
+ sms_daily_count_cache_key,
+)
from notifications_utils.clients.redis.annual_limit import RedisAnnualLimit
from notifications_utils.clients.redis.redis_client import RedisClient
-from app import service_api_client, template_statistics_client
+from app import redis_client, service_api_client, template_statistics_client
from app.utils import DELIVERED_STATUSES, FAILURE_STATUSES
@@ -11,38 +16,16 @@ def __init__(self, client):
def get_all_notification_counts_for_today(self, service_id):
- """
- Get total number of notifications by type for the current service
-
- Return value:
- {
- 'sms': {'requested': int, 'delivered': int, 'failed': int}
- 'email': {'requested': int, 'delivered': int, 'failed': int}
- }
-
- """
- if self.client.was_seeded_today(service_id):
- # annual limits client returns this data structure:
- # {
- # sms_delivered: int,
- # email_delivered: int,
- # sms_failed: int,
- # email_failed: int
- # }
- stats = self.client.get_all_notification_counts(service_id)
+ # try to get today's stats from redis
+ todays_sms = redis_client.get(sms_daily_count_cache_key(service_id))
+ todays_email = redis_client.get(email_daily_count_cache_key(service_id))
+ if todays_sms is not None and todays_email is not None:
return {
- "sms": {
- "requested": stats["sms_delivered"] + stats["sms_failed"],
- "delivered": stats["sms_delivered"],
- "failed": stats["sms_failed"]
- },
- "email": {
- "requested": stats["email_delivered"] + stats["email_failed"],
- "delivered": stats["email_delivered"],
- "failed": stats["email_failed"]
- }
+ '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)
@@ -55,8 +38,8 @@ def get_all_notification_counts_for_year(self, service_id, year):
Return value:
{
- 'sms': {'requested': int, 'delivered': int, 'failed': int}
- 'email': {'requested': int, 'delivered': int, 'failed': int}
+ 'sms': int,
+ 'email': int
}
"""
@@ -65,8 +48,7 @@ def get_all_notification_counts_for_year(self, service_id, year):
stats_this_year = _aggregate_stats_from_service_api(stats_this_year)
# aggregate stats_today and stats_this_year
for template_type in ["sms", "email"]:
- for status in ["requested", "delivered", "failed"]:
- stats_this_year[template_type][status] += stats_today[template_type][status]
+ stats_this_year[template_type] += stats_today[template_type]
return stats_this_year
@@ -76,8 +58,8 @@ def get_all_notification_counts_ytd(self, service_id, year):
Return value:
{
- 'sms': {'requested': int, 'delivered': int, 'failed': int}
- 'email': {'requested': int, 'delivered': int, 'failed': int}
+ 'sms': int,
+ 'email': int
}
"""
@@ -86,8 +68,7 @@ def get_all_notification_counts_ytd(self, service_id, year):
# aggregate stats_today and stats_this_year
for template_type in ["sms", "email"]:
- for status in ["requested", "delivered", "failed"]:
- stats_this_year[template_type][status] += stats_today[template_type][status]
+ stats_this_year[template_type] += stats_today[template_type]
return stats_this_year
@@ -95,14 +76,11 @@ def get_all_notification_counts_ytd(self, service_id, year):
def _aggregate_notifications_stats(template_statistics):
template_statistics = _filter_out_cancelled_stats(template_statistics)
notifications = {
- template_type: {status: 0 for status in ("requested", "delivered", "failed")} for template_type in ["sms", "email"]
+ 'sms': 0,
+ 'email': 0
}
for stat in template_statistics:
- notifications[stat["template_type"]]["requested"] += stat["count"]
- if stat["status"] in DELIVERED_STATUSES:
- notifications[stat["template_type"]]["delivered"] += stat["count"]
- elif stat["status"] in FAILURE_STATUSES:
- notifications[stat["template_type"]]["failed"] += stat["count"]
+ notifications[stat["template_type"]] += stat["count"]
return notifications
@@ -123,11 +101,7 @@ def _aggregate_stats_from_service_api(stats):
total_stats[msg_type][status] += count
return {
- msg_type: {
- "requested": sum(counts.values()),
- "delivered": sum(counts.get(status, 0) for status in DELIVERED_STATUSES),
- "failed": sum(counts.get(status, 0) for status in FAILURE_STATUSES)
- }
+ msg_type: sum(counts.values())
for msg_type, counts in total_stats.items()
}
From 7e0bd02d55be25bb07b64b60080edb052f74c97c Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Wed, 27 Nov 2024 20:22:58 +0000
Subject: [PATCH 03/28] test: add tests!
---
.../notify_client/test_annual_limit_client.py | 111 ++++++++++++++++++
1 file changed, 111 insertions(+)
create mode 100644 tests/app/notify_client/test_annual_limit_client.py
diff --git a/tests/app/notify_client/test_annual_limit_client.py b/tests/app/notify_client/test_annual_limit_client.py
new file mode 100644
index 0000000000..b4605ed7a7
--- /dev/null
+++ b/tests/app/notify_client/test_annual_limit_client.py
@@ -0,0 +1,111 @@
+from unittest.mock import Mock, patch
+
+import pytest
+
+from app.notify_client.annual_limit_client import AnnualLimitWrapper
+
+
+@pytest.fixture
+def mock_redis():
+ with patch('app.notify_client.annual_limit_client.redis_client') as mock:
+ yield mock
+
+@pytest.fixture
+def mock_template_stats():
+ with patch('app.notify_client.annual_limit_client.template_statistics_client') as mock:
+ yield mock
+
+@pytest.fixture
+def mock_service_api():
+ with patch('app.notify_client.annual_limit_client.service_api_client') as mock:
+ yield mock
+
+class TestAnnualLimitWrapper:
+ 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 = AnnualLimitWrapper(Mock())
+
+ # 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
+ ])
+ 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 = AnnualLimitWrapper(Mock())
+
+ # Execute
+ result = wrapper.get_all_notification_counts_for_today('service-123')
+
+ # Assert
+ assert result == {'sms': 10, 'email': 10}
+ 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 = AnnualLimitWrapper(Mock())
+
+ 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_all_notification_counts_ytd(self):
+ # Setup
+ wrapper = AnnualLimitWrapper(Mock())
+
+ with patch.object(wrapper, 'get_all_notification_counts_for_today') as mock_today, \
+ patch.object(wrapper, 'get_all_notification_counts_for_year') as mock_year:
+
+ mock_today.return_value = {
+ 'sms': 2,
+ 'email': 4
+ }
+ mock_year.return_value = {
+ 'sms': 10,
+ 'email': 20
+ }
+
+ # Execute
+ result = wrapper.get_all_notification_counts_ytd('service-123', 2024)
+
+ # Assert
+ assert result['sms'] == 12
+ assert result['email'] == 24
+
From e949bf1fcbdd5355c8104961dd9c0751591c7896 Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Thu, 28 Nov 2024 12:59:36 +0000
Subject: [PATCH 04/28] chore: `utcnow` is deprecated, use `now` instead
---
app/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/__init__.py b/app/__init__.py
index 0025ac021d..6e42f80bc9 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -226,7 +226,7 @@ def get_locale():
application.jinja_env.globals["show_tou_prompt"] = show_tou_prompt
application.jinja_env.globals["parse_ua"] = parse
application.jinja_env.globals["events_key"] = EVENTS_KEY
- application.jinja_env.globals["now"] = datetime.utcnow
+ application.jinja_env.globals["now"] = datetime.now
# Initialize Salesforce Account list
if application.config["FF_SALESFORCE_CONTACT"]:
From 7170035092526bdfaa3b906ed5a91eeceee24c23 Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Thu, 28 Nov 2024 13:00:07 +0000
Subject: [PATCH 05/28] feat(annual limits): show error if user is over annual
limits
---
app/main/views/send.py | 42 +++++++++++++++++-------------------------
1 file changed, 17 insertions(+), 25 deletions(-)
diff --git a/app/main/views/send.py b/app/main/views/send.py
index bc7ce03cfb..8778e53d60 100644
--- a/app/main/views/send.py
+++ b/app/main/views/send.py
@@ -54,7 +54,7 @@
)
from app.main.views.dashboard import aggregate_notifications_stats
from app.models.user import Users
-from app.notify_client.annual_limit_client import annual_limit_client
+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,
@@ -664,24 +664,6 @@ def _check_messages(service_id, template_id, upload_id, preview_row, letters_as_
remaining_email_messages_today if db_template["template_type"] == "email" else remaining_sms_message_fragments_today
)
-
- # Override the remaining messages counts with the remaining annual counts, if the latter are lower
- over_annual_limit = False
- if current_app.config["FF_ANNUAL_LIMIT"]:
- stats_ytd = annual_limit_client.get_all_notification_counts_ytd(service_id, datetime.now(timezone.utc).year)
- remaining_sms_this_year = current_service.sms_annual_limit - stats_ytd["sms"]["requested"]
- remaining_email_this_year = current_service.email_annual_limit - stats_ytd["email"]["requested"]
-
- # if this is an email send
- if db_template["template_type"] == "email":
- if remaining_email_this_year < remaining_email_messages_today:
- recipients_remaining_messages = remaining_email_this_year
- over_annual_limit = True
- else:
- if remaining_sms_this_year < remaining_sms_message_fragments_today:
- recipients_remaining_messages = remaining_sms_this_year
- over_annual_limit = True
-
if db_template["template_type"] == "email":
email_reply_to = get_email_reply_to_address_from_session()
elif db_template["template_type"] == "sms":
@@ -784,7 +766,6 @@ def _check_messages(service_id, template_id, upload_id, preview_row, letters_as_
db_template["version"],
request.args.get("original_file_name", ""),
),
- over_annual_limit=over_annual_limit
)
@@ -806,13 +787,24 @@ def check_messages(service_id, template_id, upload_id, row_index=2):
data["sms_parts_remaining"] = current_service.sms_daily_limit - daily_sms_fragment_count(service_id)
if current_app.config["FF_ANNUAL_LIMIT"]:
+ # Override the remaining messages counts with the remaining annual counts, if the latter are lower
+ stats_ytd = notification_counts_client.get_all_notification_counts_for_year(service_id, datetime.now(timezone.utc).year)
+ remaining_sms_this_year = current_service.sms_annual_limit - stats_ytd["sms"]
+ remaining_email_this_year = current_service.email_annual_limit - stats_ytd["email"]
+
# Show annual limit validation over the daily one (even if both are true)
- if data["over_annual_limit"]:
- data["send_exceeds_annual_limit"] = True
+ if data["template"].template_type == "email":
+ if remaining_email_this_year < data["count_of_recipients"]:
+ data["recipients_remaining_messages"] = remaining_email_this_year
+ data["send_exceeds_annual_limit"] = True
+ else:
+ 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"]
- else:
- data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"]
+ if remaining_sms_this_year < data["count_of_recipients"]:
+ data["recipients_remaining_messages"] = remaining_sms_this_year
+ data["send_exceeds_annual_limit"] = True
+ else:
+ data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"]
if (
data["recipients"].too_many_rows
From 6529f2d466eeef2bc285b71fd0a183fc111a6756 Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Thu, 28 Nov 2024 13:01:03 +0000
Subject: [PATCH 06/28] fix: move daily limit message into the appropriate
template
---
.../partials/check/too-many-email-messages.html | 6 ++++++
app/templates/views/check/column-errors.html | 11 +----------
2 files changed, 7 insertions(+), 10 deletions(-)
diff --git a/app/templates/partials/check/too-many-email-messages.html b/app/templates/partials/check/too-many-email-messages.html
index 67c2b6b4cd..0617d5d0b2 100644
--- a/app/templates/partials/check/too-many-email-messages.html
+++ b/app/templates/partials/check/too-many-email-messages.html
@@ -9,6 +9,12 @@
{% endif %}
{% if send_exceeds_daily_limit %}
{{ _("To request a daily limit above {} emails, {}").format(current_service.message_limit, content_link(_("contact us"), url_for('main.contact'), is_external_link=true)) }}
+
+ {{ _('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 %}
{%- endif -%}
diff --git a/app/templates/views/check/column-errors.html b/app/templates/views/check/column-errors.html
index aca0d8950b..26cb381b05 100644
--- a/app/templates/views/check/column-errors.html
+++ b/app/templates/views/check/column-errors.html
@@ -171,17 +171,8 @@ {{ _('You cannot send all these text messages today')
{% 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))}}
-
-
-
+ {% endcall %}
{% endif %}
-
-
{% if not send_exceeds_daily_limit %}
From 6c814a34d8ef5d2bf077a12c23b8d1128b5a3474 Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Thu, 28 Nov 2024 13:01:35 +0000
Subject: [PATCH 07/28] feat: add a client to get daily/yearly sent stats using
caching where possible
---
.../notification_counts_client.py | 76 ++++++++++++
.../notify_client/test_annual_limit_client.py | 111 ------------------
.../test_notification_counts_client.py | 89 ++++++++++++++
3 files changed, 165 insertions(+), 111 deletions(-)
create mode 100644 app/notify_client/notification_counts_client.py
delete mode 100644 tests/app/notify_client/test_annual_limit_client.py
create mode 100644 tests/app/notify_client/test_notification_counts_client.py
diff --git a/app/notify_client/notification_counts_client.py b/app/notify_client/notification_counts_client.py
new file mode 100644
index 0000000000..ae3bc3a68f
--- /dev/null
+++ b/app/notify_client/notification_counts_client.py
@@ -0,0 +1,76 @@
+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.utils import DELIVERED_STATUSES, FAILURE_STATUSES
+
+
+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_email = redis_client.get(email_daily_count_cache_key(service_id))
+
+ 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
+
+
+# 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/tests/app/notify_client/test_annual_limit_client.py b/tests/app/notify_client/test_annual_limit_client.py
deleted file mode 100644
index b4605ed7a7..0000000000
--- a/tests/app/notify_client/test_annual_limit_client.py
+++ /dev/null
@@ -1,111 +0,0 @@
-from unittest.mock import Mock, patch
-
-import pytest
-
-from app.notify_client.annual_limit_client import AnnualLimitWrapper
-
-
-@pytest.fixture
-def mock_redis():
- with patch('app.notify_client.annual_limit_client.redis_client') as mock:
- yield mock
-
-@pytest.fixture
-def mock_template_stats():
- with patch('app.notify_client.annual_limit_client.template_statistics_client') as mock:
- yield mock
-
-@pytest.fixture
-def mock_service_api():
- with patch('app.notify_client.annual_limit_client.service_api_client') as mock:
- yield mock
-
-class TestAnnualLimitWrapper:
- 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 = AnnualLimitWrapper(Mock())
-
- # 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
- ])
- 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 = AnnualLimitWrapper(Mock())
-
- # Execute
- result = wrapper.get_all_notification_counts_for_today('service-123')
-
- # Assert
- assert result == {'sms': 10, 'email': 10}
- 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 = AnnualLimitWrapper(Mock())
-
- 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_all_notification_counts_ytd(self):
- # Setup
- wrapper = AnnualLimitWrapper(Mock())
-
- with patch.object(wrapper, 'get_all_notification_counts_for_today') as mock_today, \
- patch.object(wrapper, 'get_all_notification_counts_for_year') as mock_year:
-
- mock_today.return_value = {
- 'sms': 2,
- 'email': 4
- }
- mock_year.return_value = {
- 'sms': 10,
- 'email': 20
- }
-
- # Execute
- result = wrapper.get_all_notification_counts_ytd('service-123', 2024)
-
- # Assert
- assert result['sms'] == 12
- assert result['email'] == 24
-
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..0faf35082f
--- /dev/null
+++ b/tests/app/notify_client/test_notification_counts_client.py
@@ -0,0 +1,89 @@
+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
+
+
+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
+ ],
+ )
+ 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 == {"sms": 10, "email": 10}
+ 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
From a890ce1a2d36a963f95896f4f57f2dbfe6f43a0c Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Thu, 28 Nov 2024 13:17:18 +0000
Subject: [PATCH 08/28] add translations
---
app/notify_client/annual_limit_client.py | 108 ------------------
.../check/too-many-email-messages.html | 8 +-
app/translations/csv/fr.csv | 6 +-
3 files changed, 12 insertions(+), 110 deletions(-)
delete mode 100644 app/notify_client/annual_limit_client.py
diff --git a/app/notify_client/annual_limit_client.py b/app/notify_client/annual_limit_client.py
deleted file mode 100644
index 5d9881ecde..0000000000
--- a/app/notify_client/annual_limit_client.py
+++ /dev/null
@@ -1,108 +0,0 @@
-
-from notifications_utils.clients.redis import (
- email_daily_count_cache_key,
- sms_daily_count_cache_key,
-)
-from notifications_utils.clients.redis.annual_limit import RedisAnnualLimit
-from notifications_utils.clients.redis.redis_client import RedisClient
-
-from app import redis_client, service_api_client, template_statistics_client
-from app.utils import DELIVERED_STATUSES, FAILURE_STATUSES
-
-
-class AnnualLimitWrapper:
- def __init__(self, client):
- self.client = client
-
-
- 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_email = redis_client.get(email_daily_count_cache_key(service_id))
-
- 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_all_notification_counts_ytd(self, service_id, year):
- """
- Get total number of notifications by type for the current service for the year to date
-
- Return value:
- {
- 'sms': int,
- 'email': int
- }
-
- """
- stats_today = self.get_all_notification_counts_for_today(service_id)
- stats_this_year = self.get_all_notification_counts_for_year(service_id, 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
-
-# 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()
- }
-
-annual_limit_client = AnnualLimitWrapper(RedisAnnualLimit(RedisClient()))
diff --git a/app/templates/partials/check/too-many-email-messages.html b/app/templates/partials/check/too-many-email-messages.html
index 0617d5d0b2..ff7dd02953 100644
--- a/app/templates/partials/check/too-many-email-messages.html
+++ b/app/templates/partials/check/too-many-email-messages.html
@@ -5,7 +5,13 @@
{{ _("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 send_exceeds_annual_limit %}
- {{ _("To request an annual limit above {} emails, {}").format(current_service.email_annual_limit, content_link(_("contact us"), url_for('main.contact'), is_external_link=true)) }}
+
+ {{ _('{} can only send {} more email messages until annual limit resets'.format(current_service.name, recipients_remaining_messages)) }}
+
+ {{ _('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)) }}
+
+
{% endif %}
{% if send_exceeds_daily_limit %}
{{ _("To request a daily limit above {} emails, {}").format(current_service.message_limit, content_link(_("contact us"), url_for('main.contact'), is_external_link=true)) }}
diff --git a/app/translations/csv/fr.csv b/app/translations/csv/fr.csv
index 57aead4f21..fe2c41b635 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"
+"{} can only send {} more email messages until annual limit resets","FR: {} can only send {} more email messages until annual limit resets"
+"To send some of these messages now, edit the spreadsheet to {} recipients maximum. ","FR: To send some of these messages now, edit the spreadsheet to {} recipients maximum. "
+"To send to recipients you removed, wait until April 1, {} or contact them some other way.","FR: To send to recipients you removed, wait until April 1, {} or contact them some other way"
+"These messages exceed the annual limit","FR: These messages exceed the annual limit"
\ No newline at end of file
From 5e61b91e5d3ce3e8392e152953fd6d3d6bc26aac Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Thu, 28 Nov 2024 13:18:59 +0000
Subject: [PATCH 09/28] chore: formatting
---
app/main/views/send.py | 4 ++--
app/notify_client/template_statistics_api_client.py | 2 +-
gunicorn_config.py | 1 +
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/app/main/views/send.py b/app/main/views/send.py
index 8778e53d60..271d8870f2 100644
--- a/app/main/views/send.py
+++ b/app/main/views/send.py
@@ -798,13 +798,13 @@ def check_messages(service_id, template_id, upload_id, row_index=2):
data["recipients_remaining_messages"] = remaining_email_this_year
data["send_exceeds_annual_limit"] = True
else:
- data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"]
+ data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"]
else:
if remaining_sms_this_year < data["count_of_recipients"]:
data["recipients_remaining_messages"] = remaining_sms_this_year
data["send_exceeds_annual_limit"] = True
else:
- data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"]
+ data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"]
if (
data["recipients"].too_many_rows
diff --git a/app/notify_client/template_statistics_api_client.py b/app/notify_client/template_statistics_api_client.py
index 429bdcd313..fb6acbc138 100644
--- a/app/notify_client/template_statistics_api_client.py
+++ b/app/notify_client/template_statistics_api_client.py
@@ -19,9 +19,9 @@ def get_template_statistics_for_template(self, service_id, template_id):
return self.get(url="/service/{}/template-statistics/{}".format(service_id, template_id))["data"]
-
template_statistics_client = TemplateStatisticsApiClient()
+
class TemplateStatistics:
def __init__(self, stats):
self.stats = stats
diff --git a/gunicorn_config.py b/gunicorn_config.py
index 63325ca386..cf6e5e7112 100644
--- a/gunicorn_config.py
+++ b/gunicorn_config.py
@@ -51,6 +51,7 @@
# Start timer for total running time
start_time = time.time()
+
def on_starting(server):
server.log.info("Starting Notifications Admin")
From e1fe31f03ea63121c4467b8ade3af79452c6d631 Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Thu, 28 Nov 2024 14:03:52 +0000
Subject: [PATCH 10/28] fix send to work without FF too
---
app/main/views/send.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/app/main/views/send.py b/app/main/views/send.py
index 271d8870f2..706f208327 100644
--- a/app/main/views/send.py
+++ b/app/main/views/send.py
@@ -797,14 +797,14 @@ def check_messages(service_id, template_id, upload_id, row_index=2):
if remaining_email_this_year < data["count_of_recipients"]:
data["recipients_remaining_messages"] = remaining_email_this_year
data["send_exceeds_annual_limit"] = True
- else:
- data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"]
else:
if remaining_sms_this_year < data["count_of_recipients"]:
data["recipients_remaining_messages"] = remaining_sms_this_year
data["send_exceeds_annual_limit"] = True
- else:
- data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"]
+
+ 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
From 10ffa2740bb952b0b3b07019c1c0cf20da92ef4e Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Thu, 28 Nov 2024 17:24:30 +0000
Subject: [PATCH 11/28] =?UTF-8?q?feat:=20fix=20sending;=20write=20tests;?=
=?UTF-8?q?=20=F0=9F=99=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/main/views/send.py | 7 +-
.../check/too-many-email-messages.html | 7 +-
.../check/too-many-sms-message-parts.html | 2 +-
app/templates/views/check/column-errors.html | 10 +-
tests/app/main/views/test_send.py | 215 +++++++++++++++++-
tests/conftest.py | 28 ++-
6 files changed, 255 insertions(+), 14 deletions(-)
diff --git a/app/main/views/send.py b/app/main/views/send.py
index 706f208327..ab6412caf1 100644
--- a/app/main/views/send.py
+++ b/app/main/views/send.py
@@ -793,6 +793,8 @@ def check_messages(service_id, template_id, upload_id, row_index=2):
remaining_email_this_year = current_service.email_annual_limit - stats_ytd["email"]
# Show annual limit validation over the daily one (even if both are true)
+ data["send_exceeds_annual_limit"] = False
+ data["send_exceeds_daily_limit"] = False
if data["template"].template_type == "email":
if remaining_email_this_year < data["count_of_recipients"]:
data["recipients_remaining_messages"] = remaining_email_this_year
@@ -801,10 +803,11 @@ def check_messages(service_id, template_id, upload_id, row_index=2):
if remaining_sms_this_year < data["count_of_recipients"]:
data["recipients_remaining_messages"] = remaining_sms_this_year
data["send_exceeds_annual_limit"] = True
+ else:
+ data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"]
- 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"]
+ data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"]
if (
data["recipients"].too_many_rows
diff --git a/app/templates/partials/check/too-many-email-messages.html b/app/templates/partials/check/too-many-email-messages.html
index ff7dd02953..ed75a1ab29 100644
--- a/app/templates/partials/check/too-many-email-messages.html
+++ b/app/templates/partials/check/too-many-email-messages.html
@@ -6,18 +6,17 @@
{% else %}
{% if send_exceeds_annual_limit %}
- {{ _('{} can only send {} more email messages until annual limit resets'.format(current_service.name, recipients_remaining_messages)) }}
+ {{ _('{} can only send {} more email messages until annual limit resets'.format(current_service.name, recipients_remaining_messages)) }}
{{ _('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)) }}
- {% endif %}
- {% if send_exceeds_daily_limit %}
+ {% elif send_exceeds_daily_limit or recipients.more_rows_than_can_send %}
{{ _("To request a daily limit above {} emails, {}").format(current_service.message_limit, content_link(_("contact us"), url_for('main.contact'), is_external_link=true)) }}
{{ _('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))}}
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..8ceee20762 100644
--- a/app/templates/partials/check/too-many-sms-message-parts.html
+++ b/app/templates/partials/check/too-many-sms-message-parts.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/views/check/column-errors.html b/app/templates/views/check/column-errors.html
index 26cb381b05..b60c6457ff 100644
--- a/app/templates/views/check/column-errors.html
+++ b/app/templates/views/check/column-errors.html
@@ -10,10 +10,12 @@
{% set prefix_txt = _('a column called') %}
{% set prefix_plural_txt = _('columns called') %}
-{% if send_exceeds_daily_limit %}
- {% set page_title = _('These messages exceed your daily limit') %}
-{% elif send_exceeds_annual_limit %}
+{% 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') %}
{% else %}
{% set page_title = _('Check there’s a column for each variable') %}
{% endif %}
@@ -168,7 +170,7 @@
{{ _('You cannot send all these text messages today')
{% call banner_wrapper(type='dangerous') %}
{% include "partials/check/too-many-email-messages.html" %}
{% endcall %}
- {% elif recipients.more_rows_than_can_send %}
+ {% elif recipients.more_rows_than_can_send or send_exceeds_annual_limit %}
{% call banner_wrapper(type='dangerous') %}
{% include "partials/check/too-many-email-messages.html" %}
{% endcall %}
diff --git a/tests/app/main/views/test_send.py b/tests/app/main/views/test_send.py
index 78df4b10cc..bb431810ee 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_notification_counts_client,
fake_uuid,
num_requested,
expected_msg,
@@ -2560,6 +2563,10 @@ def test_check_messages_shows_too_many_sms_messages_errors(
},
)
+ mock_notification_counts_client.get_all_notification_counts_for_year.return_value = {
+ "sms": 0,
+ "email": 0,
+ }
with client_request.session_transaction() as session:
session["file_uploads"] = {
fake_uuid: {
@@ -2584,6 +2591,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 +2632,7 @@ def test_check_messages_shows_too_many_email_messages_errors(
mock_get_template_statistics,
mock_get_job_doesnt_exist,
mock_get_jobs,
+ mock_notification_counts_client,
fake_uuid,
num_requested,
expected_msg,
@@ -2617,7 +2649,10 @@ def test_check_messages_shows_too_many_email_messages_errors(
"email": {"requested": num_requested, "delivered": 0, "failed": 0},
},
)
-
+ mock_notification_counts_client.get_all_notification_counts_for_year.return_value = {
+ "sms": 0,
+ "email": 0,
+ }
with client_request.session_transaction() as session:
session["file_uploads"] = {
fake_uuid: {
@@ -3401,3 +3436,181 @@ 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,
+ ):
+ 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 that `num_sent_this_year` have already been sent this year
+ mock_notification_counts_client.get_all_notification_counts_for_year.return_value = {
+ "sms": 1000, # not used in test but needs a value
+ "email": num_sent_this_year,
+ }
+
+ # 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,
+ ):
+ 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 that `num_sent_this_year` have already been sent this year
+ mock_notification_counts_client.get_all_notification_counts_for_year.return_value = {
+ "sms": num_sent_this_year,
+ "email": 1000, # not used in test but needs a value
+ }
+
+ # mock that we've already sent `emails_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
diff --git a/tests/conftest.py b/tests/conftest.py
index 94ff0ca5be..21ed1f782b 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):
From 7f1aeb686ab97355c92234edeff936386687dfdd Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Thu, 28 Nov 2024 18:31:04 +0000
Subject: [PATCH 12/28] fix: only check for `send_exceeds_annual_limit` when FF
is on
---
app/main/views/send.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/app/main/views/send.py b/app/main/views/send.py
index ab6412caf1..e96c3f4f1d 100644
--- a/app/main/views/send.py
+++ b/app/main/views/send.py
@@ -825,9 +825,13 @@ def check_messages(service_id, template_id, upload_id, row_index=2):
if data["errors"] or data["trying_to_send_letters_in_trial_mode"]:
return render_template("views/check/column-errors.html", **data)
- if data["send_exceeds_daily_limit"] or data["send_exceeds_annual_limit"]:
+ 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),
From cde386261373a105f19b5b2a136f1b0d6356c8bc Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Thu, 28 Nov 2024 19:32:02 +0000
Subject: [PATCH 13/28] fix(tests): only run the new tests with the FF on (they
cant pass with it off!)
---
tests/app/main/views/test_send.py | 152 ++++++++++++++++--------------
1 file changed, 80 insertions(+), 72 deletions(-)
diff --git a/tests/app/main/views/test_send.py b/tests/app/main/views/test_send.py
index bb431810ee..c1350bdb46 100644
--- a/tests/app/main/views/test_send.py
+++ b/tests/app/main/views/test_send.py
@@ -3483,49 +3483,53 @@ def test_email_send_fails_approrpiately_when_over_limits(
num_sent_this_year,
expect_to_see_annual_limit_msg,
expect_to_see_daily_limit_msg,
+ app_,
):
- 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 that `num_sent_this_year` have already been sent this year
- mock_notification_counts_client.get_all_notification_counts_for_year.return_value = {
- "sms": 1000, # not used in test but needs a value
- "email": num_sent_this_year,
- }
+ 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 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
+ # mock that `num_sent_this_year` have already been sent this year
+ mock_notification_counts_client.get_all_notification_counts_for_year.return_value = {
+ "sms": 1000, # not used in test but needs a value
+ "email": num_sent_this_year,
+ }
- with client_request.session_transaction() as session:
- session["file_uploads"] = {
- fake_uuid: {
- "template_id": fake_uuid,
- "notification_count": 1,
- "valid": True,
+ # 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,
- )
+ 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_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
+ 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",
@@ -3571,46 +3575,50 @@ def test_sms_send_fails_approrpiately_when_over_limits(
num_sent_this_year,
expect_to_see_annual_limit_msg,
expect_to_see_daily_limit_msg,
+ app_,
):
- 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 that `num_sent_this_year` have already been sent this year
- mock_notification_counts_client.get_all_notification_counts_for_year.return_value = {
- "sms": num_sent_this_year,
- "email": 1000, # not used in test but needs a value
- }
+ 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 that we've already sent `emails_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
+ # mock that `num_sent_this_year` have already been sent this year
+ mock_notification_counts_client.get_all_notification_counts_for_year.return_value = {
+ "sms": num_sent_this_year,
+ "email": 1000, # 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,
+ # mock that we've already sent `emails_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,
- )
+ 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_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
+ 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
From aa5b18effc5b4ef79c540db42265d80788738f20 Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Thu, 28 Nov 2024 19:34:05 +0000
Subject: [PATCH 14/28] chore: remove unused imports
---
app/notify_client/notification_counts_client.py | 1 -
tests/app/notify_client/test_notification_counts_client.py | 2 +-
2 files changed, 1 insertion(+), 2 deletions(-)
diff --git a/app/notify_client/notification_counts_client.py b/app/notify_client/notification_counts_client.py
index ae3bc3a68f..ae6e1a4930 100644
--- a/app/notify_client/notification_counts_client.py
+++ b/app/notify_client/notification_counts_client.py
@@ -4,7 +4,6 @@
)
from app import redis_client, service_api_client, template_statistics_client
-from app.utils import DELIVERED_STATUSES, FAILURE_STATUSES
class NotificationCounts:
diff --git a/tests/app/notify_client/test_notification_counts_client.py b/tests/app/notify_client/test_notification_counts_client.py
index 0faf35082f..fa7183aea8 100644
--- a/tests/app/notify_client/test_notification_counts_client.py
+++ b/tests/app/notify_client/test_notification_counts_client.py
@@ -1,4 +1,4 @@
-from unittest.mock import Mock, patch
+from unittest.mock import patch
import pytest
From e7ad1debbc1578adf7ce9a88046242702498c295 Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Tue, 3 Dec 2024 13:58:40 +0000
Subject: [PATCH 15/28] fix: move secondary message outside banner
---
app/templates/partials/check/too-many-email-messages.html | 7 +++++--
app/templates/views/check/column-errors.html | 4 +---
2 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/app/templates/partials/check/too-many-email-messages.html b/app/templates/partials/check/too-many-email-messages.html
index ed75a1ab29..4ead4e464b 100644
--- a/app/templates/partials/check/too-many-email-messages.html
+++ b/app/templates/partials/check/too-many-email-messages.html
@@ -1,3 +1,4 @@
+{% from "components/banner.html" import banner_wrapper %}
{% from "components/links.html" import content_link %}
@@ -13,8 +14,10 @@
{% elif send_exceeds_daily_limit or recipients.more_rows_than_can_send %}
- {{ _("To request a daily limit above {} emails, {}").format(current_service.message_limit, content_link(_("contact us"), url_for('main.contact'), is_external_link=true)) }}
-
+ {% call banner_wrapper(type='dangerous') %}
+ {{ _("To request a daily limit above {} emails, {}").format(current_service.message_limit, content_link(_("contact us"), url_for('main.contact'), is_external_link=true)) }}
+ {% 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],
diff --git a/app/templates/views/check/column-errors.html b/app/templates/views/check/column-errors.html
index b60c6457ff..852d30f0bd 100644
--- a/app/templates/views/check/column-errors.html
+++ b/app/templates/views/check/column-errors.html
@@ -171,9 +171,7 @@
{{ _('You cannot send all these text messages today')
{% include "partials/check/too-many-email-messages.html" %}
{% endcall %}
{% elif recipients.more_rows_than_can_send or send_exceeds_annual_limit %}
- {% call banner_wrapper(type='dangerous') %}
- {% include "partials/check/too-many-email-messages.html" %}
- {% endcall %}
+ {% include "partials/check/too-many-email-messages.html" %}
{% endif %}
From aa849892736a2702aa1ff8028709f760034bbe1f Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Tue, 3 Dec 2024 14:02:48 +0000
Subject: [PATCH 16/28] fix: undo ruff change made by accident
---
pyproject.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index 8c37921bf2..662a8a46ce 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -247,7 +247,7 @@ select = [
"I001",
"I002"
]
-ignore = ["E203", "E501", "E402", "F401"]
+ignore = ["E203", "E501", "E402"]
# Provide line length leeway for docstrings
[tool.ruff.lint.pycodestyle]
From bb34be9f26aea5611e1ac253b8126eede75ca1a7 Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Wed, 4 Dec 2024 16:32:42 +0000
Subject: [PATCH 17/28] feat(error message): split annual into its own
---
app/main/views/send.py | 6 +++++
.../check/too-many-email-messages-annual.html | 13 +++++++++++
.../check/too-many-email-messages.html | 22 +------------------
app/templates/views/check/column-errors.html | 13 ++++++++---
app/templates/views/notifications/check.html | 6 +++++
5 files changed, 36 insertions(+), 24 deletions(-)
create mode 100644 app/templates/partials/check/too-many-email-messages-annual.html
diff --git a/app/main/views/send.py b/app/main/views/send.py
index e96c3f4f1d..f7984c827f 100644
--- a/app/main/views/send.py
+++ b/app/main/views/send.py
@@ -809,6 +809,8 @@ def check_messages(service_id, template_id, upload_id, row_index=2):
else:
data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"]
+ data["send_exceeds_annual_limit"] = False
+
if (
data["recipients"].too_many_rows
or not data["count_of_recipients"]
@@ -1113,6 +1115,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/templates/partials/check/too-many-email-messages-annual.html b/app/templates/partials/check/too-many-email-messages-annual.html
new file mode 100644
index 0000000000..de30481845
--- /dev/null
+++ b/app/templates/partials/check/too-many-email-messages-annual.html
@@ -0,0 +1,13 @@
+{% 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 %}
+
{{ _('{} can only send {} more email messages until annual limit resets'.format(current_service.name, recipients_remaining_messages)) }}
+
+ {{ _('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)) }}
+
+ {%- endif -%}
+
\ 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 4ead4e464b..987846c472 100644
--- a/app/templates/partials/check/too-many-email-messages.html
+++ b/app/templates/partials/check/too-many-email-messages.html
@@ -1,29 +1,9 @@
-{% from "components/banner.html" import banner_wrapper %}
{% 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 %}
- {% if send_exceeds_annual_limit %}
-
-
{{ _('{} can only send {} more email messages until annual limit resets'.format(current_service.name, recipients_remaining_messages)) }}
-
- {{ _('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)) }}
-
-
- {% elif send_exceeds_daily_limit or recipients.more_rows_than_can_send %}
- {% call banner_wrapper(type='dangerous') %}
- {{ _("To request a daily limit above {} emails, {}").format(current_service.message_limit, content_link(_("contact us"), url_for('main.contact'), is_external_link=true)) }}
- {% 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 %}
-
+ {{ _("To request a daily limit above {} emails, {}").format(current_service.message_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 852d30f0bd..0b9cecaafe 100644
--- a/app/templates/views/check/column-errors.html
+++ b/app/templates/views/check/column-errors.html
@@ -166,12 +166,19 @@ {{ _('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-email-messages-annual.html" %}
{% endcall %}
- {% elif recipients.more_rows_than_can_send or send_exceeds_annual_limit %}
+ {% 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))}}
+
{% endif %}
diff --git a/app/templates/views/notifications/check.html b/app/templates/views/notifications/check.html
index 17c668b0c8..089e19a951 100644
--- a/app/templates/views/notifications/check.html
+++ b/app/templates/views/notifications/check.html
@@ -66,6 +66,12 @@ {{_('You cannot send this email message today') }}
+ {% elif error == 'too-many-email-messages-annual' %}
+ {{ page_header(_('This message exceeds your annual limit'), back_link=back_link) }}
+
+ {% call banner_wrapper(type='dangerous') %}
+ {% include "partials/check/too-many-email-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) }}
From b308d2d5ca1ec3a092ac835712b44246439fc23f Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Wed, 4 Dec 2024 18:32:36 +0000
Subject: [PATCH 18/28] feat(send): refactor limit checker to use
`notification_counts_client` class when sending
---
app/main/views/send.py | 26 +++----
.../notification_counts_client.py | 71 +++++++++++++++++++
2 files changed, 80 insertions(+), 17 deletions(-)
diff --git a/app/main/views/send.py b/app/main/views/send.py
index f7984c827f..53acc9ba63 100644
--- a/app/main/views/send.py
+++ b/app/main/views/send.py
@@ -1,6 +1,5 @@
import itertools
import json
-from datetime import datetime, timezone
from string import ascii_uppercase
from zipfile import BadZipFile
@@ -787,30 +786,23 @@ def check_messages(service_id, template_id, upload_id, row_index=2):
data["sms_parts_remaining"] = current_service.sms_daily_limit - daily_sms_fragment_count(service_id)
if current_app.config["FF_ANNUAL_LIMIT"]:
- # Override the remaining messages counts with the remaining annual counts, if the latter are lower
- stats_ytd = notification_counts_client.get_all_notification_counts_for_year(service_id, datetime.now(timezone.utc).year)
- remaining_sms_this_year = current_service.sms_annual_limit - stats_ytd["sms"]
- remaining_email_this_year = current_service.email_annual_limit - stats_ytd["email"]
-
- # Show annual limit validation over the daily one (even if both are true)
data["send_exceeds_annual_limit"] = False
data["send_exceeds_daily_limit"] = False
- if data["template"].template_type == "email":
- if remaining_email_this_year < data["count_of_recipients"]:
- data["recipients_remaining_messages"] = remaining_email_this_year
- data["send_exceeds_annual_limit"] = True
+ # 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 remaining_sms_this_year < data["count_of_recipients"]:
- data["recipients_remaining_messages"] = remaining_sms_this_year
- 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"]
- data["send_exceeds_annual_limit"] = False
-
if (
data["recipients"].too_many_rows
or not data["count_of_recipients"]
diff --git a/app/notify_client/notification_counts_client.py b/app/notify_client/notification_counts_client.py
index ae6e1a4930..cd1c6b7781 100644
--- a/app/notify_client/notification_counts_client.py
+++ b/app/notify_client/notification_counts_client.py
@@ -1,9 +1,12 @@
+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:
@@ -41,6 +44,74 @@ def get_all_notification_counts_for_year(self, service_id, year):
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):
From d84ba18f92106ab0b944f757795c2c21383d4e85 Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Wed, 4 Dec 2024 18:33:25 +0000
Subject: [PATCH 19/28] feat(remaining messages summary): add heading mode; fix
missing translations
---
.../components/remaining-messages-summary.html | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
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
From 8a5f916574230924240d6a91a6406210de215c27 Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Wed, 4 Dec 2024 18:33:56 +0000
Subject: [PATCH 20/28] feat(ready to send): show RMS componont on page,
prevent users from moving forward if service is at either limit
---
app/main/views/templates.py | 29 +++++++++++++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/app/main/views/templates.py b/app/main/views/templates.py
index 6c7f8a9dce..d099e2cff1 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,
@@ -134,6 +135,26 @@ def view_template(service_id, template_id):
user_has_template_permission = current_user.has_template_folder_permission(template_folder)
+ # 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
+ notification_type = template["template_type"]
+ 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"]
+
+ # determine ready to send heading
+ if yearlyRemaining == 0:
+ heading = _("Sending paused until annual limit resets")
+ elif dailyRemaining == 0:
+ heading = _("Sending paused until 7pm ET. You can schedule more messages to send later.")
+ else:
+ heading = _("Ready to send?")
+
if should_skip_template_page(template["template_type"]):
return redirect(url_for(".send_one_off", service_id=service_id, template_id=template_id))
@@ -142,6 +163,14 @@ 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,
+ dailyLimit=dailyLimit,
+ dailyUsed=dailyUsed,
+ yearlyLimit=yearlyLimit,
+ yearlyUsed=yearlyUsed,
+ notification_type=notification_type,
+ dailyRemaining=dailyRemaining,
+ yearlyRemaining=yearlyRemaining,
+ heading=heading,
)
From 2d5e949c8cb30062c9227c2f5fb49d8de723a352 Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Wed, 4 Dec 2024 18:34:16 +0000
Subject: [PATCH 21/28] feat(ready to send): update view template
---
app/templates/views/templates/_template.html | 33 +++++++++++++-------
1 file changed, 22 insertions(+), 11 deletions(-)
diff --git a/app/templates/views/templates/_template.html b/app/templates/views/templates/_template.html
index 86c19a127e..038db7fb62 100644
--- a/app/templates/views/templates/_template.html
+++ b/app/templates/views/templates/_template.html
@@ -1,4 +1,13 @@
{% 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 +22,22 @@
{% else %}
{% if current_user.has_permissions('send_messages', restrict_admin_usage=True) %}
-
{{ _('Ready to send?') }}
+
{{ heading }}
+
+ {{ remaining_messages_summary(dailyLimit, dailyUsed, yearlyLimit, yearlyUsed, notification_type, yearlyRemaining == 0 or dailyRemaining == 0) }}
-
+ {% if yearlyRemaining > 0 and dailyRemaining > 0 %}
+
+ {% endif %}
{% endif %}
{% endif %}
-
+
{{ template|string|translate_preview_template }}
-
-
-
+
\ No newline at end of file
From 80e3cd31600bccb595d79ac41afab4bce917ee17 Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Wed, 4 Dec 2024 18:34:50 +0000
Subject: [PATCH 22/28] feat(annual error mesage email): refactor page a bit,
add case for remaining = 0
---
.../check/too-many-email-messages-annual.html | 17 +++++++++++------
1 file changed, 11 insertions(+), 6 deletions(-)
diff --git a/app/templates/partials/check/too-many-email-messages-annual.html b/app/templates/partials/check/too-many-email-messages-annual.html
index de30481845..f9b3abd6f6 100644
--- a/app/templates/partials/check/too-many-email-messages-annual.html
+++ b/app/templates/partials/check/too-many-email-messages-annual.html
@@ -1,13 +1,18 @@
{% 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 %}
-
{{ _('{} can only send {} more email messages until annual limit resets'.format(current_service.name, recipients_remaining_messages)) }}
-
- {{ _('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)) }}
-
+ {% if recipients_remaining_messages > 0 %}
+ {{ _('{} can only send {} more email messages until annual limit resets'.format(current_service.name, recipients_remaining_messages)) }}
+
+ {{ _('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 email messages until April 1, {}'.format(current_service.name, 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
From f0ed140eefe7a4a9c9c4ace07d62ce229bebe4ed Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Thu, 5 Dec 2024 15:04:05 +0000
Subject: [PATCH 23/28] fix(notifications_count_client): cast bytes to int (why
are they stored as bytes?)
---
app/notify_client/notification_counts_client.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/notify_client/notification_counts_client.py b/app/notify_client/notification_counts_client.py
index cd1c6b7781..0d04291450 100644
--- a/app/notify_client/notification_counts_client.py
+++ b/app/notify_client/notification_counts_client.py
@@ -12,8 +12,8 @@
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_email = redis_client.get(email_daily_count_cache_key(service_id))
+ todays_sms = int(redis_client.get(sms_daily_count_cache_key(service_id)))
+ todays_email = int(redis_client.get(email_daily_count_cache_key(service_id)))
if todays_sms is not None and todays_email is not None:
return {"sms": todays_sms, "email": todays_email}
From 091e2ee512666fd26840c7906dd0d36758803ae9 Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Thu, 5 Dec 2024 15:06:31 +0000
Subject: [PATCH 24/28] fix: refactor error message views to make them
re-usable
---
.../partials/check/too-many-email-messages.html | 2 +-
...sages-annual.html => too-many-messages-annual.html} | 10 ++++++++--
app/templates/views/check/column-errors.html | 2 +-
app/templates/views/notifications/check.html | 7 ++++---
4 files changed, 14 insertions(+), 7 deletions(-)
rename app/templates/partials/check/{too-many-email-messages-annual.html => too-many-messages-annual.html} (70%)
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-email-messages-annual.html b/app/templates/partials/check/too-many-messages-annual.html
similarity index 70%
rename from app/templates/partials/check/too-many-email-messages-annual.html
rename to app/templates/partials/check/too-many-messages-annual.html
index f9b3abd6f6..86e4cb3ae9 100644
--- a/app/templates/partials/check/too-many-email-messages-annual.html
+++ b/app/templates/partials/check/too-many-messages-annual.html
@@ -1,17 +1,23 @@
{% 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 email messages until annual limit resets'.format(current_service.name, recipients_remaining_messages)) }}
+ {{ _('{} 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 email messages until April 1, {}'.format(current_service.name, now().year)) }}
+ {{ _('{} 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 -%}
diff --git a/app/templates/views/check/column-errors.html b/app/templates/views/check/column-errors.html
index 0b9cecaafe..1e46c28333 100644
--- a/app/templates/views/check/column-errors.html
+++ b/app/templates/views/check/column-errors.html
@@ -168,7 +168,7 @@ {{ _('You cannot send all these text messages today')
{% elif send_exceeds_annual_limit %}
{% call banner_wrapper(type='dangerous') %}
- {% include "partials/check/too-many-email-messages-annual.html" %}
+ {% include "partials/check/too-many-messages-annual.html" %}
{% endcall %}
{% elif recipients.more_rows_than_can_send %}
{% call banner_wrapper(type='dangerous') %}
diff --git a/app/templates/views/notifications/check.html b/app/templates/views/notifications/check.html
index 089e19a951..0161a74f17 100644
--- a/app/templates/views/notifications/check.html
+++ b/app/templates/views/notifications/check.html
@@ -66,11 +66,12 @@ {{_('You cannot send this email message today') }}
- {% elif error == 'too-many-email-messages-annual' %}
- {{ page_header(_('This message exceeds your annual limit'), back_link=back_link) }}
+ {% 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') %}
- {% include "partials/check/too-many-email-messages-annual.html" %}
+ {% 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 #}
From 87f465e9c22c79243a4abf86b772fc8486d6dc87 Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Thu, 5 Dec 2024 15:06:46 +0000
Subject: [PATCH 25/28] chore: translations
---
app/translations/csv/fr.csv | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/app/translations/csv/fr.csv b/app/translations/csv/fr.csv
index fe2c41b635..d6af104602 100644
--- a/app/translations/csv/fr.csv
+++ b/app/translations/csv/fr.csv
@@ -2028,7 +2028,13 @@
"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"
-"{} can only send {} more email messages until annual limit resets","FR: {} can only send {} more email messages until annual limit resets"
+"{} can only send {} more {} until annual limit resets","FR: {} can only send {} more {} until annual limit resets"
"To send some of these messages now, edit the spreadsheet to {} recipients maximum. ","FR: To send some of these messages now, edit the spreadsheet to {} recipients maximum. "
"To send to recipients you removed, wait until April 1, {} or contact them some other way.","FR: To send to recipients you removed, wait until April 1, {} or contact them some other way"
-"These messages exceed the annual limit","FR: These messages exceed the annual limit"
\ No newline at end of file
+"These messages exceed the annual limit","FR: These messages exceed the annual limit"
+"{} cannot send any more {} until April 1, {}","FR: {} cannot send any more {} until April 1, {}"
+"For more information, visit the usage report for {}.","FR: For more information, visit the usage report for {}."
+"This message exceeds your annual limit", "FR: This message exceeds your annual limit"
+"email messages","courriels"
+"Sending paused until annual limit resets","FR: Sending paused until annual limit resets"
+"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."
\ No newline at end of file
From 31780b4b498946f6c1aa231d76baa3b378b5803e Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Thu, 5 Dec 2024 15:07:15 +0000
Subject: [PATCH 26/28] tests: add tests for new send code, template code, and
changes to notification_counts_client
---
tests/app/main/views/test_send.py | 129 ++++++++++++++++--
tests/app/main/views/test_templates.py | 69 +++++++++-
.../test_notification_counts_client.py | 112 ++++++++++++++-
3 files changed, 297 insertions(+), 13 deletions(-)
diff --git a/tests/app/main/views/test_send.py b/tests/app/main/views/test_send.py
index c1350bdb46..5967046fd4 100644
--- a/tests/app/main/views/test_send.py
+++ b/tests/app/main/views/test_send.py
@@ -3493,10 +3493,21 @@ def test_email_send_fails_approrpiately_when_over_limits(
),
)
- # mock that `num_sent_this_year` have already been sent this year
- mock_notification_counts_client.get_all_notification_counts_for_year.return_value = {
- "sms": 1000, # not used in test but needs a value
- "email": num_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": 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
@@ -3584,14 +3595,23 @@ def test_sms_send_fails_approrpiately_when_over_limits(
["phone number"] + ([mock_get_users_by_service(None)[0]["mobile_number"]] * num_being_sent)
),
)
-
- # mock that `num_sent_this_year` have already been sent this year
- mock_notification_counts_client.get_all_notification_counts_for_year.return_value = {
- "sms": num_sent_this_year,
- "email": 1000, # not used in test but needs a value
+ 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 `emails_sent_today` emails 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
@@ -3622,3 +3642,90 @@ def test_sms_send_fails_approrpiately_when_over_limits(
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..54df21e58f 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
@@ -54,6 +54,12 @@
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()
@@ -2742,3 +2748,64 @@ 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,
+ ):
+ 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
index fa7183aea8..342aaa2917 100644
--- a/tests/app/notify_client/test_notification_counts_client.py
+++ b/tests/app/notify_client/test_notification_counts_client.py
@@ -1,4 +1,5 @@
-from unittest.mock import patch
+from datetime import datetime
+from unittest.mock import Mock, patch
import pytest
@@ -23,6 +24,12 @@ def mock_service_api():
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
@@ -87,3 +94,106 @@ def test_get_all_notification_counts_for_year(self, mock_service_api):
# 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)
From e3b67c121a547b2d2b90c034fd8369a9dba55ddb Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Thu, 5 Dec 2024 15:07:57 +0000
Subject: [PATCH 27/28] chore: fix translation
---
app/translations/csv/fr.csv | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/translations/csv/fr.csv b/app/translations/csv/fr.csv
index d6af104602..be9346b1c1 100644
--- a/app/translations/csv/fr.csv
+++ b/app/translations/csv/fr.csv
@@ -2034,7 +2034,7 @@
"These messages exceed the annual limit","FR: These messages exceed the annual limit"
"{} cannot send any more {} until April 1, {}","FR: {} cannot send any more {} until April 1, {}"
"For more information, visit the usage report for {}.","FR: For more information, visit the usage report for {}."
-"This message exceeds your annual limit", "FR: This message exceeds your annual limit"
+"This message exceeds your annual limit","FR: This message exceeds your annual limit"
"email messages","courriels"
"Sending paused until annual limit resets","FR: Sending paused until annual limit resets"
"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."
\ No newline at end of file
From 0aeecf77f812488552d5ee9d5f90287501263b45 Mon Sep 17 00:00:00 2001
From: Andrew Leith
Date: Thu, 5 Dec 2024 15:34:48 +0000
Subject: [PATCH 28/28] chore: remove dupe translation due to merge
---
app/translations/csv/fr.csv | 1 -
1 file changed, 1 deletion(-)
diff --git a/app/translations/csv/fr.csv b/app/translations/csv/fr.csv
index 2a4de35570..6b2b5dc2cb 100644
--- a/app/translations/csv/fr.csv
+++ b/app/translations/csv/fr.csv
@@ -2038,4 +2038,3 @@
"email messages","courriels"
"Sending paused until annual limit resets","FR: Sending paused until annual limit resets"
"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."
-"Month by month totals","Totaux mensuels"