Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/validate annual limits 2 unfixed #2013

Closed
wants to merge 33 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
431772c
feat: validate annual limit
andrewleith Nov 26, 2024
31881bc
Merge branch 'main' into feat/validate-annual-limit
andrewleith Nov 26, 2024
76faa15
fix: modify wrapper to work with older redis keys
andrewleith Nov 27, 2024
7e0bd02
test: add tests!
andrewleith Nov 27, 2024
e949bf1
chore: `utcnow` is deprecated, use `now` instead
andrewleith Nov 28, 2024
7170035
feat(annual limits): show error if user is over annual limits
andrewleith Nov 28, 2024
6529f2d
fix: move daily limit message into the appropriate template
andrewleith Nov 28, 2024
6c814a3
feat: add a client to get daily/yearly sent stats using caching where…
andrewleith Nov 28, 2024
a890ce1
add translations
andrewleith Nov 28, 2024
5e61b91
chore: formatting
andrewleith Nov 28, 2024
e1fe31f
fix send to work without FF too
andrewleith Nov 28, 2024
10ffa27
feat: fix sending; write tests; 🙏
andrewleith Nov 28, 2024
35a5805
Merge branch 'main' into feat/validate-annual-limit
andrewleith Nov 28, 2024
7f1aeb6
fix: only check for `send_exceeds_annual_limit` when FF is on
andrewleith Nov 28, 2024
cde3862
fix(tests): only run the new tests with the FF on (they cant pass wit…
andrewleith Nov 28, 2024
aa5b18e
chore: remove unused imports
andrewleith Nov 28, 2024
82b44e0
Merge branch 'main' into feat/validate-annual-limit
whabanks Dec 2, 2024
e7ad1de
fix: move secondary message outside banner
andrewleith Dec 3, 2024
aa84989
fix: undo ruff change made by accident
andrewleith Dec 3, 2024
4312146
Merge branch 'main' into feat/validate-annual-limit-2
andrewleith Dec 4, 2024
bb34be9
feat(error message): split annual into its own
andrewleith Dec 4, 2024
b308d2d
feat(send): refactor limit checker to use `notification_counts_client…
andrewleith Dec 4, 2024
d84ba18
feat(remaining messages summary): add heading mode; fix missing trans…
andrewleith Dec 4, 2024
8a5f916
feat(ready to send): show RMS componont on page, prevent users from m…
andrewleith Dec 4, 2024
2d5e949
feat(ready to send): update view template
andrewleith Dec 4, 2024
80e3cd3
feat(annual error mesage email): refactor page a bit, add case for re…
andrewleith Dec 4, 2024
f0ed140
fix(notifications_count_client): cast bytes to int (why are they stor…
andrewleith Dec 5, 2024
091e2ee
fix: refactor error message views to make them re-usable
andrewleith Dec 5, 2024
87f465e
chore: translations
andrewleith Dec 5, 2024
31780b4
tests: add tests for new send code, template code, and changes to not…
andrewleith Dec 5, 2024
e3b67c1
chore: fix translation
andrewleith Dec 5, 2024
ec3ed46
Merge branch 'main' into feat/validate-annual-limit-2
andrewleith Dec 5, 2024
0aeecf7
chore: remove dupe translation due to merge
andrewleith Dec 5, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion app/main/views/send.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
)
from app.main.views.dashboard import aggregate_notifications_stats
from app.models.user import Users
from app.notify_client import notification_counts_client
from app.s3_client.s3_csv_client import (
copy_bulk_send_file_to_uploads,
list_bulk_send_uploads,
Expand Down Expand Up @@ -783,7 +784,24 @@ def check_messages(service_id, template_id, upload_id, row_index=2):
data["original_file_name"] = SanitiseASCII.encode(data.get("original_file_name", ""))
data["sms_parts_requested"] = data["stats_daily"]["sms"]["requested"]
data["sms_parts_remaining"] = current_service.sms_daily_limit - daily_sms_fragment_count(service_id)
data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"]

if current_app.config["FF_ANNUAL_LIMIT"]:
data["send_exceeds_annual_limit"] = False
data["send_exceeds_daily_limit"] = False
# determine the remaining sends for daily + annual
limit_stats = notification_counts_client.get_limit_stats(current_service)
remaining_annual = limit_stats[data["template"].template_type]["annual"]["remaining"]

if remaining_annual < data["count_of_recipients"]:
data["recipients_remaining_messages"] = remaining_annual
data["send_exceeds_annual_limit"] = True
else:
# if they arent over their limit, and its sms, check if they are over their daily limit
if data["template"].template_type == "sms":
data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"]

else:
data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"]

if (
data["recipients"].too_many_rows
Expand Down Expand Up @@ -1085,6 +1103,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

Expand Down
29 changes: 29 additions & 0 deletions app/main/views/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))

Expand All @@ -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,
)


Expand Down
146 changes: 146 additions & 0 deletions app/notify_client/notification_counts_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
from datetime import datetime

from notifications_utils.clients.redis import (
email_daily_count_cache_key,
sms_daily_count_cache_key,
)

from app import redis_client, service_api_client, template_statistics_client
from app.models.service import Service


class NotificationCounts:
def get_all_notification_counts_for_today(self, service_id):
# try to get today's stats from redis
todays_sms = 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}
# fallback to the API if the stats are not in redis
else:
stats = template_statistics_client.get_template_statistics_for_service(service_id, limit_days=1)
transformed_stats = _aggregate_notifications_stats(stats)

return transformed_stats

def get_all_notification_counts_for_year(self, service_id, year):
"""
Get total number of notifications by type for the current service for the current year

Return value:
{
'sms': int,
'email': int
}

"""
stats_today = self.get_all_notification_counts_for_today(service_id)
stats_this_year = service_api_client.get_monthly_notification_stats(service_id, year)["data"]
stats_this_year = _aggregate_stats_from_service_api(stats_this_year)
# aggregate stats_today and stats_this_year
for template_type in ["sms", "email"]:
stats_this_year[template_type] += stats_today[template_type]

return stats_this_year

def get_limit_stats(self, service: Service):
"""
Get the limit stats for the current service, by notification type, including:
- how many notifications were sent today and this year
- the monthy and daily limits
- the number of notifications remaining today and this year
Returns:
dict: A dictionary containing the limit stats for email and SMS notifications. The structure is as follows:
{
"email": {
"annual": {
"limit": int, # The annual limit for email notifications
"sent": int, # The number of email notifications sent this year
"remaining": int, # The number of email notifications remaining this year
},
"daily": {
"limit": int, # The daily limit for email notifications
"sent": int, # The number of email notifications sent today
"remaining": int, # The number of email notifications remaining today
},
},
"sms": {
"annual": {
"limit": int, # The annual limit for SMS notifications
"sent": int, # The number of SMS notifications sent this year
"remaining": int, # The number of SMS notifications remaining this year
},
"daily": {
"limit": int, # The daily limit for SMS notifications
"sent": int, # The number of SMS notifications sent today
"remaining": int, # The number of SMS notifications remaining today
},
}
}
"""

sent_today = self.get_all_notification_counts_for_today(service.id)
sent_thisyear = self.get_all_notification_counts_for_year(service.id, datetime.now().year)

limit_stats = {
"email": {
"annual": {
"limit": service.email_annual_limit,
"sent": sent_thisyear["email"],
"remaining": service.email_annual_limit - sent_thisyear["email"],
},
"daily": {
"limit": service.message_limit,
"sent": sent_today["email"],
"remaining": service.message_limit - sent_today["email"],
},
},
"sms": {
"annual": {
"limit": service.sms_annual_limit,
"sent": sent_thisyear["sms"],
"remaining": service.sms_annual_limit - sent_thisyear["sms"],
},
"daily": {
"limit": service.sms_daily_limit,
"sent": sent_today["sms"],
"remaining": service.sms_daily_limit - sent_today["sms"],
},
},
}

return limit_stats


# TODO: consolidate this function and other functions that transform the results of template_statistics_client calls
def _aggregate_notifications_stats(template_statistics):
template_statistics = _filter_out_cancelled_stats(template_statistics)
notifications = {"sms": 0, "email": 0}
for stat in template_statistics:
notifications[stat["template_type"]] += stat["count"]

return notifications


def _filter_out_cancelled_stats(template_statistics):
return [s for s in template_statistics if s["status"] != "cancelled"]


def _aggregate_stats_from_service_api(stats):
"""Aggregate monthly notification stats excluding cancelled"""
total_stats = {"sms": {}, "email": {}}

for month_data in stats.values():
for msg_type in ["sms", "email"]:
if msg_type in month_data:
for status, count in month_data[msg_type].items():
if status != "cancelled":
if status not in total_stats[msg_type]:
total_stats[msg_type][status] = 0
total_stats[msg_type][status] += count

return {msg_type: sum(counts.values()) for msg_type, counts in total_stats.items()}


notification_counts_client = NotificationCounts()
12 changes: 6 additions & 6 deletions app/templates/components/remaining-messages-summary.html
Original file line number Diff line number Diff line change
@@ -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) %}
<!-- Validate textOnly param -->
{% set textOnly_allowed_values = ['text', 'emoji'] %}
{% if textOnly not in textOnly_allowed_values %}
Expand Down Expand Up @@ -95,14 +95,14 @@
</div>
{% endif %}

{% if sections[0].skip %}
{% if not headingMode and sections[0].skip %}
<p class="mt-4 pl-10 py-4 border-l-4 border-gray-300" data-testid="yearly-sending-paused">
Sending paused until annual limit resets
{{ _('Sending paused until annual limit resets') }}
</p>
{% elif sections[0].remaining == "0" %}
{% elif not headingMode and sections[0].remaining == "0" %}
<p class="mt-4 pl-10 py-4 border-l-4 border-gray-300" data-testid="daily-sending-paused">
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.') }}
</p>
{% endif %}

{% endmacro %}
{% endmacro %}
2 changes: 1 addition & 1 deletion app/templates/partials/check/too-many-email-messages.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{% from "components/links.html" import content_link %}

<p>
<p data-testid="exceeds-daily">
{%- if current_service.trial_mode %}
{{ _("Your service is in trial mode. To send more messages, <a href='{}'>request to go live</a>").format(url_for('main.request_to_go_live', service_id=current_service.id)) }}
{% else %}
Expand Down
24 changes: 24 additions & 0 deletions app/templates/partials/check/too-many-messages-annual.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{% from "components/links.html" import content_link %}

{% if template.template_type == 'email' %}
{% set units = _('email messages') %}
{% else %}
{% set units = _('text messages') %}
{% endif %}

<p data-testid="exceeds-annual">
{%- if current_service.trial_mode %}
{{ _("Your service is in trial mode. To send more messages, <a href='{}'>request to go live</a>").format(url_for('main.request_to_go_live', service_id=current_service.id)) }}
{% else %}
{% if recipients_remaining_messages > 0 %}
<p>{{ _('<strong>{}</strong> can only send <strong>{}</strong> more {} until annual limit resets'.format(current_service.name, recipients_remaining_messages, units)) }}</p>
<p>
{{ _('To send some of these messages now, edit the spreadsheet to <strong>{}</strong> recipients maximum. '.format(recipients_remaining_messages)) }}
{{ _('To send to recipients you removed, wait until <strong>April 1, {}</strong> or contact them some other way.'.format(now().year)) }}
</p>
{% else %}
<p>{{ _('<strong>{}</strong> cannot send any more {} until <strong>April 1, {}</strong>'.format(current_service.name, units, now().year)) }}</p>
<p>{{ _('For more information, visit the <a href={}>usage report for {}</a>.'.format(url_for('.monthly', service_id=current_service.id), current_service.name)) }}</p>
{% endif %}
{%- endif -%}
</p>
22 changes: 10 additions & 12 deletions app/templates/views/check/column-errors.html
Original file line number Diff line number Diff line change
Expand Up @@ -164,21 +164,19 @@ <h2 class="heading-medium">{{ _('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))}}
</p>
{% elif recipients.more_rows_than_can_send and false %}
{% elif send_exceeds_annual_limit %}
{% call banner_wrapper(type='dangerous') %}
{% include "partials/check/too-many-email-messages.html" %}
{% include "partials/check/too-many-messages-annual.html" %}
{% endcall %}
{% elif recipients.more_rows_than_can_send %}
{% call banner_wrapper(type='dangerous') %}
{% include "partials/check/too-many-email-messages.html" %}
{% endcall %}
<h2 class="heading-medium">{{ _('You cannot send all these email messages today') }}</h2>
<p>
{{ _("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))}}
</p>


{% call banner_wrapper(type='dangerous') %}
{% include "partials/check/too-many-email-messages.html" %}
{% endcall %}
<h2 class="heading-medium">{{ _('You cannot send all these email messages today') }}</h2>
<p>
{{ _("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))}}
</p>
{% endif %}


Expand Down
7 changes: 7 additions & 0 deletions app/templates/views/notifications/check.html
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ <h2 class="heading-medium">{{_('You cannot send this email message today') }}</h
content_link(_("your current local time"), _('https://nrc.canada.ca/en/web-clock/'), is_external_link=true))}}
</p>
</div>
{% elif error == 'too-many-email-annual' or error == 'too-many-sms-annual' %}
{{ page_header(_('These messages exceed the annual limit'), back_link=back_link) }}
<div class="mb-12 clear-both contain-floats">
{% call banner_wrapper(type='dangerous') %}
{% set recipients_remaining_messages = 0 %}
{% include "partials/check/too-many-messages-annual.html" %}
{% endcall %}
{% elif error == 'message-too-long' %}
{# the only row_errors we can get when sending one off messages is that the message is too long #}
{{ govuk_back_link(back_link) }}
Expand Down
33 changes: 22 additions & 11 deletions app/templates/views/templates/_template.html
Original file line number Diff line number Diff line change
@@ -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 %}


<!-- dailyLimit: {{ dailyLimit }} <br />
dailyUsed: {{ dailyUsed }} <br />
yearlyLimit: {{ yearlyLimit }} <br />
yearlyUsed: {{ yearlyUsed }} <br />
dailyRemaining : {{ dailyRemaining }} <br />
yearlyRemaining : {{ yearlyRemaining }} <br /> -->

<div>
{% if template._template.archived %}
Expand All @@ -13,20 +22,22 @@
</p>
{% else %}
{% if current_user.has_permissions('send_messages', restrict_admin_usage=True) %}
<h2 class="heading-medium">{{ _('Ready to send?') }}</h2>
<h2 class="heading-medium">{{ heading }}</h2>

{{ remaining_messages_summary(dailyLimit, dailyUsed, yearlyLimit, yearlyUsed, notification_type, yearlyRemaining == 0 or dailyRemaining == 0) }}

<div class="mb-12">
<a href="{{ url_for('.add_recipients', service_id=current_service.id, template_id=template.id) }}" class="button" data-testid="add-recipients">
{{ _('Yes, add recipients') }}
</a>
<a href="{{ url_for('.send_test', service_id=current_service.id, template_id=template.id) }}" class="button button-secondary">{{ _('No, send yourself this message') }}</a>
</div>
{% if yearlyRemaining > 0 and dailyRemaining > 0 %}
<div class="mb-12 mt-12">
<a href="{{ url_for('.add_recipients', service_id=current_service.id, template_id=template.id) }}" class="button" data-testid="add-recipients">
{{ _('Yes, add recipients') }}
</a>
<a href="{{ url_for('.send_test', service_id=current_service.id, template_id=template.id) }}" class="button button-secondary">{{ _('No, send yourself this message') }}</a>
</div>
{% endif %}
{% endif %}
{% endif %}
</div>

<div class="w-full template-container">
<div class="w-full template-container mt-10">
{{ template|string|translate_preview_template }}
</div>


</div>
Loading
Loading