diff --git a/app/main/views/send.py b/app/main/views/send.py index 2a5720e38b..5ebcf828de 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -53,6 +53,7 @@ ) from app.main.views.dashboard import aggregate_notifications_stats from app.models.user import Users +from app.notify_client import notification_counts_client from app.s3_client.s3_csv_client import ( copy_bulk_send_file_to_uploads, list_bulk_send_uploads, @@ -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 @@ -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 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, ) diff --git a/app/notify_client/notification_counts_client.py b/app/notify_client/notification_counts_client.py new file mode 100644 index 0000000000..0d04291450 --- /dev/null +++ b/app/notify_client/notification_counts_client.py @@ -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() diff --git a/app/templates/components/remaining-messages-summary.html b/app/templates/components/remaining-messages-summary.html index 42175d47fd..b685e03aff 100644 --- a/app/templates/components/remaining-messages-summary.html +++ b/app/templates/components/remaining-messages-summary.html @@ -1,4 +1,4 @@ -{% macro remaining_messages_summary(dailyLimit, dailyUsed, yearlyLimit, yearlyUsed, notification_type, textOnly=None) %} +{% macro remaining_messages_summary(dailyLimit, dailyUsed, yearlyLimit, yearlyUsed, notification_type, headingMode=False, textOnly=None) %} {% set textOnly_allowed_values = ['text', 'emoji'] %} {% if textOnly not in textOnly_allowed_values %} @@ -95,14 +95,14 @@ {% endif %} - {% if sections[0].skip %} + {% if not headingMode and sections[0].skip %}
- Sending paused until annual limit resets + {{ _('Sending paused until annual limit resets') }}
- {% elif sections[0].remaining == "0" %} + {% elif not headingMode and sections[0].remaining == "0" %}- Sending paused until 7pm ET. You can schedule more messages to send later. + {{ _('Sending paused until 7pm ET. You can schedule more messages to send later.') }}
{% endif %} -{% endmacro %} +{% endmacro %} \ No newline at end of file diff --git a/app/templates/partials/check/too-many-email-messages.html b/app/templates/partials/check/too-many-email-messages.html index 987846c472..9f66acd9c3 100644 --- a/app/templates/partials/check/too-many-email-messages.html +++ b/app/templates/partials/check/too-many-email-messages.html @@ -1,6 +1,6 @@ {% from "components/links.html" import content_link %} -+
{%- if current_service.trial_mode %} {{ _("Your service is in trial mode. To send more messages, request to go live").format(url_for('main.request_to_go_live', service_id=current_service.id)) }} {% else %} diff --git a/app/templates/partials/check/too-many-messages-annual.html b/app/templates/partials/check/too-many-messages-annual.html new file mode 100644 index 0000000000..86e4cb3ae9 --- /dev/null +++ b/app/templates/partials/check/too-many-messages-annual.html @@ -0,0 +1,24 @@ +{% from "components/links.html" import content_link %} + +{% if template.template_type == 'email' %} + {% set units = _('email messages') %} +{% else %} + {% set units = _('text messages') %} +{% endif %} + +
+ {%- if current_service.trial_mode %} + {{ _("Your service is in trial mode. To send more messages, request to go live").format(url_for('main.request_to_go_live', service_id=current_service.id)) }} + {% else %} + {% if recipients_remaining_messages > 0 %} +
{{ _('{} can only send {} more {} until annual limit resets'.format(current_service.name, recipients_remaining_messages, units)) }}
++ {{ _('To send some of these messages now, edit the spreadsheet to {} recipients maximum. '.format(recipients_remaining_messages)) }} + {{ _('To send to recipients you removed, wait until April 1, {} or contact them some other way.'.format(now().year)) }} +
+ {% else %} +{{ _('{} cannot send any more {} until April 1, {}'.format(current_service.name, units, now().year)) }}
+{{ _('For more information, visit the usage report for {}.'.format(url_for('.monthly', service_id=current_service.id), current_service.name)) }}
+ {% endif %} + {%- endif -%} + \ No newline at end of file diff --git a/app/templates/views/check/column-errors.html b/app/templates/views/check/column-errors.html index 32f2ae9e8e..2bfb65afdb 100644 --- a/app/templates/views/check/column-errors.html +++ b/app/templates/views/check/column-errors.html @@ -164,21 +164,19 @@- {{ _("You can try sending these messages after {} Eastern Time. Check {}.").format(time_to_reset[current_lang], - content_link(_("your current local time"), _('https://nrc.canada.ca/en/web-clock/'), is_external_link=true))}} -
- - + {% call banner_wrapper(type='dangerous') %} + {% include "partials/check/too-many-email-messages.html" %} + {% endcall %} ++ {{ _("You 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..0161a74f17 100644 --- a/app/templates/views/notifications/check.html +++ b/app/templates/views/notifications/check.html @@ -66,6 +66,13 @@