diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 9219001d9f..20279ddac1 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -17,6 +17,7 @@ RUN apt-get update \ emacs \ exa \ fd-find \ + fzf \ git \ iputils-ping \ iproute2 \ diff --git a/.devcontainer/scripts/installations.sh b/.devcontainer/scripts/installations.sh index f009a6b51e..7151480947 100644 --- a/.devcontainer/scripts/installations.sh +++ b/.devcontainer/scripts/installations.sh @@ -13,6 +13,10 @@ echo -e "alias ll='exa -alh@ --git'" >> ~/.zshrc echo -e "alias lt='exa -al -T -L 2'" >> ~/.zshrc echo -e "alias poe='poetry run poe'" >> ~/.zshrc +echo -e "# fzf key bindings and completion" >> ~/.zshrc +echo -e "source /usr/share/doc/fzf/examples/key-bindings.zsh" >> ~/.zshrc +echo -e "source /usr/share/doc/fzf/examples/completion.zsh" >> ~/.zshrc + # Poetry autocomplete echo -e "fpath+=/.zfunc" >> ~/.zshrc echo -e "autoload -Uz compinit && compinit" diff --git a/app/config.py b/app/config.py index 920bbf13cc..9b37a55798 100644 --- a/app/config.py +++ b/app/config.py @@ -80,10 +80,10 @@ class Config(object): # FEATURE FLAGS FF_SALESFORCE_CONTACT = env.bool("FF_SALESFORCE_CONTACT", True) FF_RTL = env.bool("FF_RTL", True) - FF_ANNUAL_LIMIT = env.bool("FF_ANNUAL_LIMIT", True) + FF_ANNUAL_LIMIT = env.bool("FF_ANNUAL_LIMIT", False) - FREE_YEARLY_EMAIL_LIMIT = env.int("FREE_YEARLY_EMAIL_LIMIT", 10_000_000) - FREE_YEARLY_SMS_LIMIT = env.int("FREE_YEARLY_SMS_LIMIT", 25_000) + FREE_YEARLY_EMAIL_LIMIT = env.int("FREE_YEARLY_EMAIL_LIMIT", 20_000_000) + FREE_YEARLY_SMS_LIMIT = env.int("FREE_YEARLY_SMS_LIMIT", 100_000) GC_ARTICLES_API = os.environ.get("GC_ARTICLES_API", "articles.alpha.canada.ca/notification-gc-notify") GC_ARTICLES_API_AUTH_PASSWORD = os.environ.get("GC_ARTICLES_API_AUTH_PASSWORD") GC_ARTICLES_API_AUTH_USERNAME = os.environ.get("GC_ARTICLES_API_AUTH_USERNAME") @@ -215,7 +215,7 @@ class Test(Development): NO_BRANDING_ID = "0af93cf1-2c49-485f-878f-f3e662e651ef" FF_RTL = True - FF_ANNUAL_LIMIT = False + FF_ANNUAL_LIMIT = True class ProductionFF(Config): diff --git a/app/extensions.py b/app/extensions.py index 8421b1e560..51bebe6e38 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -1,5 +1,6 @@ from flask_caching import Cache from notifications_utils.clients.antivirus.antivirus_client import AntivirusClient +from notifications_utils.clients.redis.annual_limit import RedisAnnualLimit from notifications_utils.clients.redis.bounce_rate import RedisBounceRate from notifications_utils.clients.redis.redis_client import RedisClient from notifications_utils.clients.statsd.statsd_client import StatsdClient @@ -10,4 +11,6 @@ zendesk_client = ZendeskClient() redis_client = RedisClient() bounce_rate_client = RedisBounceRate(redis_client) +annual_limit_client = RedisAnnualLimit(redis_client) + cache = Cache(config={"CACHE_TYPE": "simple"}) # TODO: pull config out to config.py later diff --git a/app/main/views/dashboard.py b/app/main/views/dashboard.py index f1bd214bfe..54c141a82d 100644 --- a/app/main/views/dashboard.py +++ b/app/main/views/dashboard.py @@ -24,7 +24,7 @@ service_api_client, template_statistics_client, ) -from app.extensions import bounce_rate_client +from app.extensions import annual_limit_client, bounce_rate_client from app.main import main from app.models.enum.bounce_rate_status import BounceRateStatus from app.models.enum.notification_statuses import NotificationStatuses @@ -229,16 +229,90 @@ def usage(service_id): @main.route("/services//monthly") @user_has_permissions("view_activity") def monthly(service_id): + def combine_daily_to_annual(daily, annual, mode): + if mode == "redis": + # the redis client omits properties if there are no counts yet, so account for this here\ + daily_redis = { + field: daily.get(field, 0) for field in ["sms_delivered", "sms_failed", "email_delivered", "email_failed"] + } + annual["sms"] += daily_redis["sms_delivered"] + daily_redis["sms_failed"] + annual["email"] += daily_redis["email_delivered"] + daily_redis["email_failed"] + elif mode == "db": + annual["sms"] += daily["sms"]["requested"] + annual["email"] += daily["email"]["requested"] + + return annual + + def combine_daily_to_monthly(daily, monthly, mode): + if mode == "redis": + # the redis client omits properties if there are no counts yet, so account for this here\ + daily_redis = { + field: daily.get(field, 0) for field in ["sms_delivered", "sms_failed", "email_delivered", "email_failed"] + } + + monthly[0]["sms_counts"]["failed"] += daily_redis["sms_failed"] + monthly[0]["sms_counts"]["requested"] += daily_redis["sms_failed"] + daily_redis["sms_delivered"] + monthly[0]["email_counts"]["failed"] += daily_redis["email_failed"] + monthly[0]["email_counts"]["requested"] += daily_redis["email_failed"] + daily_redis["email_delivered"] + elif mode == "db": + monthly[0]["sms_counts"]["failed"] += daily["sms"]["failed"] + monthly[0]["sms_counts"]["requested"] += daily["sms"]["requested"] + monthly[0]["email_counts"]["failed"] += daily["email"]["failed"] + monthly[0]["email_counts"]["requested"] += daily["email"]["requested"] + + return monthly + + def aggregate_by_type(notification_data): + counts = {"sms": 0, "email": 0, "letter": 0} + for month_data in notification_data["data"].values(): + for message_type, message_counts in month_data.items(): + if isinstance(message_counts, dict): + counts[message_type] += sum(message_counts.values()) + + # return the result + return counts + year, current_financial_year = requested_and_current_financial_year(request) + + # if FF_ANNUAL is on + if current_app.config["FF_ANNUAL_LIMIT"]: + monthly_data = service_api_client.get_monthly_notification_stats(service_id, year) + annual_data = aggregate_by_type(monthly_data) + + todays_data = annual_limit_client.get_all_notification_counts(current_service.id) + + # if redis is empty, query the db + if todays_data is None: + todays_data = service_api_client.get_service_statistics(service_id, limit_days=1, today_only=False) + annual_data_aggregate = combine_daily_to_annual(todays_data, annual_data, "db") + + months = (format_monthly_stats_to_list(monthly_data["data"]),) + monthly_data_aggregate = combine_daily_to_monthly(todays_data, months[0], "db") + else: + # aggregate daily + annual + current_app.logger.info("todays data" + str(todays_data)) + annual_data_aggregate = combine_daily_to_annual(todays_data, annual_data, "redis") + + months = (format_monthly_stats_to_list(monthly_data["data"]),) + monthly_data_aggregate = combine_daily_to_monthly(todays_data, months[0], "redis") + else: + monthly_data_aggregate = ( + format_monthly_stats_to_list(service_api_client.get_monthly_notification_stats(service_id, year)["data"]), + ) + monthly_data_aggregate = monthly_data_aggregate[0] + annual_data_aggregate = None + return render_template( "views/dashboard/monthly.html", - months=format_monthly_stats_to_list(service_api_client.get_monthly_notification_stats(service_id, year)["data"]), + months=monthly_data_aggregate, years=get_tuples_of_financial_years( partial_url=partial(url_for, ".monthly", service_id=service_id), start=current_financial_year - 2, end=current_financial_year, ), + annual_data=annual_data_aggregate, selected_year=year, + current_financial_year=current_financial_year, ) @@ -284,6 +358,21 @@ def aggregate_notifications_stats(template_statistics): def get_dashboard_partials(service_id): + def aggregate_by_type(data, daily_data): + counts = {"sms": 0, "email": 0, "letter": 0} + # flatten out this structure to match the above + for month_data in data["data"].values(): + for message_type, message_counts in month_data.items(): + if isinstance(message_counts, dict): + counts[message_type] += sum(message_counts.values()) + + # add todays data to the annual data + counts = { + "sms": counts["sms"] + daily_data["sms"]["requested"], + "email": counts["email"] + daily_data["email"]["requested"], + } + return counts + all_statistics_weekly = template_statistics_client.get_template_statistics_for_service(service_id, limit_days=7) template_statistics_weekly = aggregate_template_usage(all_statistics_weekly) @@ -300,6 +389,10 @@ def get_dashboard_partials(service_id): dashboard_totals_weekly = (get_dashboard_totals(stats_weekly),) bounce_rate_data = get_bounce_rate_data_from_redis(service_id) + # get annual data from fact table (all data this year except today) + annual_data = service_api_client.get_monthly_notification_stats(service_id, year=get_current_financial_year()) + annual_data = aggregate_by_type(annual_data, dashboard_totals_daily[0]) + return { "upcoming": render_template("views/dashboard/_upcoming.html", scheduled_jobs=scheduled_jobs), "daily_totals": render_template( @@ -308,6 +401,13 @@ def get_dashboard_partials(service_id): statistics=dashboard_totals_daily[0], column_width=column_width, ), + "annual_totals": render_template( + "views/dashboard/_totals_annual.html", + service_id=service_id, + statistics=dashboard_totals_daily[0], + statistics_annual=annual_data, + column_width=column_width, + ), "weekly_totals": render_template( "views/dashboard/_totals.html", service_id=service_id, @@ -329,6 +429,7 @@ def get_dashboard_partials(service_id): def _get_daily_stats(service_id): + # TODO: get from redis, else fallback to template_statistics_client.get_template_statistics_for_service all_statistics_daily = template_statistics_client.get_template_statistics_for_service(service_id, limit_days=1) stats_daily = aggregate_notifications_stats(all_statistics_daily) dashboard_totals_daily = (get_dashboard_totals(stats_daily),) diff --git a/app/notify_client/service_api_client.py b/app/notify_client/service_api_client.py index 6054c374d2..b1274337c7 100644 --- a/app/notify_client/service_api_client.py +++ b/app/notify_client/service_api_client.py @@ -1,5 +1,5 @@ import json -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from flask import current_app from flask_login import current_user @@ -9,6 +9,12 @@ from app.notify_client import NotifyAdminAPIClient, _attach_current_user, cache +def _seconds_until_midnight(): + now = datetime.now(timezone.utc) + midnight = datetime.combine(now + timedelta(days=1), datetime.min.time()) + return int((midnight - now).total_seconds()) + + class ServiceAPIClient(NotifyAdminAPIClient): @cache.delete("user-{user_id}") def create_service( @@ -377,8 +383,15 @@ def is_service_email_from_unique(self, service_id, email_from): def get_service_history(self, service_id): return self.get("/service/{0}/history".format(service_id)) + # TODO: cache this once the backend is updated to exlude data from the current day + # @flask_cache.memoize(timeout=_seconds_until_midnight()) def get_monthly_notification_stats(self, service_id, year): - return self.get(url="/service/{}/notifications/monthly?year={}".format(service_id, year)) + return self.get( + url="/service/{}/notifications/monthly?year={}".format( + service_id, + year, + ) + ) def get_safelist(self, service_id): return self.get(url="/service/{}/safelist".format(service_id)) @@ -622,5 +635,15 @@ def _use_case_data_name(self, service_id): def _tos_key_name(self, service_id): return f"tos-accepted-{service_id}" + def aggregate_by_type(self, notification_data): + counts = {"sms": 0, "email": 0, "letter": 0} + for month_data in notification_data["data"].values(): + for message_type, message_counts in month_data.items(): + if isinstance(message_counts, dict): + counts[message_type] += sum(message_counts.values()) + + # return the result + return counts + service_api_client = ServiceAPIClient() diff --git a/app/templates/components/message-count-label.html b/app/templates/components/message-count-label.html index 81b6764baf..444f58b1ab 100644 --- a/app/templates/components/message-count-label.html +++ b/app/templates/components/message-count-label.html @@ -104,9 +104,9 @@ {%- if session["userlang"] == "fr" -%} {%- if count <= 1 -%} - {{ _('addresse courriel problématique') }} + addresse courriel problématique {%- else -%} - {{ _('addresses courriel problématiques') }} + addresses courriel problématiques {%- endif %} {{" "}} {%- endif %} diff --git a/app/templates/components/terms.html b/app/templates/components/terms.html index 8d3dcf1197..e161a19c85 100644 --- a/app/templates/components/terms.html +++ b/app/templates/components/terms.html @@ -53,7 +53,7 @@

{{ _(headings[1].title) }}

{{ _('Daily limit per service:') }}

-

{{ _('10,000 emails
1000 text messages') }}

+

{{ _('10,000 emails
1,000 text messages') }}

@@ -66,7 +66,7 @@

{{ _(headings[1].title) }}

-

{{ _('10 million emails
25,000 text messages') }}

+

{{ _('20 million emails
100,000 text messages') }}

diff --git a/app/templates/views/api/callbacks/delivery-status-callback.html b/app/templates/views/api/callbacks/delivery-status-callback.html index 92c5b0e118..e561c16236 100644 --- a/app/templates/views/api/callbacks/delivery-status-callback.html +++ b/app/templates/views/api/callbacks/delivery-status-callback.html @@ -41,13 +41,13 @@

autocomplete='new-password' ) }} {% set test_response_txt = _('Test response time') if has_callback_config else None %} - {% set test_response_value = _('test_response_time') if has_callback_config else None %} + {% set test_response_value = 'test_response_time' if has_callback_config else None %} {% set display_footer = is_deleting if is_deleting else False %} {% set delete_link = url_for('.delete_delivery_status_callback', service_id=current_service.id) if has_callback_config else None%} {% if not display_footer %} {{ sticky_page_footer_two_submit_buttons_and_delete_link( button1_text=_('Save'), - button1_value=_('save'), + button1_value='save', button2_text=test_response_txt, button2_value=test_response_value, delete_link=delete_link, diff --git a/app/templates/views/api/callbacks/received-text-messages-callback.html b/app/templates/views/api/callbacks/received-text-messages-callback.html index 7056ffa71d..e43e2b55e7 100644 --- a/app/templates/views/api/callbacks/received-text-messages-callback.html +++ b/app/templates/views/api/callbacks/received-text-messages-callback.html @@ -36,14 +36,14 @@

autocomplete='new-password' ) }} {% set test_response_txt = _('Test response time') if has_callback_config else None %} - {% set test_response_value = _('test_response_time') if has_callback_config else None %} + {% set test_response_value = 'test_response_time' if has_callback_config else None %} {% set display_footer = is_deleting if is_deleting else False %} {% set delete_link = url_for('.delete_received_text_messages_callback', service_id=current_service.id) if has_callback_config else None%} {% set delete_link_text = _('Delete') if has_callback_config else None %} {% if not display_footer %} {{ sticky_page_footer_two_submit_buttons_and_delete_link( button1_text=_('Save'), - button1_value=_('save'), + button1_value='save', button2_text=test_response_txt, button2_value=test_response_value, delete_link=delete_link, diff --git a/app/templates/views/check/column-errors.html b/app/templates/views/check/column-errors.html index c3ef2200f8..32f2ae9e8e 100644 --- a/app/templates/views/check/column-errors.html +++ b/app/templates/views/check/column-errors.html @@ -161,7 +161,8 @@

- {{ _("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))}} + {{ _("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 %} {% call banner_wrapper(type='dangerous') %} @@ -173,7 +174,8 @@

{{ _('You cannot send all these text messages today') {% 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))}} + {{ _("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/views/dashboard/_totals_annual.html b/app/templates/views/dashboard/_totals_annual.html new file mode 100644 index 0000000000..4985ec80be --- /dev/null +++ b/app/templates/views/dashboard/_totals_annual.html @@ -0,0 +1,25 @@ +{% from "components/big-number.html" import big_number %} +{% from "components/message-count-label.html" import message_count_label %} +{% from 'components/remaining-messages.html' import remaining_messages %} +{% from "components/show-more.html" import show_more %} + +
+

+ {{ _('Annual usage') }} +
+ + {% set current_year = current_year or (now().year if now().month < 4 else now().year + 1) %} + {{ _('resets on April 1, ') ~ current_year }} + +

+
+
+ {{ remaining_messages(header=_('emails'), total=current_service.email_annual_limit, used=statistics_annual['email'], muted=true) }} +
+
+ {{ remaining_messages(header=_('text messages'), total=current_service.sms_annual_limit, used=statistics_annual['sms'], muted=true) }} +
+
+ {{ show_more(url_for('.monthly', service_id=current_service.id), _('Visit usage report')) }} +
+ diff --git a/app/templates/views/dashboard/_totals_daily.html b/app/templates/views/dashboard/_totals_daily.html index 8b9d9e0dcb..98195f1a5d 100644 --- a/app/templates/views/dashboard/_totals_daily.html +++ b/app/templates/views/dashboard/_totals_daily.html @@ -1,8 +1,29 @@ {% from "components/big-number.html" import big_number %} {% from "components/message-count-label.html" import message_count_label %} {% from 'components/remaining-messages.html' import remaining_messages %} +{% from "components/show-more.html" import show_more %} -
+{% if config["FF_ANNUAL_LIMIT"] %} +
+

+ {{ _('Daily usage') }} +
+ + {{ _('resets at 7pm Eastern Time') }} + +

+
+
+ {{ remaining_messages(header=_('emails'), total=current_service.message_limit, used=statistics['email']['requested'], muted=true) }} +
+
+ {{ remaining_messages(header=_('text messages'), total=current_service.sms_daily_limit, used=statistics['sms']['requested'], muted=true) }} +
+
+ {{ show_more(url_for('main.contact'), _('Request a daily limit increase')) }} +
+{% else %} +

{{ _('Daily usage') }}
@@ -18,4 +39,5 @@

{{ remaining_messages(header=_('text messages'), total=current_service.sms_daily_limit, used=statistics['sms']['requested']) }}

- + +{% endif %} diff --git a/app/templates/views/dashboard/dashboard.html b/app/templates/views/dashboard/dashboard.html index 110a94f241..4e005d86bb 100644 --- a/app/templates/views/dashboard/dashboard.html +++ b/app/templates/views/dashboard/dashboard.html @@ -26,7 +26,10 @@

{{ _("Scheduled sends") }}

{{ ajax_block(partials, updates_url, 'weekly_totals', interval=5) }} {{ ajax_block(partials, updates_url, 'daily_totals', interval=5) }} - + {% if config["FF_ANNUAL_LIMIT"] %} + {{ ajax_block(partials, updates_url, 'annual_totals', interval=5) }} + {% endif %} +
{% if partials['has_template_statistics'] %} diff --git a/app/templates/views/dashboard/monthly.html b/app/templates/views/dashboard/monthly.html index 7aacbb685a..5fe77f7253 100644 --- a/app/templates/views/dashboard/monthly.html +++ b/app/templates/views/dashboard/monthly.html @@ -1,22 +1,23 @@ -{% from "components/big-number.html" import big_number_with_status, big_number %} +{% from "components/big-number.html" import big_number_with_status, big_number, big_number_simple %} {% from "components/pill.html" import pill %} {% from "components/table.html" import list_table, field, hidden_field_heading, right_aligned_field_heading, row_heading %} {% from "components/message-count-label.html" import message_count_label %} +{% from 'components/remaining-messages.html' import remaining_messages %} {% extends "admin_template.html" %} {% block service_page_title %} - {{ _('Messages sent,') }} + {{ _('Usage report') }} {{ selected_year }} {{ _('to') }} {{ selected_year + 1 }} {{ _('fiscal year') }} {% endblock %} {% block maincolumn_content %}

- {{ _('Messages sent') }} + {{ _('Usage report') }}

-
+
{{ pill( items=years, current_value=selected_year, @@ -25,6 +26,50 @@

) }}

+ {% if config["FF_ANNUAL_LIMIT"] %} +

+ {% if selected_year == current_financial_year %} + {{ _('Annual limit overview') }} + {% else %} + {{ _('Annual overview') }} + {% endif %} +
+ + {{ _('Fiscal year begins April 1, ') ~ selected_year ~ _(' and ends March 31, ') ~ (selected_year + 1) }} + +

+
+ {% if selected_year == current_financial_year %} +
+ {{ remaining_messages(header=_('emails'), total=current_service.email_annual_limit, used=annual_data['email']) }} +
+
+ {{ remaining_messages(header=_('text messages'), total=current_service.sms_annual_limit, used=annual_data['sms']) }} +
+ {% else %} +
+ {{ big_number_simple( + annual_data['email'], + _('emails'), + + ) + }} +
+
+ {{ big_number_simple( + annual_data['sms'], + _('text messages'), + + ) + }} +
+ {% endif %} +
+

+ {{ _('Month by month totals') }} +

+ {% endif %} + {% if months %} {% set spend_txt = _('Total spend') %} {% set heading_1 = _('Month') %} diff --git a/app/templates/views/email-branding/branding-goc.html b/app/templates/views/email-branding/branding-goc.html index d9fc13c351..e059b08f82 100644 --- a/app/templates/views/email-branding/branding-goc.html +++ b/app/templates/views/email-branding/branding-goc.html @@ -24,7 +24,8 @@

{{ _('Change the default langu {% call form_wrapper() %} {{ radios(form.goc_branding, hide_legend=True, testid="goc_branding") }} - {{ _('{}').format(url_for('main.review_branding_pool', service_id=current_service.id), _('Select alternate logo')) }} + {{ _('Select alternate logo') }}
{{ page_footer( diff --git a/app/templates/views/email-branding/branding-pool.html b/app/templates/views/email-branding/branding-pool.html index daa3366124..1d293017c7 100644 --- a/app/templates/views/email-branding/branding-pool.html +++ b/app/templates/views/email-branding/branding-pool.html @@ -29,7 +29,8 @@
- {{ _('{}').format(url_for('main.create_branding_request', service_id=current_service.id), _('Request a new logo')) }} + {{ + _('Request a new logo') }}
{{ page_footer(_('Preview'), testid="preview") }} diff --git a/app/templates/views/email-branding/branding-request-submitted.html b/app/templates/views/email-branding/branding-request-submitted.html index 968dad1359..70ab504f0c 100644 --- a/app/templates/views/email-branding/branding-request-submitted.html +++ b/app/templates/views/email-branding/branding-request-submitted.html @@ -31,7 +31,7 @@ {{ task_shortcut( description=_("Explore other settings"), link_url=url_for('main.service_settings', service_id=current_service.id), - link_text=_("Go to your Settings"), + link_text=_("Go to your settings"), icon="arrow-right" )}}
diff --git a/app/templates/views/notifications/check.html b/app/templates/views/notifications/check.html index 044eb29281..17c668b0c8 100644 --- a/app/templates/views/notifications/check.html +++ b/app/templates/views/notifications/check.html @@ -43,7 +43,8 @@ {% endcall %}

{{_('You cannot send this text message today') }}

- {{ _("You can try sending this message 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))}} + {{ _("You can try sending this message 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 error == 'too-many-messages' %} @@ -61,7 +62,8 @@

{{_('You cannot send this text message today') }}

{{_('You cannot send this email message today') }}

- {{ _("You can try sending this message 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))}} + {{ _("You can try sending this message 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 error == 'message-too-long' %} diff --git a/app/templates/views/service-settings/email_from.html b/app/templates/views/service-settings/email_from.html index 321b6ee3f0..b9b9851efc 100644 --- a/app/templates/views/service-settings/email_from.html +++ b/app/templates/views/service-settings/email_from.html @@ -23,8 +23,7 @@ {% call form_wrapper() %} {% set save_txt = _('Save') %} - {% set hint_txt = _('Maximum 64 characters with no spaces. Characters can include letters, numbers, dots, dashes, and - underscores.') %} + {% set hint_txt = _('Maximum 64 characters with no spaces. Characters can include letters, numbers, dots, dashes, and underscores.') %} {{ textbox(form.email_from, hint=hint_txt) }}
{% call confirmation_preview() %} diff --git a/app/templates/views/service-settings/set-free-sms-allowance.html b/app/templates/views/service-settings/set-free-sms-allowance.html index c410b58d10..d06a4ccd00 100644 --- a/app/templates/views/service-settings/set-free-sms-allowance.html +++ b/app/templates/views/service-settings/set-free-sms-allowance.html @@ -12,7 +12,7 @@ {% call form_wrapper() %} {{ page_header( - _('Free text messages per year'), + _('Free text messages per fiscal year'), back_link=url_for('.service_settings', service_id=current_service.id) ) }} {{ textbox(form.free_sms_allowance) }} diff --git a/app/translations/csv/fr.csv b/app/translations/csv/fr.csv index 4641060992..57aead4f21 100644 --- a/app/translations/csv/fr.csv +++ b/app/translations/csv/fr.csv @@ -1,6 +1,6 @@ "source","target" "\!/\!/ HELLO DEVS, READ THIS FOR DOCUMENTATION \!/\!/","Read more about how translations work in scripts/generate_en_translations.py" -":"," :" +": "," : " "English","Anglais" "French","Français" "Your account and language","Votre compte et langue" @@ -56,6 +56,7 @@ "Who runs this service?","Qui assure la gestion du service?" "What’s your key called?","Quel est le nom de votre clé?" "Numbers of text messages per fiscal year","Nombre de messages texte par exercice financier" +"Free text messages per fiscal year","Messages texte gratuits par exercice financier" "Enter password","Entrez votre mot de passe" "Invalid password","Mot de passe non valide" "Template name","Nom du gabarit" @@ -127,6 +128,7 @@ "Text message","Message texte" "Choose a folder","Choisissez un dossier" "Create template","Créer un gabarit" +"Create category","Créer une catégorie" "Will you send the message by email or text?","Enverrez-vous le message par courriel ou par texte?" "What’s their name?","Quel est son nom?" "What’s their email address?","Quelle est son adresse courriel?" @@ -653,6 +655,7 @@ "Count in list of live services","Compte dans la liste de services activés" "Organisation","Organisation" "Free text message allowance","Nombre de messages texte gratuits" +"Free text messages per year","Nombre de messages texte gratuits par année" "text messages per fiscal year","Allocation de messages texte par exercice financier" "Letter branding","Image de marque de la lettre" "Data retention","Rétention des données" @@ -1080,6 +1083,7 @@ "Daily text message limit","Limite quotidienne de message texte" "Last edited","Dernière modification : " "See previous versions","Voir les versions précédentes" +"Version {}","Version {}" "Delete this template","Supprimer ce gabarit" "Redact personalised variable content after sending","Masquer le contenu variable personnalisé après l’envoi" "Personalised variable content redacted after sending","Le contenu variable personnalisé masqué après l’envoi" @@ -1426,6 +1430,7 @@ "Filter by year","Filtrer par année" "Filter by status","Filtrer par état de livraison" "Filter by template type","Filtrer par type de gabarit" +"Filter by template type and category","Filtrer par type et catégorie de gabarit" "Top of page","Haut de page" "Your service is in trial mode. Trial mode limits your service to sending notifications to yourself and to your team members within GC Notify.","Votre service est en mode d’essai. Le mode d’essai limite l’envoi de notifications à vous-même et aux autres membres de votre équipe sur Notification GC." "Complete the following steps to go live and send notifications to more people.","Complétez les étapes suivantes pour activer votre service et envoyer des messages à d’autres personnes." @@ -1637,6 +1642,8 @@ "Back to template {}","Retour au gabarit {}" "Previewing template {}","Aperçu du gabarit {}" "You need a new password","Vous devez créer un nouveau mot de passe" +"You need to create a new password","Vous devez créer un nouveau mot de passe" +"GC Notify needs you to create a new password for this account.","Notification GC vous demande de créer un nouveau mot de passe pour ce compte." "As a security precaution, all users of GC Notify must change their password.","Par mesure de sécurité, tou·te·s les utilisateur·rice·s de Notification GC doivent changer leur mot de passe." "Check your email. If you did not receive the link,","Nous vous avons envoyé un courriel contenant un lien de réinitialisation. Si vous ne l’avez pas reçu," "contact support.","veuillez contacter notre équipe de soutien." @@ -1650,7 +1657,7 @@ "The link in the email we sent you has expired","Le lien que nous vous avons envoyé par courriel a expiré." "Check your email. We sent you a password reset link.","Vérifiez votre courriel. Nous vous avons envoyé un lien pour réinitialiser votre mot de passe." "Why we are asking you to create a new password","Pourquoi nous vous demandons de créer un nouveau mot de passe." -"GC Notify has expired all user passwords out of an abundance of caution following the discovery of a potential security risk on March 29, 2022.", Notification GC a expiré le mot de passe de tous les utilisateurs par excès de prudence, suivant la découverte d’une faille potentielle le 29 mars 2022." +"GC Notify has expired all user passwords out of an abundance of caution following the discovery of a potential security risk on March 29, 2022.","Notification GC a expiré le mot de passe de tous les utilisateurs par excès de prudence, suivant la découverte d’une faille potentielle le 29 mars 2022." "If you don't receive a password reset link in your inbox,","Si vous ne recevez pas de courriel pour réinitialiser votre mot de passe," "please contact our support team.","contactez notre équipe de soutien technique." "A password that is hard to guess contains:","Un mot de passe difficile à deviner contient les caractéristiques suivantes:" @@ -1705,7 +1712,8 @@ "This message exceeds your daily email limit","Ce message dépasse votre limite quotidienne d'envoi de courriels" "You’ve sent too many text messages.","Vous avez envoyé trop de messages texte." "You can send more text messages after {} Eastern Time. To raise your daily limit, {contact_us}.","Vous pourrez envoyer d’autres messages texte après {} heures, heure de l’Est. Pour augmenter votre limite d’envoi quotidienne, {contact_us}." -"You can try sending these messages after {} Eastern Time. Check your current local time.","Vous pourrez envoyer ces messages après {} heures, heure de l’Est. Comparez les heures officielles au Canada." +"You can try sending these messages after {} Eastern Time. Check {}.","Vous pourrez envoyer ces messages après {} heures, heure de l’Est. Comparez {}." +"https://nrc.canada.ca/en/web-clock/","https://nrc.canada.ca/fr/horloge-web/" "Attachment has virus","La pièce jointe contient un virus" "Review reports","Examiner les rapports" "Enter name of your group","Saisissez le nom de votre groupe" @@ -1716,8 +1724,9 @@ "For example: Treasury Board of Canada Secretariat","Par exemple : Secrétariat du Conseil du Trésor du Canada" "For example: Canadian Digital Service","Par exemple : Service numérique canadien" "Not on list?","Vous ne trouvez pas?" +"Not on the list? Add your organization","Vous ne trouvez pas? Ajoutez votre organisation" "Choose name from drop-down menu","Choisissez un nom dans le menu déroulant" -"Tech issue","Problème technique", +"Tech issue","Problème technique" "Content or inbox issue","Problème de contenu ou de boîte de réception" "In transit","Envoi en cours" "Exceeds Protected A","Niveau supérieur à Protégé A" @@ -1766,8 +1775,7 @@ "Annual maximum
(April 1 to March 31)","Maximum par exercice financier" "To request a daily limit above {} emails, {}","Si vous désirez obtenir une limite quotidienne supérieure à {} courriels, veuillez {}" "To request a daily limit above {} text messages, {}","Si vous désirez obtenir une limite quotidienne supérieure à {} messages texte, veuillez {}" -"You can try sending these messages after {} Eastern Time. Check your {}.","Vous pourrez envoyer ces messages après {} heures, heure de l’Est. Comparez {}" -"You can try sending this message after {} Eastern Time. Check your {}.","Vous pourrez envoyer ce message après {} heures, heure de l’Est. Comparez {}" +"You can try sending this message after {} Eastern Time. Check {}.","Vous pourrez envoyer ce message après {} heures, heure de l’Est. Comparez {}." "your current local time","les heures officielles au Canada" "You cannot send this email message today","Vous ne pouvez pas envoyer ce courriel aujourd’hui." "You cannot send this text message today","Vous ne pouvez pas envoyer ce message texte aujourd’hui." @@ -1776,12 +1784,16 @@ "of","de" "Sent since 7 pm Eastern Time","Envoyé depuis 19 h, heure de l'Est" "You are nearing the daily {} limit","Vous approchez de la limite quotidienne de {}" +"Below limit: ","Vous êtes en dessous de votre limite : " +"Near limit: ","Vous approchez de votre limite : " +"At limit: ","Vous avez atteint votre limite : " "Daily usage","Utilisation quotidienne" "Message limits reset each night at 7pm Eastern Time","Les limites d’envoi sont réinitialisées chaque soir à 19 h, heure de l’Est" "Maximum 612 characters. Some messages may be too long due to custom content.","612 caractères au maximum. Certains messages peuvent être trop longs en raison de leur contenu personnalisé." "Too many characters","Limite de caractère atteinte" "New features","Nouvelles fonctionnalités" "Your","Votre" +"your","votre" "You are browsing templates. Create and copy template or add new folder.","Vous explorez les gabarits. Créer et copier un gabarit ou créer un nouveau dossier." "Move templates to a new or existing folder","Déplacer les gabarits dans un dossier" "You are selecting templates. Move templates into a new or existing folder.","Vous sélectionnez les gabarits. Déplacer les gabarits dans un dossier." @@ -1918,7 +1930,7 @@ "Read and agree to the terms of use","Lisez et acceptez les conditions d’utilisation" "Read and agree to continue","Lisez et acceptez les conditions d’utilisation" "Agree follows terms of use","Accepter suite aux conditions d'utilisation" -"Priority","Envoi prioritaire", +"Priority","Envoi prioritaire" "Bulk","Envoi de masse" "Text message priority","Niveau de priorité des messages texte" "Hide category","Visibilité de la catégorie" @@ -1944,9 +1956,9 @@ "We apply limits to maintain our ","Nous appliquons des limites afin de maintenir nos " "To discuss these limits, ","Si vous souhaitez en discuter avec nous, n’hésitez pas à " "Daily limit per service:","Limite par jour et par service :" -"10,000 emails
1000 text messages","10 000 courriels
1 000 messages texte" +"10,000 emails
1,000 text messages","10 000 courriels
1 000 messages texte" "Annual limit per service (April 1 to March 31):","Limite par année et par service (du 1er avril au 31 mars) :" -"10 million emails
25,000 text messages","10 millions de courriels
25 000 messages texte" +"20 million emails
100,000 text messages","20 millions de courriels
100 000 messages texte" "Texts are:","Les messages texte :" "Unencrypted, and have other security issues. Bad actors may be able to impersonate your service.","ne sont pas chiffrés et présentent d’autres problèmes en matière de sécurité (disponible en anglais seulement). Des personnes mal intentionnées pourraient être en mesure d’usurper l’identité de votre service;" "At higher risk than email for delay, delivery failure, or lack of information about delivery status.","présentent un risque plus élevé de retard, d’échec de livraison ou de manque d’information sur l’état de livraison que les courriels;" @@ -2004,3 +2016,15 @@ "Annual text message limit","(FR) Limite maximale de messages texte par exercice financier" "Annual email message limit","(FR) Limite maximale de messages électroniques par exercice financier" "Annual email limit","(FR) Limite maximale de courriels par exercice financier" +"Test response time","Tester le temps de réponse" +"No records found.","Aucun enregistrement trouvé." +" and ends March 31, "," et se termine le 31 mars " +"Annual limit overview","Aperçu de la limite annuelle" +"Annual overview","Aperçu annuel" +"Usage report","Rapport d’utilisation" +"Fiscal year begins April 1, ","Réinitialisation le 1er avril " +"resets on April 1, ","Réinitialisation le 1er avril " +"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 diff --git a/babel.cfg b/babel.cfg index 19d23b74a1..2c649d5cee 100644 --- a/babel.cfg +++ b/babel.cfg @@ -1 +1,2 @@ [python: app/**.py] +[jinja2: app/templates/**.html] \ No newline at end of file diff --git a/gunicorn_config.py b/gunicorn_config.py index b679ae9a23..63325ca386 100644 --- a/gunicorn_config.py +++ b/gunicorn_config.py @@ -1,11 +1,13 @@ import os import sys +import time import traceback import gunicorn # type: ignore import newrelic.agent # See https://bit.ly/2xBVKBH -newrelic.agent.initialize() # noqa: E402 +environment = os.environ.get("NOTIFY_ENVIRONMENT") +newrelic.agent.initialize(environment=environment) # noqa: E402 # Guincorn sets the server type on our app. We don't want to show it in the header in the response. gunicorn.SERVER = "Undisclosed" @@ -20,10 +22,34 @@ # to be larger than the idle timeout configured for the load balancer. # > By default, Elastic Load Balancing sets the idle timeout value for your load balancer to 60 seconds. # https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#connection-idle-timeout -on_aws = os.environ.get("NOTIFY_ENVIRONMENT", "") in ["production", "staging", "scratch", "dev"] +on_aws = environment in ["production", "staging", "scratch", "dev"] if on_aws: keepalive = 75 + # The default graceful timeout period for Kubernetes is 30 seconds, so + # make sure that the timeouts defined here are less than the configured + # Kubernetes timeout. This ensures that the gunicorn worker will exit + # before the Kubernetes pod is terminated. This is important because + # Kubernetes will send a SIGKILL to the pod if it does not terminate + # within the grace period. If the worker is still processing requests + # when it receives the SIGKILL, it will be terminated abruptly and + # will not be able to finish processing the request. This can lead to + # 502 errors being returned to the client. + # + # Also, some libraries such as NewRelic might need some time to finish + # initialization before the worker can start processing requests. The + # timeout values should consider these factors. + # + # Gunicorn config: + # https://docs.gunicorn.org/en/stable/settings.html#graceful-timeout + # + # Kubernetes config: + # https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/ + graceful_timeout = 85 + timeout = 90 + +# Start timer for total running time +start_time = time.time() def on_starting(server): server.log.info("Starting Notifications Admin") @@ -36,7 +62,9 @@ def worker_abort(worker): def on_exit(server): + elapsed_time = time.time() - start_time server.log.info("Stopping Notifications Admin") + server.log.info("Total gunicorn Admin running time: {:.2f} seconds".format(elapsed_time)) def worker_int(worker): diff --git a/newrelic.ini b/newrelic.ini index 63d6fe8e32..82898ab3f0 100644 --- a/newrelic.ini +++ b/newrelic.ini @@ -180,6 +180,16 @@ browser_monitoring.auto_instrument = false # call tree. # thread_profiler.enabled = true +# If this setting is enabled, it will capture package and version +# information on startup of the agent that is displayed in the APM +# environment tab. +# In applications that have a large number of packages, having this +# setting enabled may cause a CPU spike as it captures all the package +# and version information. It is recommended in those cases to disable +# this setting. +# Disabling this setting will disable the ability to detect vulnerabilities in outdated packages. +package_reporting.enabled = false + # --------------------------------------------------------------------------- # @@ -188,7 +198,7 @@ browser_monitoring.auto_instrument = false # specific environment will be used when the environment argument to the # newrelic.agent.initialize() function has been defined to be either # "development", "test", "staging" or "production". -# + [newrelic:development] # monitor_mode = false diff --git a/poetry.lock b/poetry.lock index 4a7de69cef..bb32568820 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1479,26 +1479,40 @@ files = [ [[package]] name = "newrelic" -version = "8.10.0" +version = "10.3.0" description = "New Relic Python Agent" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" -files = [ - {file = "newrelic-8.10.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:cf3b67327e64d2b50aec855821199b2bc46bc0c2d142df269d420748dd49b31b"}, - {file = "newrelic-8.10.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9601d886669fe1e0c23bbf91fb68ab23086011816ba96c6dd714c60dc0a74088"}, - {file = "newrelic-8.10.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:55a64d2abadf69bbc7bb01178332c4f25247689a97b01a62125d162ea7ec8974"}, - {file = "newrelic-8.10.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:b6cddd869ac8f7f32f6de8212ae878a21c9e63f2183601d239a76d38c5d5a366"}, - {file = "newrelic-8.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9af0130e1f1ca032c606d15a6d5558d27273a063b7c53702218b3beccd50b23"}, - {file = "newrelic-8.10.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2fd24b32dbf510e4e3fe40b71ad395dd73a4bb9f5eaf59eb5ff22ed76ba2d41"}, - {file = "newrelic-8.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2567ba9e29fd7b9f4c23cf16a5a149097eb0e5da587734c5a40732d75aaec189"}, - {file = "newrelic-8.10.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9c9f7842234a51e4a2fdafe42c42ebe0b6b1966279f2f91ec8a9c16480c2236"}, - {file = "newrelic-8.10.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:365d3b1a10d1021217beeb28a93c1356a9feb94bd24f02972691dc71227e40dc"}, - {file = "newrelic-8.10.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecd0666557419dbe11b04e3b38480b3113b3c4670d42619420d60352a1956dd8"}, - {file = "newrelic-8.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722072d57e2d416de68b650235878583a2a8809ea39c7dd5c8c11a19089b7665"}, - {file = "newrelic-8.10.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbda843100c99ac3291701c0a70fedb705c0b0707800c60b93657d3985aae357"}, - {file = "newrelic-8.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ed36fb91f152128825459eae9a52da364352ea95bcd78b405b0a5b8057b2ed7"}, - {file = "newrelic-8.10.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc975c29548e25805ead794d9de7ab3cb8ba4a6a106098646e1ab03112d1432e"}, - {file = "newrelic-8.10.0.tar.gz", hash = "sha256:8a2271b76ea684a63936302579d6085d46a2b54042cb91dc9b0d71a0cd4dd38b"}, +python-versions = ">=3.7" +files = [ + {file = "newrelic-10.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cc803e30b72e2afe7b759a79fbadbed557aa5d51ef36f26d68e9d0aeb156a7f"}, + {file = "newrelic-10.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2fbdbbb4de1cb305b2c0155639fe74bf6925e54cc014a07176f46fba396cb03"}, + {file = "newrelic-10.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4a5f11d99621495c7edd121c96d8a71f9af4c0036de8546bc3aac94b6183a3f0"}, + {file = "newrelic-10.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b1caebc9854614435acc28b95efa1b8b2a30eb9ac8778a96ff65ca619ce7e832"}, + {file = "newrelic-10.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:667e91dc56a99358d08d93e9372f5889b3ba0650d0911718baef68ea607419d1"}, + {file = "newrelic-10.3.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5dc21702764dffb8d55349fa75ad3d37a9ee53ab4ade3cc8cb347bdf0ba36268"}, + {file = "newrelic-10.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:080e764b50be8522df204d5f8fb26bdfa64945da85fe2786bc3051318fa77188"}, + {file = "newrelic-10.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e23dfbc55ba7baa3b65d4f2739ba328e15466a4d4767a85fe732082c423b88e0"}, + {file = "newrelic-10.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30e272c8aa1d812bfe4609db48aa19d3322627263906f5a1cbba267625d0b40f"}, + {file = "newrelic-10.3.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31face7377b9083b0e889ea69d2a7d95a79dfdbfcf1bf92256e03d8863c92a68"}, + {file = "newrelic-10.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b459cdb67f3dc64ac16420a951f3c6c2e75b8f4bc68b2bfb3f395e83d611a61"}, + {file = "newrelic-10.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9085b07ec7e43c079137b515c007542b8f7ecda15504fc7e863fd235481bd87"}, + {file = "newrelic-10.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92c3da1480db8cd2c6f17e7b7bd09bfcb4a755caab823c9770185a97446f693d"}, + {file = "newrelic-10.3.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f91ad51693f98be5dfd7d115588059e4f2371628aef7412ca830bfdb001d588"}, + {file = "newrelic-10.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8847709eeda25ce9e577f2d593f0ca12e3de04d3e816e6fda29fa10a4e211900"}, + {file = "newrelic-10.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0f587779f03b0dc1131e2067221042ce796551bbb73a361046f25102763d1718"}, + {file = "newrelic-10.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb741158af8b84638187d12cce54263e4f14eb031778eea03a45cedb92b92678"}, + {file = "newrelic-10.3.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e8e60d72a8c9d31af96ada5bc20cecb9331079061c5cd0e63ebeed514526678"}, + {file = "newrelic-10.3.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7af34b3d833f15b4deccd8a0909231ba9adb3e86106630e1a1003d8fc57e0710"}, + {file = "newrelic-10.3.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bbd6f9f67fc17337719ef4a5abd5c988824e7b824b598dcfebe54d482cca3ab8"}, + {file = "newrelic-10.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4315353409952f89c84f2cf99de50aadd793c18ee112701319e96f79e166feec"}, + {file = "newrelic-10.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fb7c6fcce05c2b7ed00801c10f6dc22efac68debe54ca0c2ff0b1b0e1ad81f8"}, + {file = "newrelic-10.3.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3c0a41f83a003d87a31a3e290f8c54d680b1590baa86a62ffb60f576ae4bf951"}, + {file = "newrelic-10.3.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:17cbc679ec01bdd01092c159dc76a5c3abe91a5fd713dcf9429103162792cd5b"}, + {file = "newrelic-10.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7f9566282a6c0b1d733dc014b2123577c16a65be4814ea48af46d2c4de2a57"}, + {file = "newrelic-10.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef5453250fd570af30be7a384b1c85fa5b92ad08a748f3266ea3540f1f06eb"}, + {file = "newrelic-10.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ee4ff34ab06f0df4ba823dd368fb79789bcdabe4ffa3c0b880a53a65f610f852"}, + {file = "newrelic-10.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a9453656125daf9b2ece9e49d6d99b064dbb9e85adc1ff6fadd4bdb89ab2f4cd"}, + {file = "newrelic-10.3.0.tar.gz", hash = "sha256:26040d0b707c30dba2c93b3122e87b39d98cc4910bcbb518bf7ab7c8ab62a5b8"}, ] [package.extras] diff --git a/pyproject.toml b/pyproject.toml index a5dedcf4ed..f8eeadd75c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,13 +52,14 @@ awscli-cwlogs = "^1.4.6" notifications-utils = { git = "https://github.com/cds-snc/notifier-utils.git", branch = "task/pythonupgrade"} + # Pinned dependencies certifi = "^2024.0.0" # Pinned for security reasons: https://github.com/cds-snc/notification-admin/security/dependabot/169 redis = "3.5.3" # pinned for now xlrd = "1.2.0" # this is pinned so that we can continue to support .xslm files; see https://github.com/pyexcel/pyexcel-xls/issues/53 # Putting upgrade on hold due to v1.0.0 using sha512 instead of sha1 by default itsdangerous = "2.2.0" # pyup: <1.0.0 -newrelic = "8.10.0" # Pinned to 8.10.0 for now, 8.11.0 caused a performance regression: https://gcdigital.slack.com/archives/C012W5K734Y/p1709668046344929 +newrelic = "10.3.0" aws-xray-sdk = "^2.14.0" validators = "^0.28.3" diff --git a/tests/__init__.py b/tests/__init__.py index 7e6bef33e6..494d6797dc 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -176,8 +176,8 @@ def service_json( message_limit=1000, sms_daily_limit=1000, rate_limit=100, - email_annual_limit=10000000, - sms_annual_limit=25000, + email_annual_limit=20000000, + sms_annual_limit=100000, active=True, restricted=True, email_from="test.service", diff --git a/tests/app/main/views/test_dashboard.py b/tests/app/main/views/test_dashboard.py index b87baa165b..a00980bc6c 100644 --- a/tests/app/main/views/test_dashboard.py +++ b/tests/app/main/views/test_dashboard.py @@ -1,7 +1,9 @@ import copy import re +from unittest.mock import ANY import pytest +from bs4 import BeautifulSoup from flask import url_for from freezegun import freeze_time @@ -21,6 +23,7 @@ create_active_caseworking_user, create_active_user_view_permissions, normalize_spaces, + set_config, ) stub_template_stats = [ @@ -137,6 +140,7 @@ def test_task_shortcuts_are_visible_based_on_permissions( mock_get_service_templates, mock_get_jobs, mock_get_template_statistics, + mock_get_service_statistics, permissions: list, text_in_page: list, text_not_in_page: list, @@ -170,6 +174,7 @@ def test_survey_widget_presence( mock_get_service_templates, mock_get_jobs, mock_get_template_statistics, + mock_get_service_statistics, mocker, admin_url, is_widget_present, @@ -193,6 +198,7 @@ def test_sending_link_has_query_param( mock_get_service_templates, mock_get_jobs, mock_get_template_statistics, + mock_get_service_statistics, ): active_user_with_permissions["permissions"][SERVICE_ONE_ID] = ["view_activity", "send_messages"] client_request.login(active_user_with_permissions) @@ -209,6 +215,7 @@ def test_no_sending_link_if_no_templates( client_request: ClientRequest, mock_get_service_templates_when_no_templates_exist, mock_get_template_statistics, + mock_get_service_statistics, mock_get_jobs, ): page = client_request.get("main.service_dashboard", service_id=SERVICE_ONE_ID) @@ -305,11 +312,7 @@ def test_should_show_monthly_breakdown_of_template_usage( def test_anyone_can_see_monthly_breakdown( - client, - api_user_active, - service_one, - mocker, - mock_get_monthly_notification_stats, + client, api_user_active, service_one, mocker, mock_get_monthly_notification_stats, mock_get_service_statistics ): validate_route_permission_with_client( mocker, @@ -324,16 +327,14 @@ def test_anyone_can_see_monthly_breakdown( def test_monthly_shows_letters_in_breakdown( - client_request, - service_one, - mock_get_monthly_notification_stats, + client_request, service_one, mock_get_monthly_notification_stats, mock_get_service_statistics ): page = client_request.get("main.monthly", service_id=service_one["id"]) columns = page.select(".table-field-left-aligned .big-number-label") - assert normalize_spaces(columns[0].text) == "emails" - assert normalize_spaces(columns[1].text) == "text messages" + assert normalize_spaces(columns[2].text) == "emails" + assert normalize_spaces(columns[3].text) == "text messages" @pytest.mark.parametrize( @@ -345,10 +346,7 @@ def test_monthly_shows_letters_in_breakdown( ) @freeze_time("2015-01-01 15:15:15.000000") def test_stats_pages_show_last_3_years( - client_request, - endpoint, - mock_get_monthly_notification_stats, - mock_get_monthly_template_usage, + client_request, endpoint, mock_get_monthly_notification_stats, mock_get_monthly_template_usage, mock_get_service_statistics ): page = client_request.get( endpoint, @@ -361,9 +359,7 @@ def test_stats_pages_show_last_3_years( def test_monthly_has_equal_length_tables( - client_request, - service_one, - mock_get_monthly_notification_stats, + client_request, service_one, mock_get_monthly_notification_stats, mock_get_service_statistics ): page = client_request.get("main.monthly", service_id=service_one["id"]) @@ -401,31 +397,6 @@ def test_should_show_upcoming_jobs_on_dashboard( assert table_rows[1].find_all("td")[0].text.strip() == "Scheduled to send to 30 recipients" -@pytest.mark.parametrize( - "permissions, column_name, expected_column_count", - [ - (["email", "sms"], ".w-1\\/2", 6), - (["email", "sms"], ".w-1\\/2", 6), - ], -) -def test_correct_columns_display_on_dashboard_v15( - client_request: ClientRequest, - mock_get_service_templates, - mock_get_template_statistics, - mock_get_service_statistics, - mock_get_jobs, - service_one, - permissions, - expected_column_count, - column_name, - app_, -): - service_one["permissions"] = permissions - - page = client_request.get("main.service_dashboard", service_id=service_one["id"]) - assert len(page.select(column_name)) == expected_column_count - - def test_daily_usage_section_shown( client_request, mocker, @@ -1424,3 +1395,201 @@ def test_dashboard_daily_limits( ) == 2 ) + + +class TestAnnualLimits: + def test_daily_usage_uses_muted_component( + self, + logged_in_client, + mocker, + mock_get_service_templates_when_no_templates_exist, + mock_get_jobs, + mock_get_service_statistics, + mock_get_usage, + app_, + ): + with set_config(app_, "FF_ANNUAL_LIMIT", True): # REMOVE LINE WHEN FF REMOVED + mocker.patch( + "app.template_statistics_client.get_template_statistics_for_service", + return_value=copy.deepcopy(stub_template_stats), + ) + + url = url_for("main.service_dashboard", service_id=SERVICE_ONE_ID) + response = logged_in_client.get(url) + page = BeautifulSoup(response.data.decode("utf-8"), "html.parser") + + # ensure both email + sms widgets are muted + assert len(page.select("[data-testid='daily-usage'] .remaining-messages.muted")) == 2 + + def test_annual_usage_uses_muted_component( + self, + logged_in_client, + mocker, + mock_get_service_templates_when_no_templates_exist, + mock_get_jobs, + mock_get_service_statistics, + mock_get_usage, + app_, + ): + with set_config(app_, "FF_ANNUAL_LIMIT", True): # REMOVE LINE WHEN FF REMOVED + mocker.patch( + "app.template_statistics_client.get_template_statistics_for_service", + return_value=copy.deepcopy(stub_template_stats), + ) + + url = url_for("main.service_dashboard", service_id=SERVICE_ONE_ID) + response = logged_in_client.get(url) + page = BeautifulSoup(response.data.decode("utf-8"), "html.parser") + + # ensure both email + sms widgets are muted + assert len(page.select("[data-testid='annual-usage'] .remaining-messages.muted")) == 2 + + @freeze_time("2024-11-25 12:12:12") + @pytest.mark.parametrize( + "redis_daily_data, monthly_data, expected_data", + [ + ( + {"sms_delivered": 100, "email_delivered": 50, "sms_failed": 1000, "email_failed": 500}, + { + "data": { + "2024-04": {"sms": {}, "email": {}, "letter": {}}, + "2024-05": {"sms": {}, "email": {}, "letter": {}}, + "2024-06": {"sms": {}, "email": {}, "letter": {}}, + "2024-07": {"sms": {}, "email": {}, "letter": {}}, + "2024-08": {"sms": {}, "email": {}, "letter": {}}, + "2024-09": {"sms": {}, "email": {}, "letter": {}}, + "2024-10": { + "sms": {"delivered": 5, "permanent-failure": 50, "sending": 5, "technical-failure": 100}, + "email": {"delivered": 10, "permanent-failure": 110, "sending": 50, "technical-failure": 50}, + "letter": {}, + }, + "2024-11": { + "sms": {"delivered": 5, "permanent-failure": 50, "sending": 5, "technical-failure": 100}, + "email": {"delivered": 10, "permanent-failure": 110, "sending": 50, "technical-failure": 50}, + "letter": {}, + }, + } + }, + {"email": 990, "letter": 0, "sms": 1420}, + ), + ( + {"sms_delivered": 6, "email_delivered": 6, "sms_failed": 6, "email_failed": 6}, + { + "data": { + "2024-10": { + "sms": {"delivered": 6, "permanent-failure": 6, "sending": 6, "technical-failure": 6}, + "email": {"delivered": 6, "permanent-failure": 6, "sending": 6, "technical-failure": 6}, + "letter": {}, + }, + } + }, + {"email": 36, "letter": 0, "sms": 36}, + ), + ], + ) + def test_usage_report_aggregates_calculated_properly_with_redis( + self, + logged_in_client, + mocker, + mock_get_service_templates_when_no_templates_exist, + mock_get_jobs, + mock_get_service_statistics, + mock_get_usage, + app_, + redis_daily_data, + monthly_data, + expected_data, + ): + with set_config(app_, "FF_ANNUAL_LIMIT", True): # REMOVE LINE WHEN FF REMOVED + # mock annual_limit_client.get_all_notification_counts + mocker.patch( + "app.main.views.dashboard.annual_limit_client.get_all_notification_counts", + return_value=redis_daily_data, + ) + + mocker.patch( + "app.service_api_client.get_monthly_notification_stats", + return_value=copy.deepcopy(monthly_data), + ) + + mock_render_template = mocker.patch("app.main.views.dashboard.render_template") + + url = url_for("main.monthly", service_id=SERVICE_ONE_ID) + logged_in_client.get(url) + + mock_render_template.assert_called_with( + ANY, months=ANY, years=ANY, annual_data=expected_data, selected_year=ANY, current_financial_year=ANY + ) + + @freeze_time("2024-11-25 12:12:12") + @pytest.mark.parametrize( + "daily_data, monthly_data, expected_data", + [ + ( + { + "sms": {"requested": 100, "delivered": 50, "failed": 50}, + "email": {"requested": 100, "delivered": 50, "failed": 50}, + "letter": {"requested": 0, "delivered": 0, "failed": 0}, + }, + { + "data": { + "2024-04": {"sms": {}, "email": {}, "letter": {}}, + "2024-05": {"sms": {}, "email": {}, "letter": {}}, + "2024-06": {"sms": {}, "email": {}, "letter": {}}, + "2024-07": {"sms": {}, "email": {}, "letter": {}}, + "2024-08": {"sms": {}, "email": {}, "letter": {}}, + "2024-09": {"sms": {}, "email": {}, "letter": {}}, + "2024-10": { + "sms": {"delivered": 5, "permanent-failure": 50, "sending": 5, "technical-failure": 100}, + "email": {"delivered": 10, "permanent-failure": 110, "sending": 50, "technical-failure": 50}, + "letter": {}, + }, + "2024-11": { + "sms": {"delivered": 5, "permanent-failure": 50, "sending": 5, "technical-failure": 100}, + "email": {"delivered": 10, "permanent-failure": 110, "sending": 50, "technical-failure": 50}, + "letter": {}, + }, + } + }, + {"email": 540, "letter": 0, "sms": 420}, + ) + ], + ) + def test_usage_report_aggregates_calculated_properly_without_redis( + self, + logged_in_client, + mocker, + mock_get_service_templates_when_no_templates_exist, + mock_get_jobs, + mock_get_service_statistics, + mock_get_usage, + app_, + daily_data, + monthly_data, + expected_data, + ): + with set_config(app_, "FF_ANNUAL_LIMIT", True): # REMOVE LINE WHEN FF REMOVED + # mock annual_limit_client.get_all_notification_counts + mocker.patch( + "app.main.views.dashboard.annual_limit_client.get_all_notification_counts", + return_value=None, + ) + + mocker.patch( + "app.service_api_client.get_service_statistics", + return_value=copy.deepcopy(daily_data), + ) + + mocker.patch( + "app.service_api_client.get_monthly_notification_stats", + return_value=copy.deepcopy(monthly_data), + ) + + mock_render_template = mocker.patch("app.main.views.dashboard.render_template") + + url = url_for("main.monthly", service_id=SERVICE_ONE_ID) + logged_in_client.get(url) + + mock_render_template.assert_called_with( + ANY, months=ANY, years=ANY, annual_data=expected_data, selected_year=ANY, current_financial_year=ANY + ) diff --git a/tests/app/main/views/test_service_settings.py b/tests/app/main/views/test_service_settings.py index 10a1af7727..2ce8210a5d 100644 --- a/tests/app/main/views/test_service_settings.py +++ b/tests/app/main/views/test_service_settings.py @@ -71,13 +71,13 @@ def mock_get_service_settings_page_common( "Email branding English Government of Canada signature Change", "Send files by email Off (API-only) Change", "Daily maximum 1,000 emails", - "Annual maximum(April 1 to March 31) 10,000,000 emails", + "Annual maximum(April 1 to March 31) 20,000,000 emails", "Label Value Action", "Send text messages On Change", "Start text messages with service name On Change", "Send international text messages Off Change", "Daily maximum 1,000 text messages", - "Annual maximum(April 1 to March 31) 25,000 text messages", + "Annual maximum(April 1 to March 31) 100,000 text messages", ], ), ( @@ -95,21 +95,21 @@ def mock_get_service_settings_page_common( "Email branding English Government of Canada signature Change", "Send files by email Off (API-only) Change", "Daily maximum 1,000 emails", - "Annual maximum(April 1 to March 31) 10,000,000 emails", + "Annual maximum(April 1 to March 31) 20,000,000 emails", "Label Value Action", "Send text messages On Change", "Start text messages with service name On Change", "Send international text messages Off Change", "Daily maximum 1,000 text messages", - "Annual maximum(April 1 to March 31) 25,000 text messages", + "Annual maximum(April 1 to March 31) 100,000 text messages", "Label Value Action", "Live On Change", "Count in list of live services Yes Change", "Organisation Test Organisation Government of Canada Change", "Daily email limit 1,000 Change", "Daily text message limit 1,000 Change", - "Annual email limit 10,000,000 Change", - "Annual text message limit 25,000 Change", + "Annual email limit 20,000,000 Change", + "Annual text message limit 100,000 Change", "API rate limit per minute 100", "Text message senders GOVUK Manage", "Receive text messages Off Change", @@ -233,13 +233,13 @@ def test_organisation_name_links_to_org_dashboard( "Email branding Your branding (Organisation name) Change", "Send files by email Off (API-only) Change", "Daily maximum 1,000 emails", - "Annual maximum(April 1 to March 31) 10,000,000 emails", + "Annual maximum(April 1 to March 31) 20,000,000 emails", "Label Value Action", "Send text messages On Change", "Start text messages with service name On Change", "Send international text messages On Change", "Daily maximum 1,000 text messages", - "Annual maximum(April 1 to March 31) 25,000 text messages", + "Annual maximum(April 1 to March 31) 100,000 text messages", ], ), ( @@ -255,13 +255,13 @@ def test_organisation_name_links_to_org_dashboard( "Email branding Your branding (Organisation name) Change", "Send files by email Off (API-only) Change", "Daily maximum 1,000 emails", - "Annual maximum(April 1 to March 31) 10,000,000 emails", + "Annual maximum(April 1 to March 31) 20,000,000 emails", "Label Value Action", "Send text messages On Change", "Start text messages with service name On Change", "Send international text messages Off Change", "Daily maximum 1,000 text messages", - "Annual maximum(April 1 to March 31) 25,000 text messages", + "Annual maximum(April 1 to March 31) 100,000 text messages", ], ), ], @@ -3235,7 +3235,7 @@ def test_unknown_channel_404s( ), ( "sms", - "You can send up to 25,000 text messages per fiscal year.", + "You can send up to 100,000 text messages per fiscal year.", "Send text messages", [], "False", @@ -3244,7 +3244,7 @@ def test_unknown_channel_404s( ), ( "email", - "You can send up to 10 million emails per fiscal year for free.", + "You can send up to 20 million emails per fiscal year for free.", "Send emails", [], "False", @@ -3253,7 +3253,7 @@ def test_unknown_channel_404s( ), ( "email", - "You can send up to 10 million emails per fiscal year for free.", + "You can send up to 20 million emails per fiscal year for free.", "Send emails", ["email", "sms", "letter"], "True", diff --git a/tests/conftest.py b/tests/conftest.py index fedd0f170b..94ff0ca5be 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -578,9 +578,42 @@ def _get(service_id, today_only, limit_days=None): "letter": {"requested": 0, "delivered": 0, "failed": 0}, } + # mock these stats at the same time + def _get_monthly_stats(service_id, year): + return { + "data": { + "2024-04": {"sms": {}, "email": {}, "letter": {}}, + "2024-05": {"sms": {}, "email": {}, "letter": {}}, + "2024-06": {"sms": {}, "email": {}, "letter": {}}, + "2024-07": {"sms": {}, "email": {}, "letter": {}}, + "2024-08": {"sms": {}, "email": {}, "letter": {}}, + "2024-09": {"sms": {}, "email": {}, "letter": {}}, + "2024-10": {"sms": {}, "email": {}, "letter": {}}, + "2024-11": { + "sms": {"sent": 1}, + "email": {"delivered": 1, "permanent-failure": 1, "sending": 3, "technical-failure": 1}, + "letter": {}, + }, + } + } + + mocker.patch("app.service_api_client.get_monthly_notification_stats", side_effect=_get_monthly_stats) + return mocker.patch("app.service_api_client.get_service_statistics", side_effect=_get) +@pytest.fixture(scope="function") +def mock_get_annual_statistics(mocker, api_user_active): + def _get(service_id, year): + return { + "email": 100, + "sms": 200, + "letter": 300, + } + + return mocker.patch("app.service_api_client.get_monthly_notification_stats", side_effect=_get) + + @pytest.fixture(scope="function") def mock_get_detailed_services(mocker, fake_uuid): service_one = service_json( @@ -3636,12 +3669,12 @@ def mock_update_message_limit(mocker): @pytest.fixture(scope="function") def mock_update_email_annual_limit(mocker): - return mocker.patch("app.service_api_client.update_email_annual_limit", return_value=10000000) + return mocker.patch("app.service_api_client.update_email_annual_limit", return_value=20000000) @pytest.fixture(scope="function") def mock_update_sms_annual_limit(mocker): - return mocker.patch("app.service_api_client.update_sms_annual_limit", return_value=25000) + return mocker.patch("app.service_api_client.update_sms_annual_limit", return_value=100000) @pytest.fixture(scope="function")