Skip to content

Commit

Permalink
Add service setting to change annual limits for platform admins (#1976)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
whabanks authored Oct 31, 2024
1 parent 2cdb4d4 commit 4f85158
Show file tree
Hide file tree
Showing 12 changed files with 287 additions and 70 deletions.
65 changes: 45 additions & 20 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "") != ""
Expand All @@ -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", "[email protected]")
CRM_GITHUB_PERSONAL_ACCESS_TOKEN = os.getenv("CRM_GITHUB_PERSONAL_ACCESS_TOKEN")
CONTACT_EMAIL = os.environ.get(
"CONTACT_EMAIL", "[email protected]")
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", "")
Expand All @@ -67,35 +71,44 @@ 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")
HC_FR_SERVICE_ID = os.getenv("HC_FR_SERVICE_ID")
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", "")

Expand All @@ -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", "[email protected]")
SENDING_DOMAIN = os.environ.get("SENDING_DOMAIN", "notification.alpha.canada.ca")
SECURITY_EMAIL = os.environ.get(
"SECURITY_EMAIL", "[email protected]")
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"
Expand All @@ -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
Expand All @@ -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]:
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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):
Expand All @@ -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):
Expand Down
20 changes: 20 additions & 0 deletions app/main/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
71 changes: 54 additions & 17 deletions app/main/views/service_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,6 +33,7 @@
from app.main.forms import (
ChangeEmailFromServiceForm,
ConfirmPasswordForm,
EmailAnnualMessageLimit,
FieldWithLanguageOptions,
FreeSMSAllowance,
GoLiveAboutNotificationsForm,
Expand All @@ -57,6 +59,7 @@
ServiceSwitchChannelForm,
SetEmailBranding,
SetLetterBranding,
SMSAnnualMessageLimit,
SMSMessageLimit,
SMSPrefixForm,
)
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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_id>/service-settings/letter-contact/<letter_contact_id>/delete",
methods=["POST"],
)
@main.route("/services/<service_id>/service-settings/letter-contact/<letter_contact_id>/delete", methods=["POST"])
@user_has_permissions("manage_service")
def service_delete_letter_contact(service_id, letter_contact_id):
service_api_client.delete_letter_contact(
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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_id>/service-settings/set-sms-message-limit", methods=["GET", "POST"])
Expand All @@ -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_id>/service-settings/set-free-sms-allowance",
methods=["GET", "POST"],
)
@main.route("/service/<service_id>/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_id>/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_id>/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)
Expand Down
2 changes: 2 additions & 0 deletions app/models/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ class Service(JSONModel):
"sending_domain",
"organisation_notes",
"sensitive_service",
"email_annual_limit",
"sms_annual_limit",
}

TEMPLATE_TYPES = (
Expand Down
6 changes: 6 additions & 0 deletions app/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down Expand Up @@ -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",
},
Expand Down Expand Up @@ -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",
Expand Down
Loading

0 comments on commit 4f85158

Please sign in to comment.