From c878f83a17283b5234f4e19b54d6cff67234700e Mon Sep 17 00:00:00 2001 From: William B <7444334+whabanks@users.noreply.github.com> Date: Tue, 9 Jul 2024 13:07:10 -0400 Subject: [PATCH] Initial TemplateCategory client (#1884) * Initial TemplateCategory client * Hook the api client up to the admin front end - Implemented add and update a category - Tweaks to the api client - Added tests for the API client - Renamed desc_en/desc_fr to description_en/fr for consistency * Address PR comments - Fixed desc_en/desc_fr -> description_en/description_fr typos - Delete the template_categories key when updating a category - Updated tests to check we are properly deleting Redis cache keys when updating a template * Delete template_categories cache when creating a new cat * Map old process_types to new ones for the UI - Fix tests * Update desc_col naming * Fix letter tests * Fix a number of failing tests - Added mock to get all template categories - Added template_category_json() - Updated template_json() with a template_category param - Sorted config keys under config.py * Fix a good chunk of tests * chore: fix failing tests * chore: remove duplicate param; remove unused imports * chore: fix import --------- Co-authored-by: Andrew Leith --- app/config.py | 86 +++++----- app/main/forms.py | 20 +-- app/main/views/templates.py | 64 ++++++-- .../template_category_api_client.py | 153 ++++++------------ .../views/templates/template_categories.html | 10 +- .../views/templates/template_category.html | 10 +- tests/__init__.py | 30 ++++ tests/app/main/views/test_letters.py | 2 + tests/app/main/views/test_platform_admin.py | 14 +- tests/app/main/views/test_template_folders.py | 7 +- tests/app/main/views/test_templates.py | 45 +++++- .../test_template_category_api_client.py | 118 ++++++++++++++ tests/conftest.py | 17 ++ 13 files changed, 386 insertions(+), 190 deletions(-) create mode 100644 tests/app/notify_client/test_template_category_api_client.py diff --git a/app/config.py b/app/config.py index 78fe390407..b8c8fa2a17 100644 --- a/app/config.py +++ b/app/config.py @@ -21,30 +21,31 @@ class Config(object): - # 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] - ACTIVITY_STATS_LIMIT_DAYS = 7 - if os.environ.get("HEROKU_APP_NAME", "") != "": - ADMIN_BASE_URL = "https://" + os.environ.get("HEROKU_APP_NAME", "") + ".herokuapp.com" - else: - ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", "http://localhost:6012") - ADMIN_CLIENT_USER_NAME = "notify-admin" - ADMIN_CLIENT_SECRET = os.environ.get("ADMIN_CLIENT_SECRET") - ANTIVIRUS_API_HOST = os.environ.get("ANTIVIRUS_API_HOST") - ANTIVIRUS_API_KEY = os.environ.get("ANTIVIRUS_API_KEY") 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(",")] - + ADMIN_BASE_URL = ( + "https://" + os.environ.get("HEROKU_APP_NAME", "") + ".herokuapp.com" + if os.environ.get("HEROKU_APP_NAME", "") != "" + else os.environ.get("ADMIN_BASE_URL", "http://localhost:6012") + ) + ADMIN_CLIENT_SECRET = os.environ.get("ADMIN_CLIENT_SECRET") + ADMIN_CLIENT_USER_NAME = "notify-admin" + ANTIVIRUS_API_HOST = os.environ.get("ANTIVIRUS_API_HOST") + ANTIVIRUS_API_KEY = os.environ.get("ANTIVIRUS_API_KEY") API_HOST_NAME = os.environ.get("API_HOST_NAME") ASSET_DOMAIN = os.getenv("ASSET_DOMAIN", "assets.notification.canada.ca") ASSET_PATH = "/static/" ASSETS_DEBUG = False AWS_REGION = os.environ.get("AWS_REGION", "us-east-1") + + # Bounce Rate parameters + BR_DISPLAY_VOLUME_MINIMUM = 1000 + BULK_SEND_AWS_BUCKET = os.getenv("BULK_SEND_AWS_BUCKET") - BULK_SEND_TEST_SERVICE_ID = os.getenv("BULK_SEND_TEST_SERVICE_ID") + 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") @@ -52,8 +53,6 @@ class Config(object): 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") - CRM_GITHUB_PERSONAL_ACCESS_TOKEN = os.getenv("CRM_GITHUB_PERSONAL_ACCESS_TOKEN") - CRM_ORG_LIST_URL = os.getenv("CRM_ORG_LIST_URL") DANGEROUS_SALT = os.environ.get("DANGEROUS_SALT") DEBUG = False DEBUG_KEY = os.environ.get("DEBUG_KEY", "") @@ -74,8 +73,21 @@ class Config(object): DOCUMENTATION_DOMAIN = os.getenv("DOCUMENTATION_DOMAIN", "documentation.notification.canada.ca") EMAIL_2FA_EXPIRY_SECONDS = 1_800 # 30 Minutes EMAIL_EXPIRY_SECONDS = 3600 # 1 hour - FREE_YEARLY_SMS_LIMIT = env.int("FREE_YEARLY_SMS_LIMIT", 25_000) + + # 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] + + # FEATURE FLAGS + FF_NEW_BRANDING = env.bool("FF_NEW_BRANDING", False) + FF_SALESFORCE_CONTACT = env.bool("FF_SALESFORCE_CONTACT", True) + FF_TEMPLATE_CATEGORY = env.bool("FF_TEMPLATE_CATEGORY", False) + FF_TOU = env.bool("FF_TOU", 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) + 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") @@ -84,40 +96,38 @@ class Config(object): 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("/") - GC_ARTICLES_API = os.environ.get("GC_ARTICLES_API", "articles.alpha.canada.ca/notification-gc-notify") - GC_ARTICLES_API_AUTH_USERNAME = os.environ.get("GC_ARTICLES_API_AUTH_USERNAME") - GC_ARTICLES_API_AUTH_PASSWORD = os.environ.get("GC_ARTICLES_API_AUTH_PASSWORD") - LANGUAGES = ["en", "fr"] 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", "") + NOTIFY_APP_NAME = "admin" NOTIFY_BAD_FILLER_UUID = "00000000-0000-0000-0000-000000000000" NOTIFY_ENVIRONMENT = "development" NOTIFY_LOG_LEVEL = "DEBUG" NOTIFY_LOG_PATH = os.getenv("NOTIFY_LOG_PATH", "") - NOTIFY_SERVICE_ID = "d6aa2c68-a2d9-4437-ab19-3ae8eb202553" + NOTIFY_TEMPLATE_PREFILL_SERVICE_ID = "93305b36-b0a0-4a34-9ab2-c1b7bb5ca489" - NOTIFY_USER_ID = "6af522d0-2915-4e52-83a3-3690455a5fe6" - PERMANENT_SESSION_LIFETIME = 8 * 60 * 60 # 8 hours - REDIS_URL = os.environ.get("REDIS_URL") + PERMANENT_SESSION_LIFETIME = 8 * 60 * 60 # 8 hours REDIS_ENABLED = env.bool("REDIS_ENABLED", False) - + REDIS_URL = os.environ.get("REDIS_URL") ROUTE_SECRET_KEY_1 = os.environ.get("ROUTE_SECRET_KEY_1", "") ROUTE_SECRET_KEY_2 = os.environ.get("ROUTE_SECRET_KEY_2", "") - WAF_SECRET = os.environ.get("WAF_SECRET", "waf-secret") + + # Scan files integration + SCANFILES_AUTH_TOKEN = os.environ.get("SCANFILES_AUTH_TOKEN", "") + 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") - SEND_FILE_MAX_AGE_DEFAULT = 365 * 24 * 60 * 60 # 1 year SENDING_DOMAIN = os.environ.get("SENDING_DOMAIN", "notification.alpha.canada.ca") + SENSITIVE_SERVICES = os.environ.get("SENSITIVE_SERVICES", "") SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_NAME = "notify_admin_session" + SESSION_COOKIE_SAMESITE = "Lax" SESSION_COOKIE_SECURE = True SESSION_REFRESH_EACH_REQUEST = True - SESSION_COOKIE_SAMESITE = "Lax" - SENSITIVE_SERVICES = os.environ.get("SENSITIVE_SERVICES", "") SHOW_STYLEGUIDE = env.bool("SHOW_STYLEGUIDE", True) # Hosted graphite statsd prefix @@ -128,24 +138,16 @@ class Config(object): 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 - ZENDESK_API_KEY = os.environ.get("ZENDESK_API_KEY") - # Bounce Rate parameters - BR_DISPLAY_VOLUME_MINIMUM = 1000 - - # Scan files integration - SCANFILES_URL = os.environ.get("SCANFILES_URL", "") - SCANFILES_AUTH_TOKEN = os.environ.get("SCANFILES_AUTH_TOKEN", "") + # Various IDs + BULK_SEND_TEST_SERVICE_ID = os.getenv("BULK_SEND_TEST_SERVICE_ID") - # FEATURE FLAGS - FF_SALESFORCE_CONTACT = env.bool("FF_SALESFORCE_CONTACT", True) - FF_NEW_BRANDING = env.bool("FF_NEW_BRANDING", False) - FF_TEMPLATE_CATEGORY = env.bool("FF_TEMPLATE_CATEGORY", False) - FF_TOU = env.bool("FF_TOU", False) + 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") @classmethod diff --git a/app/main/forms.py b/app/main/forms.py index 98eeae8912..d24aacbeb5 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -1911,24 +1911,24 @@ class BrandingRequestForm(StripWhitespaceForm): class TemplateCategoryForm(StripWhitespaceForm): name_en = StringField("Name EN", validators=[DataRequired(message=_l("This cannot be empty"))]) name_fr = StringField("Name FR", validators=[DataRequired(message=_l("This cannot be empty"))]) - desc_en = StringField("Desc EN", validators=[DataRequired(message=_l("This cannot be empty"))]) - desc_fr = StringField("Desc FR", validators=[DataRequired(message=_l("This cannot be empty"))]) + description_en = StringField("Desc EN", validators=[DataRequired(message=_l("This cannot be empty"))]) + description_fr = StringField("Desc FR", validators=[DataRequired(message=_l("This cannot be empty"))]) hidden = RadioField(_l("Hide category"), choices=[("True", _l("Hide")), ("False", _l("Show"))]) - email_priority = RadioField( + email_process_type = RadioField( _l("Email Priority"), choices=[ - ("high", _l("High")), - ("medium", _l("Medium")), - ("low", _l("Low")), + ("priority", _l("High")), + ("normal", _l("Medium")), + ("bulk", _l("Low")), ], validators=[DataRequired(message=_l("This cannot be empty"))], ) - sms_priority = RadioField( + sms_process_type = RadioField( _l("Text message priority"), choices=[ - ("high", _l("High")), - ("medium", _l("Medium")), - ("low", _l("Low")), + ("priority", _l("High")), + ("normal", _l("Medium")), + ("bulk", _l("Low")), ], validators=[DataRequired(message=_l("This cannot be empty"))], ) diff --git a/app/main/views/templates.py b/app/main/views/templates.py index 3a2dc82628..b89ab382d8 100644 --- a/app/main/views/templates.py +++ b/app/main/views/templates.py @@ -75,6 +75,13 @@ "letter": LetterTemplateForm, } +# Todo: Remove this once the process_types in the backend are updated to use low/med/high +category_mapping = { + "bulk": "low", + "normal": "medium", + "priority": "high", +} + form_objects_with_category = { "email": EmailTemplateFormWithCategory, "sms": SMSTemplateFormWithCategory, @@ -840,15 +847,11 @@ def abort_403_if_not_admin_user(): def _get_categories_and_prepare_form(template, template_type): categories = template_category_api_client.get_all_template_categories() - # TODO: Remove this, this will come from the DB - if "/edit" in request.path: - template["template_category"] = "1" - form = form_objects_with_category[template_type](**template) # alphabetize choices name_col = "name_en" if get_current_locale(current_app) == "en" else "name_fr" - desc_col = "desc_en" if get_current_locale(current_app) == "en" else "desc_fr" + desc_col = "description_en" if get_current_locale(current_app) == "en" else "description_fr" categories = sorted(categories, key=lambda x: x[name_col]) form.template_category.choices = [(cat["id"], cat[name_col]) for cat in categories] @@ -1239,11 +1242,40 @@ def add_recipients(service_id, template_id): def template_categories(): template_category_list = template_category_api_client.get_all_template_categories() + # Todo: Remove this once the process_types in the backend are updated to use low/med/high + # Maps bulk/normal/priority to low/med/high for display in the front end. + for cat in template_category_list: + if cat["sms_process_type"] in category_mapping: + cat["sms_process_type"] = category_mapping[cat["sms_process_type"]] + + if cat["email_process_type"] in category_mapping: + cat["email_process_type"] = category_mapping[cat["email_process_type"]] + return render_template( "views/templates/template_categories.html", search_form=SearchByNameForm(), template_categories=template_category_list ) +@main.route("/template-categories/add", methods=["GET", "POST"]) +def add_template_category(): + form = TemplateCategoryForm() + + if form.validate_on_submit(): + template_category_api_client.create_template_category( + name_en=form.data["name_en"], + name_fr=form.data["name_fr"], + description_en=form.data["description_en"], + description_fr=form.data["description_fr"], + hidden=form.data["hidden"], + email_process_type=form.data["email_process_type"], + sms_process_type=form.data["sms_process_type"], + ) + + return redirect(url_for(".template_categories")) + + return render_template("views/templates/template_category.html", search_form=SearchByNameForm(), form=form) + + @main.route("/template-categories/", methods=["GET", "POST"]) @user_is_platform_admin def template_category(template_category_id): @@ -1251,14 +1283,26 @@ def template_category(template_category_id): form = TemplateCategoryForm( name_en=template_category["name_en"], name_fr=template_category["name_fr"], - desc_en=template_category["desc_en"], - desc_fr=template_category["desc_fr"], + description_en=template_category["description_en"], + description_fr=template_category["description_fr"], hidden=template_category["hidden"], - email_priority=template_category["email_priority"], - sms_priority=template_category["sms_priority"], + email_process_type=template_category["email_process_type"], + sms_process_type=template_category["sms_process_type"], ) - form.validate_on_submit() + if form.validate_on_submit(): + template_category_api_client.update_template_category( + template_category_id, + name_en=form.data["name_en"], + name_fr=form.data["name_fr"], + description_en=form.data["description_en"], + description_fr=form.data["description_fr"], + hidden=form.data["hidden"], + email_process_type=form.data["email_process_type"], + sms_process_type=form.data["sms_process_type"], + ) + + return redirect(url_for(".template_categories")) return render_template( "views/templates/template_category.html", search_form=SearchByNameForm(), template_category=template_category, form=form diff --git a/app/notify_client/template_category_api_client.py b/app/notify_client/template_category_api_client.py index 80e4b959d8..18a1f6567d 100644 --- a/app/notify_client/template_category_api_client.py +++ b/app/notify_client/template_category_api_client.py @@ -1,110 +1,57 @@ -from app.notify_client import NotifyAdminAPIClient - -# TODO: remove this and call the API -cats = [ - { - "name_en": "Status updates", - "name_fr": "FR: Status updates", - "desc_en": "Notice of change in status, progress of a submission", - "desc_fr": "FR: Notice of change in status, progress of a submission", - "id": "1", - "email_priority": "high", - "sms_priority": "low", - "hidden": "false", - }, - { - "name_en": "Promotional call to action", - "name_fr": "FR: Promotional call to action", - "desc_en": "Surveys, general apply now, learn more", - "desc_fr": "FR: Surveys, general apply now, learn more", - "id": "2", - "email_priority": "high", - "sms_priority": "low", - "hidden": "false", - }, - { - "name_en": "Service related requests", - "name_fr": "FR: Service related requests", - "desc_en": "Submit additional documents, follow up to move a process forward", - "desc_fr": "FR: Submit additional documents, follow up to move a process forward", - "id": "3", - "email_priority": "high", - "sms_priority": "low", - "hidden": "false", - }, - { - "name_en": "Fulfillment with attachments - email only", - "name_fr": "FR: Fulfillment with attachments - email only", - "desc_en": "Here’s your permit", - "desc_fr": "FR: Here’s your permit", - "id": "4", - "email_priority": "high", - "sms_priority": "low", - "hidden": "false", - }, - { - "name_en": "Broadcast messages", - "name_fr": "FR: Broadcast messages", - "desc_en": "General information, not related to transactions such as COVID 19 information", - "desc_fr": "FR: General information, not related to transactions such as COVID 19 information", - "id": "5", - "email_priority": "high", - "sms_priority": "low", - "hidden": "false", - }, - { - "name_en": "Auto-reply", - "name_fr": "FR: Auto-reply", - "desc_en": "No-reply messages, acknowledgements, response wait times", - "desc_fr": "FR: No-reply messages, acknowledgements, response wait times", - "id": "6", - "email_priority": "high", - "sms_priority": "low", - "hidden": "false", - }, - { - "name_en": "Verification message", - "name_fr": "FR: Verification message", - "desc_en": "Authentication codes, confirming an account change", - "desc_fr": "FR: Authentication codes, confirming an account change", - "id": "7", - "email_priority": "high", - "sms_priority": "low", - "hidden": "false", - }, - { - "name_en": "Confirmation / Receipts", - "name_fr": "FR: Confirmation / Receipts", - "desc_en": "Record of transaction, approvals", - "desc_fr": "FR: Record of transaction, approvals", - "id": "8", - "email_priority": "high", - "sms_priority": "low", - "hidden": "false", - }, -] +from app.notify_client import NotifyAdminAPIClient, cache class TemplateCategoryClient(NotifyAdminAPIClient): - def create_template_category(self, template_category): - # TODO: Implement the creation logic - pass - + @cache.delete("template_categories") + def create_template_category( + self, name_en, name_fr, description_en, description_fr, sms_process_type, email_process_type, hidden + ): + data = { + "name_en": name_en, + "name_fr": name_fr, + "description_en": description_en, + "description_fr": description_fr, + "sms_process_type": sms_process_type, + "email_process_type": email_process_type, + "hidden": True if hidden == "True" else False, + } + return self.post(url="/template-category", data=data) + + @cache.set("template_category-{template_category_id}") def get_template_category(self, template_category_id): - # TODO: Implement the retrieval logic - return next((category for category in cats if category["id"] == template_category_id), None) - - def get_all_template_categories(self): - # TODO: Implement retrieval logic - return cats - - def update_template_category(self, template_category_id, template_category): - # TODO: Implement the update logic - pass - - def delete_template_category(self, template_category_id): - # TODO: Implement the deletion logic - pass + return self.get(url="/template-category/{}".format(template_category_id))["template_category"] + + @cache.set("template_categories") + def get_all_template_categories(self, template_type=None, hidden=None, sort_key=None): + categories = self.get(url="/template-category")["template_categories"] + + if len(categories) > 0: + if sort_key and sort_key in categories[0]: + categories.sort(key=lambda category: category[sort_key].lower()) + return categories + else: + return [] + + @cache.delete("template_category-{template_category_id}") + @cache.delete("template_categories") + def update_template_category( + self, template_category_id, name_en, name_fr, description_en, description_fr, sms_process_type, email_process_type, hidden + ): + data = { + "name_en": name_en, + "name_fr": name_fr, + "description_en": description_en, + "description_fr": description_fr, + "sms_process_type": sms_process_type, + "email_process_type": email_process_type, + "hidden": hidden, + } + return self.post(url="/template-category/{}".format(template_category_id), data=data) + + @cache.delete("template_category-{template_category_id}") + @cache.delete("template_categories") + def delete_template_category(self, template_category_id, cascade=False): + return self.delete(url="/template-category/{}".format(template_category_id), data=cascade) template_category_api_client = TemplateCategoryClient() diff --git a/app/templates/views/templates/template_categories.html b/app/templates/views/templates/template_categories.html index 8c62a05a2f..1324feb7f0 100644 --- a/app/templates/views/templates/template_categories.html +++ b/app/templates/views/templates/template_categories.html @@ -31,14 +31,14 @@ ) %} {% call field() %} - {{ item.name_en }} + {{ item.name_en }} {% endcall %} {% call field() %} - {{ item.email_priority }} + {{ item.email_process_type }} {% endcall %} {% call field() %} - {{ item.sms_priority }} + {{ item.sms_process_type }} {% endcall %} {% call field() %} {{ item.hidden }} @@ -46,8 +46,8 @@ {% endcall %} - + {% endblock %} diff --git a/app/templates/views/templates/template_category.html b/app/templates/views/templates/template_category.html index 757899504a..b422a3a593 100644 --- a/app/templates/views/templates/template_category.html +++ b/app/templates/views/templates/template_category.html @@ -18,21 +18,21 @@ This page is under development and is not yet functional. - + {{ page_header(_('Update category'), id_text='template_cats') }} {% call form_wrapper() %}
- +
{{ textbox_localized("name", fields=({"en": form.name_en, "fr": form.name_fr}), legend=_("Category label")) }}
- {{ textbox_localized("desc", fields=({"en": form.desc_en, "fr": form.desc_fr}), legend=_("Category description")) }} + {{ textbox_localized("desc", fields=({"en": form.description_en, "fr": form.description_fr}), legend=_("Category description")) }}
{{ radios(form.hidden) }} - {{ radios(form.email_priority) }} - {{ radios(form.sms_priority) }} + {{ radios(form.email_process_type) }} + {{ radios(form.sms_process_type) }}
{{ page_footer( diff --git a/tests/__init__.py b/tests/__init__.py index b10904c9f7..129ce5762c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -15,6 +15,10 @@ from app.models.user import User from app.tou import TERMS_KEY +DEFAULT_TEMPLATE_CATEGORY_LOW = "0dda24c2-982a-4f44-9749-0e38b2607e89" +DEFAULT_TEMPLATE_CATEGORY_MEDIUM = "f75d6706-21b7-437e-b93a-2c0ab771e28e" +DEFAULT_TEMPLATE_CATEGORY_HIGH = "c4f87d7c-a55b-4c0f-91fe-e56c65bb1871" + # Add itsdangerous to the libraries which freezegun ignores to avoid errors. # In tests where we freeze time, the code in the test function will get the frozen time but the # fixtures will be using the current time. This causes itsdangerous to raise an exception - when @@ -284,6 +288,29 @@ def organisation_json( } +def template_category_json( + id_, + name_en="name_en", + name_fr="name_fr", + description_en="description_en", + description_fr="description_fr", + hidden=False, + sms_process_type="bulk", + email_process_type="bulk", +): + template_category = { + "id": id_, + "name_en": name_en, + "name_fr": name_fr, + "description_en": description_en, + "description_fr": description_fr, + "hidden": hidden, + "sms_process_type": sms_process_type, + "email_process_type": email_process_type, + } + return template_category + + def template_json( service_id, id_, @@ -301,9 +328,11 @@ def template_json( is_precompiled_letter=False, postage=None, folder=None, + template_category=DEFAULT_TEMPLATE_CATEGORY_LOW, ): template = { "id": id_, + "template_category_id": template_category, "name": name, "template_type": type_ or "sms", "content": content, @@ -318,6 +347,7 @@ def template_json( "is_precompiled_letter": is_precompiled_letter, "folder": folder, "postage": postage, + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, } if content is None: template["content"] = "template content" diff --git a/tests/app/main/views/test_letters.py b/tests/app/main/views/test_letters.py index a52bba5ac2..a0e69faf88 100644 --- a/tests/app/main/views/test_letters.py +++ b/tests/app/main/views/test_letters.py @@ -22,6 +22,7 @@ def test_letters_access_restricted( service_one["permissions"] = permissions mocker.patch("app.service_api_client.get_service", return_value={"data": service_one}) + mocker.patch("app.template_category_api_client.get_all_template_categories") response = platform_admin_client.get(url(service_id=service_one["id"])) @@ -41,6 +42,7 @@ def test_letters_lets_in_without_permission( ): service_one["permissions"] = ["letter"] mocker.patch("app.service_api_client.get_service", return_value={"data": service_one}) + mocker.patch("app.template_category_api_client.get_all_template_categories") client.login(api_user_active) response = client.get(url(service_id=service_one["id"])) diff --git a/tests/app/main/views/test_platform_admin.py b/tests/app/main/views/test_platform_admin.py index d3b0f4b18a..ec9195c16e 100644 --- a/tests/app/main/views/test_platform_admin.py +++ b/tests/app/main/views/test_platform_admin.py @@ -1421,11 +1421,11 @@ class TestTemplateCategory: testing_template = { "name_en": "name_en-123", "name_fr": "name_fr-123", - "desc_en": "desc_en-123", - "desc_fr": "desc_fr-123", + "description_en": "desc_en-123", + "description_fr": "desc_fr-123", "id": "id-123", - "email_priority": "email_priority-123", - "sms_priority": "sms_priority-123", + "email_process_type": "email_priority-123", + "sms_process_type": "sms_priority-123", "hidden": "hidden-123", } @@ -1436,11 +1436,15 @@ def test_item_displays_in_admin_menu(self, platform_admin_client, platform_admin assert page.find("a", href="/template-categories") def test_categories_page_is_accessible(self, platform_admin_client, platform_admin_user, mocker): + mocker.patch( + "app.main.views.templates.template_category_api_client.get_all_template_categories", + return_value=[self.testing_template], + ) resp = platform_admin_client.get(url_for("main.template_categories")) page = BeautifulSoup(resp.data.decode("utf-8"), "html.parser") # find an anchor tag with the text "Template categories" - assert len(page.select("[data-testid='template-categories-table']")) == 1 + assert len(page.select('table[data-testid="template-categories-table"]')) == 1 def test_category_page_is_accessible(self, platform_admin_client, platform_admin_user, mocker): mocker.patch( diff --git a/tests/app/main/views/test_template_folders.py b/tests/app/main/views/test_template_folders.py index 7ade125dbb..967c7933a1 100644 --- a/tests/app/main/views/test_template_folders.py +++ b/tests/app/main/views/test_template_folders.py @@ -9,6 +9,7 @@ from app.models.user import User from tests import sample_uuid from tests.conftest import ( + DEFAULT_TEMPLATE_CATEGORY_LOW, SERVICE_ONE_ID, TEMPLATE_ONE_ID, ClientRequest, @@ -370,13 +371,15 @@ def test_should_show_templates_folder_page( mock_get_service_templates.assert_called_once_with(SERVICE_ONE_ID) -def test_can_create_email_template_with_parent_folder(client_request, mock_create_service_template, fake_uuid): +def test_can_create_email_template_with_parent_folder( + client_request, mock_create_service_template, mock_get_template_categories, fake_uuid +): data = { "name": "new name", "subject": "Food incoming!", "template_content": "here's a burrito 🌯", "template_type": "email", - "template_category": "1", + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "service": SERVICE_ONE_ID, "process_type": TemplateProcessTypes.BULK.value, "parent_folder_id": PARENT_FOLDER_ID, diff --git a/tests/app/main/views/test_templates.py b/tests/app/main/views/test_templates.py index 3934a6b4a7..cced8793f8 100644 --- a/tests/app/main/views/test_templates.py +++ b/tests/app/main/views/test_templates.py @@ -30,6 +30,7 @@ _template, ) from tests.conftest import ( + DEFAULT_TEMPLATE_CATEGORY_LOW, SERVICE_ONE_ID, SERVICE_TWO_ID, TEMPLATE_ONE_ID, @@ -785,6 +786,7 @@ def test_should_show_page_template_with_priority_select_if_platform_admin( fake_uuid, ): mocker.patch("app.user_api_client.get_users_for_service", return_value=[platform_admin_user]) + mocker.patch("app.template_category_api_client.get_all_template_categories") template_id = fake_uuid client_request.login(platform_admin_user) page = client_request.get( @@ -1080,6 +1082,7 @@ def test_load_edit_template_with_copy_of_template( mock_get_service_templates, mock_get_service_email_template, mock_get_non_empty_organisations_and_services_for_user, + mock_get_template_categories, existing_template_names, expected_name, ): @@ -1108,6 +1111,7 @@ def test_copy_template_loads_template_from_within_subfolder( client_request, active_user_with_permission_to_two_services, mock_get_service_templates, + mock_get_template_categories, mock_get_non_empty_organisations_and_services_for_user, mocker, ): @@ -1196,6 +1200,7 @@ def test_should_not_allow_creation_of_a_template_without_correct_permission( client_request, service_one, mocker, + mock_get_template_categories, type_of_template, ): service_one["permissions"] = [] @@ -1247,6 +1252,7 @@ def test_should_redirect_to_one_off_if_template_type_is_letter( def test_should_redirect_when_saving_a_template( client_request, + mock_get_template_categories, mock_get_service_template, mock_update_service_template, fake_uuid, @@ -1262,6 +1268,7 @@ def test_should_redirect_when_saving_a_template( "name": name, "template_content": content, "template_type": "sms", + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "service": SERVICE_ONE_ID, "process_type": DEFAULT_PROCESS_TYPE, }, @@ -1287,6 +1294,7 @@ def test_should_edit_content_when_process_type_is_set_not_platform_admin( client_request, mocker, mock_update_service_template, + mock_get_template_categories, fake_uuid, process_type, ): @@ -1300,6 +1308,7 @@ def test_should_edit_content_when_process_type_is_set_not_platform_admin( "name": "new name", "template_content": "new template content with & entity", "template_type": "sms", + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "service": SERVICE_ONE_ID, "process_type": process_type, "button_pressed": "save", @@ -1369,6 +1378,7 @@ def test_should_403_when_edit_template_with_non_default_process_type_for_non_pla "name": "new name", "template_content": "template content with & entity", "template_type": "sms", + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "service": service["id"], "process_type": process_type, } @@ -1387,6 +1397,7 @@ def test_should_403_when_create_template_with_non_default_process_type_for_non_p mocker, mock_get_service_template, mock_update_service_template, + mock_get_template_categories, fake_uuid, process_type, service_one, @@ -1403,7 +1414,7 @@ def test_should_403_when_create_template_with_non_default_process_type_for_non_p "name": "new name", "template_content": "template content with & entity", "template_type": "sms", - "template_category": "1", + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "service": service["id"], "process_type": process_type, } @@ -1442,6 +1453,7 @@ def test_should_show_interstitial_when_making_breaking_change( client_request, mock_update_service_template, mock_get_user_by_email, + mock_get_template_categories, fake_uuid, mocker, template_data, @@ -1455,6 +1467,7 @@ def test_should_show_interstitial_when_making_breaking_change( "name": "new name", "template_content": "hello lets talk about ((thing))", "template_type": template_type, + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "subject": "reminder '\" & ((name))", "service": SERVICE_ONE_ID, "process_type": DEFAULT_PROCESS_TYPE, @@ -1499,6 +1512,7 @@ def test_removing_placeholders_is_not_a_breaking_change( client_request, mock_get_service_email_template, mock_update_service_template, + mock_get_template_categories, fake_uuid, ): existing_template = mock_get_service_email_template(0, 0)["data"] @@ -1509,6 +1523,7 @@ def test_removing_placeholders_is_not_a_breaking_change( _data={ "name": existing_template["name"], "template_content": "no placeholders", + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "subject": existing_template["subject"], "button_pressed": "save", }, @@ -1532,6 +1547,7 @@ def test_should_not_update_if_template_name_too_long( "name": "new name", "template_content": "template content!!", "template_type": template_type, + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "process_type": DEFAULT_PROCESS_TYPE, } if template_type == "email": @@ -1549,13 +1565,13 @@ def test_should_not_update_if_template_name_too_long( @pytest.mark.parametrize("template_type", ["sms", "email"]) def test_should_not_create_if_template_name_too_long( - client_request, template_type, mock_create_service_template_400_name_too_long + client_request, template_type, mock_create_service_template_400_name_too_long, mock_get_template_categories ): template_data = { "name": "new name", "template_content": "template content", "template_type": template_type, - "template_category": "1", + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "service": SERVICE_ONE_ID, "process_type": DEFAULT_PROCESS_TYPE, } @@ -1586,7 +1602,7 @@ def test_should_not_create_too_big_template( "name": "new name", "template_content": "template content", "template_type": "sms", - "template_category": "1", + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "service": SERVICE_ONE_ID, "process_type": DEFAULT_PROCESS_TYPE, }, @@ -1611,6 +1627,7 @@ def test_should_not_update_too_big_template( "template_content": "template content", "service": SERVICE_ONE_ID, "template_type": "sms", + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "process_type": DEFAULT_PROCESS_TYPE, }, _expected_status=200, @@ -1622,6 +1639,7 @@ def test_should_redirect_when_saving_a_template_email( client_request, mock_get_service_email_template, mock_update_service_template, + mock_get_template_categories, mock_get_user_by_email, fake_uuid, ): @@ -1637,6 +1655,7 @@ def test_should_redirect_when_saving_a_template_email( "name": name, "template_content": content, "template_type": "email", + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "service": SERVICE_ONE_ID, "subject": subject, "process_type": DEFAULT_PROCESS_TYPE, @@ -1664,6 +1683,7 @@ def test_should_redirect_when_previewing_a_template_email( client_request, mock_get_service_email_template, mock_update_service_template, + mock_get_template_categories, mock_get_user_by_email, fake_uuid, ): @@ -1679,6 +1699,7 @@ def test_should_redirect_when_previewing_a_template_email( "name": name, "template_content": content, "template_type": "email", + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "service": SERVICE_ONE_ID, "subject": subject, "process_type": DEFAULT_PROCESS_TYPE, @@ -1804,6 +1825,7 @@ def test_preview_has_correct_back_link( "subject": "test subject", "content": "test content", "template_type": "email", + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "folder": "", "id": id, } @@ -2087,6 +2109,7 @@ def test_route_permissions( mock_get_service_template, mock_get_template_folders, mock_get_template_statistics_for_template, + mock_get_template_categories, fake_uuid, ): validate_route_permission( @@ -2195,6 +2218,7 @@ def test_can_create_email_template_with_emoji( mock_create_service_template, mock_get_template_folders, mock_get_service_template_when_no_template_exists, + mock_get_template_categories, ): page = client_request.post( ".add_service_template", @@ -2205,7 +2229,7 @@ def test_can_create_email_template_with_emoji( "subject": "Food incoming!", "template_content": "here's a burrito 🌯", "template_type": "email", - "template_category": "1", + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "service": SERVICE_ONE_ID, "process_type": DEFAULT_PROCESS_TYPE, "button_pressed": "save", @@ -2222,6 +2246,7 @@ def test_should_not_create_sms_template_with_emoji( client_request, service_one, mock_create_service_template, + mock_get_template_categories, ): page = client_request.post( ".add_service_template", @@ -2231,7 +2256,7 @@ def test_should_not_create_sms_template_with_emoji( "name": "new name", "template_content": "here are some noodles 🍜", "template_type": "sms", - "template_category": "1", + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "service": SERVICE_ONE_ID, "process_type": DEFAULT_PROCESS_TYPE, }, @@ -2245,6 +2270,7 @@ def test_should_not_update_sms_template_with_emoji( client_request, mock_get_service_template, mock_update_service_template, + mock_get_template_categories, fake_uuid, ): page = client_request.post( @@ -2257,6 +2283,7 @@ def test_should_not_update_sms_template_with_emoji( "template_content": "here's a burger 🍔", "service": SERVICE_ONE_ID, "template_type": "sms", + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "process_type": DEFAULT_PROCESS_TYPE, }, _expected_status=200, @@ -2265,7 +2292,9 @@ def test_should_not_update_sms_template_with_emoji( assert mock_update_service_template.called is False -def test_should_create_sms_template_without_downgrading_unicode_characters(client_request, mock_create_service_template): +def test_should_create_sms_template_without_downgrading_unicode_characters( + client_request, mock_create_service_template, mock_get_template_categories +): msg = "here:\tare some “fancy quotes” and non\u200Bbreaking\u200Bspaces" client_request.post( @@ -2276,7 +2305,7 @@ def test_should_create_sms_template_without_downgrading_unicode_characters(clien "name": "new name", "template_content": msg, "template_type": "sms", - "template_category": "1", + "template_category": DEFAULT_TEMPLATE_CATEGORY_LOW, "service": SERVICE_ONE_ID, "process_type": DEFAULT_PROCESS_TYPE, }, diff --git a/tests/app/notify_client/test_template_category_api_client.py b/tests/app/notify_client/test_template_category_api_client.py new file mode 100644 index 0000000000..2c79ca87a4 --- /dev/null +++ b/tests/app/notify_client/test_template_category_api_client.py @@ -0,0 +1,118 @@ +from unittest.mock import call + +import pytest + +from app.notify_client.template_category_api_client import TemplateCategoryClient + + +@pytest.fixture +def template_category_client(mocker): + return TemplateCategoryClient() + + +def test_create_template_category(template_category_client, mocker): + mock_post = mocker.patch("app.notify_client.template_category_api_client.TemplateCategoryClient.post") + template_category_client.create_template_category( + name_en="Test Name EN", + name_fr="Test Name FR", + description_en="Test Description EN", + description_fr="Test Description FR", + sms_process_type="sms_process", + email_process_type="email_process", + hidden="True", + ) + data = { + "name_en": "Test Name EN", + "name_fr": "Test Name FR", + "description_en": "Test Description EN", + "description_fr": "Test Description FR", + "sms_process_type": "sms_process", + "email_process_type": "email_process", + "hidden": True, + } + mock_post.assert_called_once_with(url="/template-category", data=data) + + +def test_get_template_category(template_category_client, mocker, fake_uuid): + mock_get = mocker.patch( + "app.notify_client.template_category_api_client.TemplateCategoryClient.get", + return_value={"template_category": "bar"}, + ) + mock_redis_get = mocker.patch( + "app.extensions.RedisClient.get", + return_value=None, + ) + mock_redis_set = mocker.patch( + "app.extensions.RedisClient.set", + ) + template_category_client.get_template_category(template_category_id=fake_uuid) + mock_get.assert_called_once_with(url=f"/template-category/{fake_uuid}") + mock_redis_get.assert_called_once_with(f"template_category-{fake_uuid}") + mock_redis_set.assert_called_once_with( + f"template_category-{fake_uuid}", + '"bar"', + ex=604800, + ) + + +def test_get_all_template_categories(template_category_client, mocker, fake_uuid): + mock_get = mocker.patch( + "app.notify_client.template_category_api_client.TemplateCategoryClient.get", + return_value={"template_categories": [1, 2, 3]}, + ) + mock_redis_get = mocker.patch( + "app.extensions.RedisClient.get", + return_value=None, + ) + mock_redis_set = mocker.patch( + "app.extensions.RedisClient.set", + ) + template_category_client.get_all_template_categories(template_type="template_type") + mock_get.assert_called_once_with(url="/template-category") + mock_redis_get.assert_called_once_with("template_categories") + mock_redis_set.assert_called_once_with( + "template_categories", + "[1, 2, 3]", + ex=604800, + ) + + +def test_update_template_category(template_category_client, mocker): + mock_post = mocker.patch("app.notify_client.template_category_api_client.TemplateCategoryClient.post", return_value=None) + mock_redis_delete = mocker.patch( + "app.extensions.RedisClient.delete", + ) + + template_category_client.update_template_category( + template_category_id="template_category_id", + name_en="Test Name EN", + name_fr="Test Name FR", + description_en="Test Description EN", + description_fr="Test Description FR", + sms_process_type="sms_process", + email_process_type="email_process", + hidden="hidden", + ) + data = { + "name_en": "Test Name EN", + "name_fr": "Test Name FR", + "description_en": "Test Description EN", + "description_fr": "Test Description FR", + "sms_process_type": "sms_process", + "email_process_type": "email_process", + "hidden": "hidden", + } + mock_post.assert_called_once_with(url="/template-category/template_category_id", data=data) + assert call("template_categories") in mock_redis_delete.call_args_list + assert call("template_category-template_category_id") in mock_redis_delete.call_args_list + assert len(mock_redis_delete.call_args_list) == 2 + + +def test_delete_template_category(template_category_client, mocker): + mock_delete = mocker.patch("app.notify_client.template_category_api_client.TemplateCategoryClient.delete") + mock_redis_delete = mocker.patch("app.extensions.RedisClient.delete", return_value=None) + + template_category_client.delete_template_category(template_category_id="template_category_id", cascade=False) + + mock_delete.assert_called_once_with(url="/template-category/template_category_id", data=False) + assert [call("template_categories"), call("template_category-template_category_id")] in mock_redis_delete.call_args_list diff --git a/tests/conftest.py b/tests/conftest.py index 330ca595db..7daf0ac9ac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,7 @@ from app.types import EmailReplyTo from . import ( + DEFAULT_TEMPLATE_CATEGORY_LOW, TestClient, api_key_json, assert_url_expected, @@ -31,6 +32,7 @@ sample_uuid, service_json, single_notification_json, + template_category_json, template_json, template_version_json, user_json, @@ -790,6 +792,11 @@ def _get(service_id, template_id, version=None): template.update({"version": version}) return {"data": template} + def _get_tc(): + return [template_category_json(id_=DEFAULT_TEMPLATE_CATEGORY_LOW)] + + mocker.patch("app.template_category_api_client.get_all_template_categories", side_effect=_get_tc) + return mocker.patch("app.service_api_client.get_service_template", side_effect=_get) @@ -1097,6 +1104,7 @@ def create_template( name="sample template", content="Template content", subject="Template subject", + template_category_id=DEFAULT_TEMPLATE_CATEGORY_LOW, redact_personalisation=False, postage=None, folder=None, @@ -1112,6 +1120,7 @@ def create_template( redact_personalisation=redact_personalisation, postage=postage, folder=folder, + template_category=template_category_id, ) } @@ -2865,6 +2874,14 @@ def mock_remove_user_from_service(mocker): return mocker.patch("app.service_api_client.remove_user_from_service", return_value=None) +@pytest.fixture(scope="function") +def mock_get_template_categories(mocker): + def _get(): + return [template_category_json(id_=DEFAULT_TEMPLATE_CATEGORY_LOW)] + + return mocker.patch("app.template_category_api_client.get_all_template_categories", side_effect=_get) + + @pytest.fixture(scope="function") def mock_get_template_statistics(mocker, service_one, fake_uuid): template = template_json(