From 4f8515802c525bc15338b7069be2ce3e47624d0a Mon Sep 17 00:00:00 2001 From: William B <7444334+whabanks@users.noreply.github.com> Date: Thu, 31 Oct 2024 11:54:29 -0300 Subject: [PATCH] Add service setting to change annual limits for platform admins (#1976) * Add service setting to change annual limits for platform admins * Add FF to productionFF config * Fix formatting issue * More formatting undos * Fix productionFF config * Update poe format task to use ruff * Fix tests * Fix tests - Added FF checks when displaying the sms and email limits to non-admin users * Make test work with either feature flag * Fix test * Fix formatting mess and a few tests --- app/config.py | 65 +++++++---- app/main/forms.py | 20 ++++ app/main/views/service_settings.py | 71 +++++++++--- app/models/service.py | 2 + app/navigation.py | 6 + app/notify_client/service_api_client.py | 17 +++ app/templates/views/service-settings.html | 41 ++++++- app/translations/csv/fr.csv | 5 +- pyproject.toml | 7 +- tests/__init__.py | 4 + tests/app/main/views/test_service_settings.py | 109 ++++++++++++++---- tests/conftest.py | 10 ++ 12 files changed, 287 insertions(+), 70 deletions(-) diff --git a/app/config.py b/app/config.py index 78867b3ba3..d15764edb0 100644 --- a/app/config.py +++ b/app/config.py @@ -25,7 +25,8 @@ class Config(object): ALLOW_DEBUG_ROUTE = env.bool("ALLOW_DEBUG_ROUTE", False) # List of allowed service IDs that are allowed to send HTML through their templates. - ALLOW_HTML_SERVICE_IDS: List[str] = [id.strip() for id in os.getenv("ALLOW_HTML_SERVICE_IDS", "").split(",")] + ALLOW_HTML_SERVICE_IDS: List[str] = [ + id.strip() for id in os.getenv("ALLOW_HTML_SERVICE_IDS", "").split(",")] ADMIN_BASE_URL = ( "https://" + os.environ.get("HEROKU_APP_NAME", "") + ".herokuapp.com" if os.environ.get("HEROKU_APP_NAME", "") != "" @@ -47,12 +48,15 @@ class Config(object): BULK_SEND_AWS_BUCKET = os.getenv("BULK_SEND_AWS_BUCKET") CHECK_PROXY_HEADER = False - CONTACT_EMAIL = os.environ.get("CONTACT_EMAIL", "assistance+notification@cds-snc.ca") - CRM_GITHUB_PERSONAL_ACCESS_TOKEN = os.getenv("CRM_GITHUB_PERSONAL_ACCESS_TOKEN") + CONTACT_EMAIL = os.environ.get( + "CONTACT_EMAIL", "assistance+notification@cds-snc.ca") + CRM_GITHUB_PERSONAL_ACCESS_TOKEN = os.getenv( + "CRM_GITHUB_PERSONAL_ACCESS_TOKEN") CRM_ORG_LIST_URL = os.getenv("CRM_ORG_LIST_URL") CSV_MAX_ROWS = env.int("CSV_MAX_ROWS", 50_000) CSV_MAX_ROWS_BULK_SEND = env.int("CSV_MAX_ROWS_BULK_SEND", 100_000) - CSV_UPLOAD_BUCKET_NAME = os.getenv("CSV_UPLOAD_BUCKET_NAME", "notification-alpha-canada-ca-csv-upload") + CSV_UPLOAD_BUCKET_NAME = os.getenv( + "CSV_UPLOAD_BUCKET_NAME", "notification-alpha-canada-ca-csv-upload") DANGEROUS_SALT = os.environ.get("DANGEROUS_SALT") DEBUG = False DEBUG_KEY = os.environ.get("DEBUG_KEY", "") @@ -67,25 +71,32 @@ class Config(object): "other": 25_000, } DEFAULT_LIVE_SERVICE_LIMIT = env.int("DEFAULT_LIVE_SERVICE_LIMIT", 10_000) - DEFAULT_LIVE_SMS_DAILY_LIMIT = env.int("DEFAULT_LIVE_SMS_DAILY_LIMIT", 1000) + DEFAULT_LIVE_SMS_DAILY_LIMIT = env.int( + "DEFAULT_LIVE_SMS_DAILY_LIMIT", 1000) DEFAULT_SERVICE_LIMIT = env.int("DEFAULT_SERVICE_LIMIT", 50) DEFAULT_SMS_DAILY_LIMIT = env.int("DEFAULT_SMS_DAILY_LIMIT", 50) - DOCUMENTATION_DOMAIN = os.getenv("DOCUMENTATION_DOMAIN", "documentation.notification.canada.ca") + DOCUMENTATION_DOMAIN = os.getenv( + "DOCUMENTATION_DOMAIN", "documentation.notification.canada.ca") EMAIL_2FA_EXPIRY_SECONDS = 1_800 # 30 Minutes EMAIL_EXPIRY_SECONDS = 3600 # 1 hour # for waffles: pull out the routes into a flat list of the form ['/home', '/accueil', '/why-gc-notify', ...] - EXTRA_ROUTES = [item for sublist in map(lambda x: x.values(), GC_ARTICLES_ROUTES.values()) for item in sublist] + EXTRA_ROUTES = [item for sublist in map( + lambda x: x.values(), GC_ARTICLES_ROUTES.values()) for item in sublist] # 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) 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) - 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") + 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") GOOGLE_ANALYTICS_ID = os.getenv("GOOGLE_ANALYTICS_ID", "UA-102484926-14") GOOGLE_TAG_MANAGER_ID = os.getenv("GOOGLE_TAG_MANAGER_ID", "GTM-KRKRZQV") HC_EN_SERVICE_ID = os.getenv("HC_EN_SERVICE_ID") @@ -93,9 +104,11 @@ class Config(object): HIPB_ENABLED = True HTTP_PROTOCOL = "http" INVITATION_EXPIRY_SECONDS = 3_600 * 24 * 2 # 2 days - also set on api - IP_GEOLOCATE_SERVICE = os.environ.get("IP_GEOLOCATE_SERVICE", "").rstrip("/") + IP_GEOLOCATE_SERVICE = os.environ.get( + "IP_GEOLOCATE_SERVICE", "").rstrip("/") LANGUAGES = ["en", "fr"] - LOGO_UPLOAD_BUCKET_NAME = os.getenv("ASSET_UPLOAD_BUCKET_NAME", "notification-alpha-canada-ca-asset-upload") + LOGO_UPLOAD_BUCKET_NAME = os.getenv( + "ASSET_UPLOAD_BUCKET_NAME", "notification-alpha-canada-ca-asset-upload") MAX_FAILED_LOGIN_COUNT = 10 MOU_BUCKET_NAME = os.getenv("MOU_BUCKET_NAME", "") @@ -118,8 +131,10 @@ class Config(object): SCANFILES_URL = os.environ.get("SCANFILES_URL", "") SECRET_KEY = env.list("SECRET_KEY", []) - SECURITY_EMAIL = os.environ.get("SECURITY_EMAIL", "security+securite@cds-snc.ca") - SENDING_DOMAIN = os.environ.get("SENDING_DOMAIN", "notification.alpha.canada.ca") + SECURITY_EMAIL = os.environ.get( + "SECURITY_EMAIL", "security+securite@cds-snc.ca") + SENDING_DOMAIN = os.environ.get( + "SENDING_DOMAIN", "notification.alpha.canada.ca") SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_NAME = "notify_admin_session" SESSION_COOKIE_SAMESITE = "Lax" @@ -133,8 +148,10 @@ class Config(object): STATSD_PORT = 8_125 STATSD_PREFIX = os.getenv("STATSD_PREFIX") - TEMPLATE_PREVIEW_API_HOST = os.environ.get("TEMPLATE_PREVIEW_API_HOST", "http://localhost:6013") - TEMPLATE_PREVIEW_API_KEY = os.environ.get("TEMPLATE_PREVIEW_API_KEY", "my-secret-key") + TEMPLATE_PREVIEW_API_HOST = os.environ.get( + "TEMPLATE_PREVIEW_API_HOST", "http://localhost:6013") + TEMPLATE_PREVIEW_API_KEY = os.environ.get( + "TEMPLATE_PREVIEW_API_KEY", "my-secret-key") WAF_SECRET = os.environ.get("WAF_SECRET", "waf-secret") WTF_CSRF_ENABLED = True WTF_CSRF_TIME_LIMIT = None @@ -145,7 +162,8 @@ class Config(object): NOTIFY_USER_ID = "6af522d0-2915-4e52-83a3-3690455a5fe6" NOTIFY_SERVICE_ID = "d6aa2c68-a2d9-4437-ab19-3ae8eb202553" - NO_BRANDING_ID = os.environ.get("NO_BRANDING_ID", "0af93cf1-2c49-485f-878f-f3e662e651ef") + NO_BRANDING_ID = os.environ.get( + "NO_BRANDING_ID", "0af93cf1-2c49-485f-878f-f3e662e651ef") @classmethod def get_sensitive_config(cls) -> list[str]: @@ -173,7 +191,8 @@ def get_safe_config(cls) -> dict[str, Any]: class Development(Config): - ADMIN_CLIENT_SECRET = os.environ.get("ADMIN_CLIENT_SECRET", "dev-notify-secret-key") + ADMIN_CLIENT_SECRET = os.environ.get( + "ADMIN_CLIENT_SECRET", "dev-notify-secret-key") ANTIVIRUS_API_HOST = "http://localhost:6016" ANTIVIRUS_API_KEY = "test-key" API_HOST_NAME = os.environ.get("API_HOST_NAME", "http://localhost:6011") @@ -187,10 +206,12 @@ class Development(Config): SESSION_PROTECTION = None SYSTEM_STATUS_URL = "https://localhost:3000" NO_BRANDING_ID = "0af93cf1-2c49-485f-878f-f3e662e651ef" + FF_ANNUAL_LIMIT = env.bool("FF_ANNUAL_LIMIT", True) class Test(Development): - ADMIN_CLIENT_SECRET = os.environ.get("ADMIN_CLIENT_SECRET", "dev-notify-secret-key") + ADMIN_CLIENT_SECRET = os.environ.get( + "ADMIN_CLIENT_SECRET", "dev-notify-secret-key") ANTIVIRUS_API_HOST = "https://test-antivirus" ANTIVIRUS_API_KEY = "test-antivirus-secret" API_HOST_NAME = os.environ.get("API_HOST_NAME", "http://localhost:6011") @@ -213,10 +234,12 @@ class Test(Development): NO_BRANDING_ID = "0af93cf1-2c49-485f-878f-f3e662e651ef" FF_RTL = True + FF_ANNUAL_LIMIT = False class ProductionFF(Config): - ADMIN_CLIENT_SECRET = os.environ.get("ADMIN_CLIENT_SECRET", "dev-notify-secret-key") + ADMIN_CLIENT_SECRET = os.environ.get( + "ADMIN_CLIENT_SECRET", "dev-notify-secret-key") ANTIVIRUS_API_HOST = "https://test-antivirus" ANTIVIRUS_API_KEY = "test-antivirus-secret" API_HOST_NAME = os.environ.get("API_HOST_NAME", "http://localhost:6011") @@ -239,6 +262,7 @@ class ProductionFF(Config): NO_BRANDING_ID = "0af93cf1-2c49-485f-878f-f3e662e651ef" FF_RTL = False + FF_ANNUAL_LIMIT = False class Production(Config): @@ -255,6 +279,7 @@ class Staging(Production): NOTIFY_LOG_LEVEL = "INFO" SYSTEM_STATUS_URL = "https://status.staging.notification.cdssandbox.xyz" NO_BRANDING_ID = "0af93cf1-2c49-485f-878f-f3e662e651ef" + FF_ANNUAL_LIMIT = True class Scratch(Production): diff --git a/app/main/forms.py b/app/main/forms.py index d6ee981bcc..b92c09eec8 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -749,6 +749,26 @@ class SMSMessageLimit(StripWhitespaceForm): ) +class SMSAnnualMessageLimit(StripWhitespaceForm): + message_limit = IntegerField( + _l("Annual text message limit"), + validators=[ + DataRequired(message=_l("This cannot be empty")), + validators.NumberRange(min=1), + ], + ) + + +class EmailAnnualMessageLimit(StripWhitespaceForm): + message_limit = IntegerField( + _l("Annual email message limit"), + validators=[ + DataRequired(message=_l("This cannot be empty")), + validators.NumberRange(min=1), + ], + ) + + class FreeSMSAllowance(StripWhitespaceForm): free_sms_allowance = IntegerField( _l("Numbers of text messages per fiscal year"), diff --git a/app/main/views/service_settings.py b/app/main/views/service_settings.py index 5c0767a19d..902a089f3e 100644 --- a/app/main/views/service_settings.py +++ b/app/main/views/service_settings.py @@ -16,6 +16,7 @@ from flask_babel import lazy_gettext as _l from flask_login import current_user from notifications_python_client.errors import HTTPError +from notifications_utils.decorators import requires_feature from app import ( billing_api_client, @@ -32,6 +33,7 @@ from app.main.forms import ( ChangeEmailFromServiceForm, ConfirmPasswordForm, + EmailAnnualMessageLimit, FieldWithLanguageOptions, FreeSMSAllowance, GoLiveAboutNotificationsForm, @@ -57,6 +59,7 @@ ServiceSwitchChannelForm, SetEmailBranding, SetLetterBranding, + SMSAnnualMessageLimit, SMSMessageLimit, SMSPrefixForm, ) @@ -107,7 +110,8 @@ def service_settings(service_id: str): return render_template( "views/service-settings.html", service_permissions=PLATFORM_ADMIN_SERVICE_PERMISSIONS, - sending_domain=current_service.sending_domain or current_app.config["SENDING_DOMAIN"], # type: ignore + # type: ignore + sending_domain=current_service.sending_domain or current_app.config["SENDING_DOMAIN"], limits=limits, callback_api=callback_api, ) @@ -733,7 +737,8 @@ def service_edit_email_reply_to(service_id, reply_to_email_id): new_default_reply_to_address = get_new_default_reply_to_address( current_service.email_reply_to_addresses, reply_to_email_address ) - email_address = new_default_reply_to_address["email_address"] # type: ignore + # type: ignore + email_address = new_default_reply_to_address["email_address"] message: str = _("You're about to delete your default reply-to address.") + _( " The new default will be the next email on your list of reply-to addresses: ‘{}’" ).format(email_address) @@ -978,10 +983,7 @@ def service_make_blank_default_letter_contact(service_id): return redirect(url_for(".service_letter_contact_details", service_id=service_id)) -@main.route( - "/services//service-settings/letter-contact//delete", - methods=["POST"], -) +@main.route("/services//service-settings/letter-contact//delete", methods=["POST"]) @user_has_permissions("manage_service") def service_delete_letter_contact(service_id, letter_contact_id): service_api_client.delete_letter_contact( @@ -1041,7 +1043,7 @@ def service_edit_sms_sender(service_id, sms_sender_id): service_api_client.update_sms_sender( current_service.id, sms_sender_id=sms_sender_id, - sms_sender=sms_sender["sms_sender"] if is_inbound_number else form.sms_sender.data.replace("\r", ""), + sms_sender=(sms_sender["sms_sender"] if is_inbound_number else form.sms_sender.data.replace("\r", "")), is_default=True if sms_sender["is_default"] else form.is_default.data, ) return redirect(url_for(".service_sms_senders", service_id=service_id)) @@ -1106,11 +1108,7 @@ def set_message_limit(service_id): flash(_("An email has been sent to service users"), "default_with_tick") return redirect(url_for(".service_settings", service_id=service_id)) - return render_template( - "views/service-settings/set-message-limit.html", - form=form, - heading=_("Daily email limit"), - ) + return render_template("views/service-settings/set-message-limit.html", form=form, heading=_("Daily email limit")) @main.route("/services//service-settings/set-sms-message-limit", methods=["GET", "POST"]) @@ -1124,13 +1122,52 @@ def set_sms_message_limit(service_id): flash(_("An email has been sent to service users"), "default_with_tick") return redirect(url_for(".service_settings", service_id=service_id)) - return render_template("views/service-settings/set-message-limit.html", form=form, heading=_("Daily text message limit")) + return render_template( + "views/service-settings/set-message-limit.html", + form=form, + heading=_("Daily text message limit"), + ) -@main.route( - "/services//service-settings/set-free-sms-allowance", - methods=["GET", "POST"], -) +@main.route("/service//service_settings/set-sms-annual-limit", methods=["GET", "POST"]) +@user_is_platform_admin +@requires_feature("FF_ANNUAL_LIMIT") # TODO: FF_ANNUAL_LIMIT removal +def set_sms_annual_limit(service_id): + form = SMSAnnualMessageLimit(message_limit=current_service.sms_annual_limit) + + if form.validate_on_submit(): + service_api_client.update_sms_annual_limit(service_id, form.message_limit.data) + if current_service.live: + flash(_("An email has been sent to service users"), "default_with_tick") + return redirect(url_for(".service_settings", service_id=service_id)) + + return render_template( + "views/service-settings/set-message-limit.html", + form=form, + heading=_("Annual text message limit"), + ) + + +@main.route("/service//service_settings/set-email-annual.html", methods=["GET", "POST"]) +@user_is_platform_admin +@requires_feature("FF_ANNUAL_LIMIT") # TODO: FF_ANNUAL_LIMIT removal +def set_email_annual_limit(service_id): + form = EmailAnnualMessageLimit(message_limit=current_service.email_annual_limit) + + if form.validate_on_submit(): + service_api_client.update_email_annual_limit(service_id, form.message_limit.data) + if current_service.live: + flash(_("An email has been sent to service users"), "default_with_tick") + return redirect(url_for(".service_settings", service_id=service_id)) + + return render_template( + "views/service-settings/set-message-limit.html", + form=form, + heading=_("Annual email message limit"), + ) + + +@main.route("/services//service-settings/set-free-sms-allowance", methods=["GET", "POST"]) @user_is_platform_admin def set_free_sms_allowance(service_id): form = FreeSMSAllowance(free_sms_allowance=current_service.free_sms_fragment_limit) diff --git a/app/models/service.py b/app/models/service.py index 46c466dcc8..9edf352f83 100644 --- a/app/models/service.py +++ b/app/models/service.py @@ -49,6 +49,8 @@ class Service(JSONModel): "sending_domain", "organisation_notes", "sensitive_service", + "email_annual_limit", + "sms_annual_limit", } TEMPLATE_TYPES = ( diff --git a/app/navigation.py b/app/navigation.py index 95d2e5fb5f..0a0e6ca81f 100644 --- a/app/navigation.py +++ b/app/navigation.py @@ -227,6 +227,8 @@ class HeaderNavigation(Navigation): "service_sms_senders", "set_message_limit", "set_free_sms_allowance", + "set_annual_sms_limit", + "set_email_annual_limit", "service_set_letter_branding", "submit_request_to_go_live", }, @@ -464,6 +466,8 @@ class MainNavigation(Navigation): "service_sms_senders", "set_message_limit", "set_free_sms_allowance", + "set_annual_sms_limit", + "set_email_annual_limit", "service_set_letter_branding", "submit_request_to_go_live", }, @@ -744,6 +748,8 @@ class OrgNavigation(Navigation): "services_or_dashboard", "set_message_limit", "set_free_sms_allowance", + "set_annual_sms_limit", + "set_email_annual_limit", "service_set_letter_branding", "set_lang", "set_sender", diff --git a/app/notify_client/service_api_client.py b/app/notify_client/service_api_client.py index dd49362de9..f1b4f3fe69 100644 --- a/app/notify_client/service_api_client.py +++ b/app/notify_client/service_api_client.py @@ -3,6 +3,7 @@ from flask import current_app from flask_login import current_user +from notifications_utils.decorators import requires_feature from app.extensions import redis_client from app.notify_client import NotifyAdminAPIClient, _attach_current_user, cache @@ -118,6 +119,8 @@ def update_service(self, service_id, **kwargs): "sending_domain", "sms_volume_today", "sensitive_service", + "email_annual_limit", + "sms_annual_limit", } if disallowed_attributes: @@ -159,6 +162,20 @@ def update_sms_message_limit(self, service_id, sms_daily_limit): sms_daily_limit=sms_daily_limit, ) + @requires_feature("FF_ANNUAL_LIMIT") # TODO: FF_ANNUAL_LIMIT removal + def update_sms_annual_limit(self, service_id, sms_annual_limit): + return self.update_service( + service_id, + sms_annual_limit=sms_annual_limit, + ) + + @requires_feature("FF_ANNUAL_LIMIT") # TODO: FF_ANNUAL_LIMIT removal + def update_email_annual_limit(self, service_id, email_annual_limit): + return self.update_service( + service_id, + email_annual_limit=email_annual_limit, + ) + # This method is not cached because it calls through to one which is def update_service_with_properties(self, service_id, properties): return self.update_service(service_id, **properties) diff --git a/app/templates/views/service-settings.html b/app/templates/views/service-settings.html index 1067092389..ab93ef734f 100644 --- a/app/templates/views/service-settings.html +++ b/app/templates/views/service-settings.html @@ -192,7 +192,12 @@

{{ _('Your service is in trial mode') }}

{% call settings_row(if_has_permission='email') %} {% set txt = _('Annual maximum
(April 1 to March 31)') %} {{ text_field(txt) }} - {% set annual_limit = _('{} million emails').format((limits.free_yearly_email//1000000) | format_number) %} + {# TODO: FF_ANNUAL_LIMIT removal #} + {% if config["FF_ANNUAL_LIMIT"] %} + {% set annual_limit = _('{} million emails').format((current_service.email_annual_limit//1000000) | format_number) %} + {% else %} + {% set annual_limit = _('{} million emails').format((limits.free_yearly_email//1000000) | format_number) %} + {% endif%} {{ text_field(annual_limit) }} {{ text_field('')}} {% endcall %} @@ -266,8 +271,13 @@

{{ _('Your service is in trial mode') }}

{% call settings_row(if_has_permission='sms') %} {% set txt = _('Annual maximum
(April 1 to March 31)') %} {{ text_field(txt) }} - {% set txt_msg_limit = _('{} text messages').format(limits.free_yearly_sms | format_number) %} - {{ text_field(txt_msg_limit) }} + {# TODO: FF_ANNUAL_LIMIT removal #} + {% if config["FF_ANNUAL_LIMIT"] %} + {% set annual_sms_limit = _('{} text messages').format((current_service.sms_annual_limit) | format_number) %} + {% else %} + {% set annual_sms_limit = _('{} text messages').format((limits.free_yearly_sms) | format_number) %} + {% endif%} + {{ text_field(annual_sms_limit) }} {{ text_field('')}} {% endcall %} @@ -373,6 +383,31 @@

{{ _('Platform admin settings') }}

) }} {% endcall %} + {# TODO: FF_ANNUAL_LIMIT removal #} + {% if config["FF_ANNUAL_LIMIT"] %} + {% call row() %} + {% set txt = _('Annual email limit') %} + {{ text_field(txt)}} + {{ text_field(current_service.email_annual_limit | format_number) }} + {{ edit_field( + change_txt, + url_for('.set_email_annual_limit', service_id=current_service.id), + for=txt + ) }} + {% endcall %} + + {% call row() %} + {% set txt = _('Annual text message limit') %} + {{ text_field(txt)}} + {{ text_field(current_service.sms_annual_limit | format_number) }} + {{ edit_field( + change_txt, + url_for('.set_sms_annual_limit', service_id=current_service.id), + for=txt + ) }} + {% endcall %} + {% endif %} + {% call row() %} {% set txt = _('API rate limit per minute') %} {{ text_field(txt)}} diff --git a/app/translations/csv/fr.csv b/app/translations/csv/fr.csv index 19ff54c172..033df1fa59 100644 --- a/app/translations/csv/fr.csv +++ b/app/translations/csv/fr.csv @@ -1990,4 +1990,7 @@ "Display","Afficher" "email content","le message du courriel" "Email content","Message du courriel" -"right to left","de droite à gauche" \ No newline at end of file +"right to left","de droite à gauche" +"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" diff --git a/pyproject.toml b/pyproject.toml index e4fa18b8a3..b688f6287a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -214,10 +214,9 @@ args = { port = { options = ["--port", "-p"], default = "6012"}, host = { option [tool.poe.tasks.format] help = "Formats the python code with isort, black, and flake8, and checks typing with mypy and formats the JS code with prettier." shell = """ - isort ./app ./tests - black ./app ./tests - flake8 ./app ./tests - isort --check-only ./app ./tests + ruff check --select I --fix . + ruff check + ruff format . mypy ./ npx prettier --write app/assets/javascripts app/assets/stylesheets tests_cypress/cypress/e2e """ diff --git a/tests/__init__.py b/tests/__init__.py index 2eee16ef19..7e6bef33e6 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -176,6 +176,8 @@ def service_json( message_limit=1000, sms_daily_limit=1000, rate_limit=100, + email_annual_limit=10000000, + sms_annual_limit=25000, active=True, restricted=True, email_from="test.service", @@ -212,6 +214,8 @@ def service_json( "message_limit": message_limit, "sms_daily_limit": sms_daily_limit, "rate_limit": rate_limit, + "email_annual_limit": email_annual_limit, + "sms_annual_limit": sms_annual_limit, "active": active, "restricted": restricted, "email_from": email_from, diff --git a/tests/app/main/views/test_service_settings.py b/tests/app/main/views/test_service_settings.py index 49c94a005e..eba8545eb8 100644 --- a/tests/app/main/views/test_service_settings.py +++ b/tests/app/main/views/test_service_settings.py @@ -108,6 +108,8 @@ def mock_get_service_settings_page_common( "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", "API rate limit per minute 100", "Text message senders GOVUK Manage", "Receive text messages Off Change", @@ -135,31 +137,33 @@ def test_should_show_overview_inc_sms_daily_limit( mock_get_service_settings_page_common, app_, ): - service_one = service_json( - SERVICE_ONE_ID, - users=[api_user_active["id"]], - permissions=["sms", "email"], - organisation_id=ORGANISATION_ID, - restricted=False, - sending_domain=sending_domain, - ) - mocker.patch("app.service_api_client.get_service", return_value={"data": service_one}) - - client.login(user, mocker, service_one) - response = client.get(url_for("main.service_settings", service_id=SERVICE_ONE_ID)) - assert response.status_code == 200 - page = BeautifulSoup(response.data.decode("utf-8"), "html.parser") - assert page.find("h1").text == "Settings" - rows = page.select("tr") - for index, row in enumerate(expected_rows): - formatted_row = row.format(sending_domain=sending_domain or app_.config["SENDING_DOMAIN"]) - visible = rows[index] - sr_only = visible.find("span", "sr-only") - if sr_only: - sr_only.extract() - assert " ".join(visible.text.split()).startswith(" ".join(sr_only.text.split())) - assert formatted_row == " ".join(rows[index].text.split()) - app.service_api_client.get_service.assert_called_with(SERVICE_ONE_ID) + # TODO FF_ANNUAL_LIMIT removal + with set_config(app_, "FF_ANNUAL_LIMIT", True): + service_one = service_json( + SERVICE_ONE_ID, + users=[api_user_active["id"]], + permissions=["sms", "email"], + organisation_id=ORGANISATION_ID, + restricted=False, + sending_domain=sending_domain, + ) + mocker.patch("app.service_api_client.get_service", return_value={"data": service_one}) + + client.login(user, mocker, service_one) + response = client.get(url_for("main.service_settings", service_id=SERVICE_ONE_ID)) + assert response.status_code == 200 + page = BeautifulSoup(response.data.decode("utf-8"), "html.parser") + assert page.find("h1").text == "Settings" + rows = page.select("tr") + for index, row in enumerate(expected_rows): + formatted_row = row.format(sending_domain=sending_domain or app_.config["SENDING_DOMAIN"]) + visible = rows[index] + sr_only = visible.find("span", "sr-only") + if sr_only: + sr_only.extract() + assert " ".join(visible.text.split()).startswith(" ".join(sr_only.text.split())) + assert formatted_row == " ".join(rows[index].text.split()) + app.service_api_client.get_service.assert_called_with(SERVICE_ONE_ID) def test_no_go_live_link_for_service_without_organisation( @@ -3077,6 +3081,61 @@ def test_should_set_sms_message_limit( mock_update_sms_message_limit.assert_called_with(SERVICE_ONE_ID, expected_limit) +@pytest.mark.parametrize( + "limit, expected_limit", + [ + ("1", 1), + ("1000", 1_000), + pytest.param("10_001", 10_000, marks=pytest.mark.xfail), + pytest.param("foo", "foo", marks=pytest.mark.xfail), + ], +) +def test_should_set_email_annual_limit(platform_admin_client, limit, expected_limit, mock_update_email_annual_limit, app_): + with set_config(app_, "FF_ANNUAL_LIMIT", True): + response = platform_admin_client.post( + url_for("main.set_email_annual_limit",service_id=SERVICE_ONE_ID), + data={"message_limit": limit}, + ) + + assert response.status_code == 302 + assert response.location == url_for("main.service_settings", service_id=SERVICE_ONE_ID) + + mock_update_email_annual_limit.assert_called_with(SERVICE_ONE_ID, expected_limit) + + +@pytest.mark.parametrize( + "limit, expected_limit", + [ + ("1", 1), + ("1000", 1_000), + pytest.param("10_001", 10_000, marks=pytest.mark.xfail), + pytest.param("foo", "foo", marks=pytest.mark.xfail), + ], +) +def test_should_set_sms_annual_limit( + platform_admin_client, + limit, + expected_limit, + mock_update_sms_annual_limit, + app_, +): + with set_config(app_, "FF_ANNUAL_LIMIT", True): + response = platform_admin_client.post( + url_for( + "main.set_sms_annual_limit", + service_id=SERVICE_ONE_ID, + ), + data={ + "message_limit": limit, + }, + ) + + assert response.status_code == 302 + assert response.location == url_for("main.service_settings", service_id=SERVICE_ONE_ID) + + mock_update_sms_annual_limit.assert_called_with(SERVICE_ONE_ID, expected_limit) + + def test_should_show_page_to_set_sms_allowance(platform_admin_client, mock_get_free_sms_fragment_limit): response = platform_admin_client.get(url_for("main.set_free_sms_allowance", service_id=SERVICE_ONE_ID)) assert response.status_code == 200 diff --git a/tests/conftest.py b/tests/conftest.py index 062729c462..fedd0f170b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3634,6 +3634,16 @@ def mock_update_message_limit(mocker): return mocker.patch("app.service_api_client.update_message_limit", return_value=sample_limit) +@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) + + +@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) + + @pytest.fixture(scope="function") def mock_update_sms_message_limit(mocker): sample_limit = 10000