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 limit #2002

Merged
merged 19 commits into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down
35 changes: 28 additions & 7 deletions app/main/views/send.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import itertools
import json
from datetime import datetime, timezone
from string import ascii_uppercase
from zipfile import BadZipFile

Expand Down Expand Up @@ -53,6 +54,7 @@
)
from app.main.views.dashboard import aggregate_notifications_stats
from app.models.user import Users
from app.notify_client.notification_counts_client import notification_counts_client
from app.s3_client.s3_csv_client import (
copy_bulk_send_file_to_uploads,
list_bulk_send_uploads,
Expand Down Expand Up @@ -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)

Expand All @@ -659,7 +661,7 @@ def _check_messages(service_id, template_id, upload_id, preview_row, letters_as_
email_reply_to = None
sms_sender = None
recipients_remaining_messages = (
remaining_email_messages if db_template["template_type"] == "email" else remaining_sms_message_fragments
remaining_email_messages_today if db_template["template_type"] == "email" else remaining_sms_message_fragments_today
)

if db_template["template_type"] == "email":
Expand Down Expand Up @@ -743,8 +745,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,
Expand Down Expand Up @@ -783,7 +785,26 @@ 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"]:
# 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["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:
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
Expand All @@ -801,7 +822,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 = {
Expand Down
76 changes: 76 additions & 0 deletions app/notify_client/notification_counts_client.py
Original file line number Diff line number Diff line change
@@ -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
Fixed Show fixed Hide fixed


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()
46 changes: 46 additions & 0 deletions app/notify_client/template_statistics_api_client.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -17,3 +20,46 @@ def get_template_statistics_for_template(self, service_id, template_id):


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"]
20 changes: 19 additions & 1 deletion app/templates/partials/check/too-many-email-messages.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@
{%- 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 %}
{{ _("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 %}

<p>{{ _('<strong>{}</strong> can only send <strong>{}</strong> more email messages until annual limit resets'.format(current_service.name, recipients_remaining_messages)) }}</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>

{% 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)) }}

<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 %}

{%- endif -%}
</p>
17 changes: 4 additions & 13 deletions app/templates/views/check/column-errors.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down Expand Up @@ -171,17 +171,8 @@ <h2 class="heading-medium">{{ _('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 %}
<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>


{% endcall %}
{% endif %}


</div>

{% if not send_exceeds_daily_limit %}
Expand Down
6 changes: 5 additions & 1 deletion app/translations/csv/fr.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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"
"Month by month totals","Totaux mensuels"
"<strong>{}</strong> can only send <strong>{}</strong> more email messages until annual limit resets","FR: <strong>{}</strong> can only send <strong>{}</strong> more email messages until annual limit resets"
"To send some of these messages now, edit the spreadsheet to <strong>{}</strong> recipients maximum. ","FR: To send some of these messages now, edit the spreadsheet to <strong>{}</strong> recipients maximum. "
"To send to recipients you removed, wait until <strong>April 1, {}</strong> or contact them some other way.","FR: To send to recipients you removed, wait until <strong>April 1, {}</strong> or contact them some other way"
"These messages exceed the annual limit","FR: These messages exceed the annual limit"
1 change: 1 addition & 0 deletions gunicorn_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
# Start timer for total running time
start_time = time.time()


def on_starting(server):
server.log.info("Starting Notifications Admin")

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading