From 19258ccf2b5d277e19836d9a8f3735d881d50df9 Mon Sep 17 00:00:00 2001 From: William B <7444334+whabanks@users.noreply.github.com> Date: Wed, 3 Jul 2024 16:38:02 -0400 Subject: [PATCH] Add Template_Categories table (#2193) * Draft migration to add TemplateCategories table - Initial work on the models * Fix prop logic for template_process_type * Add indexes, unique constraints - Fix typos - Update sqlalchemy model * Add CRUD methods for TemplateCategories - Added template_category_id to the template_history table - Adjusted model class name TemplateCategories -> TemplateCategory - WIP: Added some basic tests for the TemplateCategory dao * Insert low, med, high default categories during migration * Fix prop logic for template_process_type again * WIP: Add API endpoints for interacting with TemplateCategories - Added a schema for TemplateCategory - Added from_json and serialize to the TemplateCategory model - Added the template/category Blueprint - Add some initial CRUD api endpoints * Implement dao and api to update process_type * Address PR comments - Changed the category relationship in Template from TemplateCategory to template_category - dao_get_template_category_by_template_id now simply selects the template, and returns the associated template_category instead of using an expensive join * Finish adding needed api endpoints - Moved the default categories into our config file - Added dao_update_template_category - Added dao_delete_template_category_by_id - Added routes to: get and delete a template category - Added a route to get all template categories - Updated the migration file to use default category uuid's from the config file * Chore: logic cleanup * First batch of unit tests & bug fixes * Implement filtering when fetching all categories - Add tests * Add lazy join on TemplateCategory * Clean up dao tests - Squash more bugs - Fix formatting - Added a couple more tests for the filters on dao_get_all_template_categories * Add tests for deleting a template category - Excluded the template_category table from deletion in notify_db_session to preserve the 3 generic template categories between test runs - Fixed inserts in the migration, apparently alembic / sqlalchemy doesn't like multi-line f-strings - Made a few tests shorter by excluding the description_en and description_fr columns as they are optional * Add API tests, squash bugs - Allow passing of a uuid to dao_create_template_category - Fixed issues with get_template_categories and delete_template_category filters / flags - Added a fixture to re-populate the template_category table with generic categories and removed template_categories from the list of tables that are excluded from the post-test db clear * Fix pre-existing tests - formatting * Misc. fixes - Fix incorrectly named prop call in dao - Added category as a declared attribute in TemplateBase model - Added a proper route to get a category by template id - Added a foreign key to templates_history for the category id - Fix a couple more tests * We definitely didn't want that FK on templatehistory... * Logic cleanups - Tweak tests - No longer excluding template_category_id in template_category_schema - Updated migration revision as 0453 was added for pinpoint work * Rename migration - Remove NOT NULL constraint on templates.process_type * Add tests for models - Simplify the dao delete logic * Add tests that were missed for template rest and dao * Rename /template/category to /template-category - Adjust tests - formatting * various fixes * Re-word dao_delete_template_category_by_id Clarify that new category assignments aren't done at random but are chosen from one of the default categories. * Add created_at and updated_at fields - Set a default category id if a category is not passed to create_sample_template * Add default value for created at, fix tests * Quick fix * Fix column defaults - Create a template category when creating a sample template and do not pass in a category as we cannot rely on the defaults existing in the test db * Fix silly typo.. * Remove unneeded assert --------- Co-authored-by: Jumana B --- app/__init__.py | 3 + app/config.py | 3 + app/dao/template_categories_dao.py | 86 ++++ app/dao/templates_dao.py | 65 ++++ app/models.py | 55 +++ app/schemas.py | 26 ++ app/template/rest.py | 20 + app/template/template_category_rest.py | 105 +++++ .../versions/0454_add_template_category.py | 85 ++++ tests/app/conftest.py | 101 +++++ tests/app/dao/test_template_categories_dao.py | 368 ++++++++++++++++++ tests/app/dao/test_templates_dao.py | 14 + tests/app/db.py | 2 + tests/app/template/test_rest.py | 14 + .../template/test_template_category_rest.py | 146 +++++++ tests/app/test_model.py | 32 ++ 16 files changed, 1125 insertions(+) create mode 100644 app/dao/template_categories_dao.py create mode 100644 app/template/template_category_rest.py create mode 100644 migrations/versions/0454_add_template_category.py create mode 100644 tests/app/dao/test_template_categories_dao.py create mode 100644 tests/app/template/test_template_category_rest.py diff --git a/app/__init__.py b/app/__init__.py index c3c144620e..78fae3406f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -201,6 +201,7 @@ def register_blueprint(application): from app.service.rest import service_blueprint from app.status.healthcheck import status as status_blueprint from app.template.rest import template_blueprint + from app.template.template_category_rest import template_category_blueprint from app.template_folder.rest import template_folder_blueprint from app.template_statistics.rest import ( template_statistics as template_statistics_blueprint, @@ -259,6 +260,8 @@ def register_blueprint(application): register_notify_blueprint(application, letter_branding_blueprint, requires_admin_auth) + register_notify_blueprint(application, template_category_blueprint, requires_admin_auth) + def register_v2_blueprints(application): from app.authentication.auth import requires_auth diff --git a/app/config.py b/app/config.py index b8b6521ad5..5acf768a2f 100644 --- a/app/config.py +++ b/app/config.py @@ -340,6 +340,9 @@ class Config(object): HEARTBEAT_TEMPLATE_SMS_LOW = "ab3a603b-d602-46ea-8c83-e05cb280b950" HEARTBEAT_TEMPLATE_SMS_MEDIUM = "a48b54ce-40f6-4e4a-abe8-1e2fa389455b" HEARTBEAT_TEMPLATE_SMS_HIGH = "4969a9e9-ddfd-476e-8b93-6231e6f1be4a" + 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" # Allowed service IDs able to send HTML through their templates. ALLOW_HTML_SERVICE_IDS: List[str] = [id.strip() for id in os.getenv("ALLOW_HTML_SERVICE_IDS", "").split(",")] diff --git a/app/dao/template_categories_dao.py b/app/dao/template_categories_dao.py new file mode 100644 index 0000000000..f38586e899 --- /dev/null +++ b/app/dao/template_categories_dao.py @@ -0,0 +1,86 @@ +import uuid +from datetime import datetime + +from flask import current_app + +from app import db +from app.dao.dao_utils import transactional +from app.models import Template, TemplateCategory + + +@transactional +def dao_create_template_category(template_category: TemplateCategory): + if template_category.id is None: + template_category.id = uuid.uuid4() + db.session.add(template_category) + + +def dao_get_template_category_by_id(template_category_id) -> TemplateCategory: + return TemplateCategory.query.filter_by(id=template_category_id).one() + + +def dao_get_template_category_by_template_id(template_id) -> TemplateCategory: + return Template.query.filter_by(id=template_id).one().category + + +# TODO: Add filters: Select all template categories used by at least 1 sms/email template +def dao_get_all_template_categories(template_type=None, hidden=None): + query = TemplateCategory.query + + if template_type is not None: + query = query.join(Template).filter(Template.template_type == template_type) + + if hidden is not None: + query = query.filter(TemplateCategory.hidden == hidden) + + return query.all() + + +@transactional +def dao_update_template_category(template_category: TemplateCategory): + db.session.add(template_category) + db.session.commit() + + +@transactional +def dao_delete_template_category_by_id(template_category_id, cascade=False): + """ + Deletes a `TemplateCategory`. By default, if the `TemplateCategory` is associated with any `Template`, it will not be deleted. + If the `cascade` option is specified then the category will be forcible removed: + 1. The `Category` will be dissociated from templates that use it + 2. The `Template` is assigned to one of the default categories that matches the priority of the deleted category + 3. Finally the `Category` will be deleted + + Args: + template_category_id (str): The id of the template_category to delete + cascade (bool, optional): Specify whether to dissociate the category from templates that use it to force removal. Defaults to False. + """ + template_category = dao_get_template_category_by_id(template_category_id) + templates = Template.query.filter_by(template_category_id=template_category_id).all() + + if not templates or cascade: + # When there are templates and we are cascading, we set the category to a default + # that matches the template's previous category's priority + if cascade: + for template in templates: + # Get the a default category that matches the previous priority of the template, based on template type + default_category_id = _get_default_category_id( + template_category.sms_process_type + if template.template_type == "sms" + else template_category.email_process_type + ) + template.category = dao_get_template_category_by_id(default_category_id) + template.updated_at = datetime.utcnow() + db.session.add(template) + + db.session.delete(template_category) + db.session.commit() + + +def _get_default_category_id(process_type): + default_categories = { + "bulk": current_app.config["DEFAULT_TEMPLATE_CATEGORY_LOW"], + "normal": current_app.config["DEFAULT_TEMPLATE_CATEGORY_MEDIUM"], + "priority": current_app.config["DEFAULT_TEMPLATE_CATEGORY_HIGH"], + } + return default_categories.get(process_type, current_app.config["DEFAULT_TEMPLATE_CATEGORY_LOW"]) diff --git a/app/dao/templates_dao.py b/app/dao/templates_dao.py index 822296cae4..8f06080986 100644 --- a/app/dao/templates_dao.py +++ b/app/dao/templates_dao.py @@ -78,6 +78,71 @@ def dao_update_template_reply_to(template_id, reply_to): return template +@transactional +def dao_update_template_process_type(template_id, process_type): + Template.query.filter_by(id=template_id).update( + { + "process_type": process_type, + } + ) + template = Template.query.filter_by(id=template_id).one() + + history = TemplateHistory( + **{ + "id": template.id, + "name": template.name, + "template_type": template.template_type, + "created_at": template.created_at, + "updated_at": template.updated_at, + "content": template.content, + "service_id": template.service_id, + "subject": template.subject, + "postage": template.postage, + "created_by_id": template.created_by_id, + "version": template.version, + "archived": template.archived, + "process_type": template.process_type, + "service_letter_contact_id": template.service_letter_contact_id, + } + ) + db.session.add(history) + return template + + +@transactional +def dao_update_template_category(template_id, category_id): + Template.query.filter_by(id=template_id).update( + { + "template_category_id": category_id, + "updated_at": datetime.utcnow(), + "version": Template.version + 1, + } + ) + + template = Template.query.filter_by(id=template_id).one() + + history = TemplateHistory( + **{ + "id": template.id, + "name": template.name, + "template_type": template.template_type, + "created_at": template.created_at, + "updated_at": template.updated_at, + "content": template.content, + "service_id": template.service_id, + "subject": template.subject, + "postage": template.postage, + "created_by_id": template.created_by_id, + "version": template.version, + "archived": template.archived, + "process_type": template.process_type, + "service_letter_contact_id": template.service_letter_contact_id, + } + ) + db.session.add(history) + return template + + @transactional def dao_redact_template(template, user_id): template.template_redacted.redact_personalisation = True diff --git a/app/models.py b/app/models.py index f79867918e..2d0e665c6a 100644 --- a/app/models.py +++ b/app/models.py @@ -1033,6 +1033,40 @@ def get_users_with_permission(self): PRECOMPILED_TEMPLATE_NAME = "Pre-compiled PDF" +class TemplateCategory(BaseModel): + __tablename__ = "template_categories" + + id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name_en = db.Column(db.String(255), unique=True, nullable=False) + name_fr = db.Column(db.String(255), unique=True, nullable=False) + description_en = db.Column(db.String(200), nullable=True) + description_fr = db.Column(db.String(200), nullable=True) + sms_process_type = db.Column(db.String(200), nullable=False) + email_process_type = db.Column(db.String(200), nullable=False) + hidden = db.Column(db.Boolean, nullable=False, default=False) + created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) + updated_at = db.Column(db.DateTime, onupdate=datetime.datetime.utcnow) + + def serialize(self): + return { + "id": self.id, + "name_en": self.name_en, + "name_fr": self.name_fr, + "description_en": self.description_en, + "description_fr": self.description_fr, + "sms_process_type": self.sms_process_type, + "email_process_type": self.email_process_type, + "hidden": self.hidden, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + + @classmethod + def from_json(cls, data): + fields = data.copy() + return cls(**fields) + + class TemplateBase(BaseModel): __abstract__ = True @@ -1078,6 +1112,14 @@ def service_id(cls): def created_by_id(cls): return db.Column(UUID(as_uuid=True), db.ForeignKey("users.id"), index=True, nullable=False) + @declared_attr + def template_category_id(cls): + return db.Column(UUID(as_uuid=True), db.ForeignKey("template_categories.id"), index=True, nullable=True) + + @declared_attr + def category(cls): + return db.relationship("TemplateCategory") + @declared_attr def created_by(cls): return db.relationship("User") @@ -1179,6 +1221,7 @@ class Template(TemplateBase): service = db.relationship("Service", backref="templates") version = db.Column(db.Integer, default=0, nullable=False) + category = db.relationship("TemplateCategory", lazy="joined", backref="templates") folder = db.relationship( "TemplateFolder", @@ -1198,6 +1241,17 @@ def get_link(self): _external=True, ) + @property + def template_process_type(self): + """By default we use the process_type from TemplateCategory, but allow admins to override it on a per-template basis. + Only when overriden do we use the process_type from the template itself. + """ + if self.template_type == SMS_TYPE: + return self.process_type if self.process_type else self.template_categories.sms_process_type + elif self.template_type == EMAIL_TYPE: + return self.process_type if self.process_type else self.template_categories.email_process_type + return self.process_type + @classmethod def from_json(cls, data, folder=None): """ @@ -1246,6 +1300,7 @@ class TemplateHistory(TemplateBase): service = db.relationship("Service") version = db.Column(db.Integer, primary_key=True, nullable=False) + category = db.relationship("TemplateCategory") @classmethod def from_json(cls, data): diff --git a/app/schemas.py b/app/schemas.py index 75a6a2e78f..644ffda597 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -107,6 +107,31 @@ def make_instance(self, data, **kwargs): return super(BaseSchema, self).make_instance(data) +class TemplateCategorySchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.TemplateCategory + + @validates("name_en") + def validate_name_en(self, value): + if not value: + raise ValidationError("Invalid name") + + @validates("name_fr") + def validate_name_fr(self, value): + if not value: + raise ValidationError("Invalid name") + + @validates("sms_process_type") + def validate_sms_process_type(self, value): + if value not in models.TEMPLATE_PROCESS_TYPE: + raise ValidationError("Invalid SMS process type") + + @validates("email_process_type") + def validate_email_process_type(self, value): + if value not in models.TEMPLATE_PROCESS_TYPE: + raise ValidationError("Invalid email process type") + + class UserSchema(BaseSchema): permissions = fields.Method("user_permissions", dump_only=True) password_changed_at = field_for(models.User, "password_changed_at", format="%Y-%m-%d %H:%M:%S.%f") @@ -805,6 +830,7 @@ def validate_archived(self, data, **kwargs): service_history_schema = ServiceHistorySchema() api_key_history_schema = ApiKeyHistorySchema() template_history_schema = TemplateHistorySchema() +template_category_schema = TemplateCategorySchema() event_schema = EventSchema() provider_details_schema = ProviderDetailsSchema() provider_details_history_schema = ProviderDetailsHistorySchema() diff --git a/app/template/rest.py b/app/template/rest.py index 46e5348dbf..4e376351c8 100644 --- a/app/template/rest.py +++ b/app/template/rest.py @@ -25,6 +25,8 @@ dao_get_template_versions, dao_redact_template, dao_update_template, + dao_update_template_category, + dao_update_template_process_type, dao_update_template_reply_to, get_precompiled_letter_template, ) @@ -132,6 +134,24 @@ def create_template(service_id): return jsonify(data=template_schema.dump(new_template)), 201 +@template_blueprint.route("//category/", methods=["POST"]) +def update_templates_category(service_id, template_id, template_category_id): + updated = dao_update_template_category(template_id, template_category_id) + return jsonify(data=template_schema.dump(updated)), 200 + + +@template_blueprint.route("//process-type", methods=["POST"]) +def update_template_process_type(template_id): + data = request.get_json() + if "process_type" not in data: + message = "Field is required" + errors = {"process_type": [message]} + raise InvalidRequest(errors, status_code=400) + + updated = dao_update_template_process_type(template_id=template_id, process_type=data.get("process_type")) + return jsonify(data=template_schema.dump(updated)), 200 + + @template_blueprint.route("/", methods=["POST"]) def update_template(service_id, template_id): fetched_template = dao_get_template_by_id_and_service_id(template_id=template_id, service_id=service_id) diff --git a/app/template/template_category_rest.py b/app/template/template_category_rest.py new file mode 100644 index 0000000000..76d9cbc1ba --- /dev/null +++ b/app/template/template_category_rest.py @@ -0,0 +1,105 @@ +from flask import Blueprint, jsonify, request + +from app.dao.template_categories_dao import ( + dao_create_template_category, + dao_delete_template_category_by_id, + dao_get_all_template_categories, + dao_get_template_category_by_id, + dao_get_template_category_by_template_id, + dao_update_template_category, +) +from app.errors import register_errors +from app.models import TemplateCategory +from app.schemas import template_category_schema + +template_category_blueprint = Blueprint( + "template_category", + __name__, + url_prefix="/template-category", +) + +register_errors(template_category_blueprint) + + +@template_category_blueprint.route("", methods=["POST"]) +def create_template_category(): + data = request.get_json() + + template_category_schema.load(data) + template_category = TemplateCategory.from_json(data) + + dao_create_template_category(template_category) + + return jsonify(template_category=template_category_schema.dump(template_category)), 201 + + +@template_category_blueprint.route("/", methods=["GET"]) +def get_template_category(template_category_id): + template_category = dao_get_template_category_by_id(template_category_id) + return jsonify(template_category=template_category_schema.dump(template_category)), 200 + + +@template_category_blueprint.route("/by-template-id/", methods=["GET"]) +def get_template_category_by_template_id(template_id): + template_category = dao_get_template_category_by_template_id(template_id) + return jsonify(template_category=template_category_schema.dump(template_category)), 200 + + +@template_category_blueprint.route("", methods=["GET"]) +def get_template_categories(): + template_type = request.args.get("template_type", None) + + hidden = request.args.get("hidden") + if hidden is not None: + if hidden == "True": + hidden = True + elif hidden == "False": + hidden = False + else: + hidden = None + + # Validate request args + if template_type is not None: + if template_type not in ["sms", "email"]: + return jsonify(message="Invalid filter 'template_type', valid template_types: 'sms', 'email'"), 400 + + template_categories = template_category_schema.dump(dao_get_all_template_categories(template_type, hidden), many=True) + return jsonify(template_categories=template_categories), 200 + + +@template_category_blueprint.route("/", methods=["POST"]) +def update_template_category(template_category_id): + current_category = dict(template_category_schema.dump(dao_get_template_category_by_id(template_category_id))) + current_category.update(request.get_json()) + + updated_category = template_category_schema.load(current_category) + dao_update_template_category(updated_category) + + return jsonify(template_category=template_category_schema.dump(updated_category)), 200 + + +@template_category_blueprint.route("/", methods=["DELETE"]) +def delete_template_category(template_category_id): + """Deletes a template category. By default, if the template category is associated with any template, it will not be deleted. + This can be overriden by specifying the `cascade` query parameter. + + Args: + template_category_id (str): The id of the template_category to delete + + Request Args: + cascade (bool, optional): Specify whether to dissociate the category from templates that use it to force removal. Defaults to False. + + Returns: + (flask.Response): The response message and http status code. + """ + + if request.args.get("cascade") == "True": + dao_delete_template_category_by_id(template_category_id, cascade=True) + return "", 200 + + template_category = dao_get_template_category_by_id(template_category_id) + if len(template_category.templates) > 0: + return jsonify(message="Cannot delete a template category with templates assigned to it."), 400 + else: + dao_delete_template_category_by_id(template_category_id) + return "", 200 diff --git a/migrations/versions/0454_add_template_category.py b/migrations/versions/0454_add_template_category.py new file mode 100644 index 0000000000..1ba66c6c8d --- /dev/null +++ b/migrations/versions/0454_add_template_category.py @@ -0,0 +1,85 @@ +""" + +Revision ID: 0454_add_template_categories +Revises: 0453_set_supports_international +Create Date: 2024-06-11 13:32:00 +""" + +from datetime import datetime + +import sqlalchemy as sa +from alembic import op +from flask import current_app +from sqlalchemy.dialects import postgresql + +revision = "0454_add_template_categories" +down_revision = "0453_set_supports_international" + + +def upgrade(): + op.create_table( + "template_categories", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), + sa.Column("name_en", sa.String(length=255), nullable=False), + sa.Column("name_fr", sa.String(length=255), nullable=False), + sa.Column("description_en", sa.String(length=255), nullable=True), + sa.Column("description_fr", sa.String(length=255), nullable=True), + sa.Column("sms_process_type", sa.String(length=255), nullable=False), + sa.Column("email_process_type", sa.String(length=255), nullable=False), + sa.Column("hidden", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=True), + sa.UniqueConstraint("name_en"), + sa.UniqueConstraint("name_fr"), + ) + + # Insert the generic low, medium, and high categories + op.execute( + "INSERT INTO template_categories (id, name_en, name_fr, sms_process_type, email_process_type, hidden, created_at) VALUES ('{}', 'Low Category (Bulk)', 'Catégorie Basse (En Vrac)', 'low', 'low', true, now())".format( + current_app.config["DEFAULT_TEMPLATE_CATEGORY_LOW"], + ) + ) + op.execute( + "INSERT INTO template_categories (id, name_en, name_fr, sms_process_type, email_process_type, hidden, created_at) VALUES ('{}', 'Medium Category (Normal)', 'Catégorie Moyenne (Normale)', 'low', 'low', true, now())".format( + current_app.config["DEFAULT_TEMPLATE_CATEGORY_MEDIUM"] + ) + ) + op.execute( + "INSERT INTO template_categories (id, name_en, name_fr, sms_process_type, email_process_type, hidden, created_at) VALUES ('{}', 'High Category (Priority)', 'Catégorie Haute (Priorité)', 'low', 'low', true, now())".format( + current_app.config["DEFAULT_TEMPLATE_CATEGORY_HIGH"] + ) + ) + + op.add_column("templates", sa.Column("template_category_id", postgresql.UUID(as_uuid=True), nullable=True)) + op.add_column("templates_history", sa.Column("template_category_id", postgresql.UUID(as_uuid=True), nullable=True)) + op.create_index( + op.f("ix_template_category_id"), + "templates", + ["template_category_id"], + unique=False, + ) + op.create_index( + op.f("ix_template_categories_name_en"), + "template_categories", + ["name_en"], + unique=False, + ) + op.create_index( + op.f("ix_template_categories_name_fr"), + "template_categories", + ["name_fr"], + unique=False, + ) + op.alter_column("templates", "process_type", nullable=True) + op.create_foreign_key("fk_template_template_categories", "templates", "template_categories", ["template_category_id"], ["id"]) + + +def downgrade(): + op.drop_constraint("fk_template_template_categories", "templates", type_="foreignkey") + op.drop_index(op.f("ix_template_category_id"), table_name="templates") + op.drop_index(op.f("ix_template_categories_name_en"), table_name="template_categories") + op.drop_index(op.f("ix_template_categories_name_fr"), table_name="template_categories") + op.alter_column("templates", "process_type", nullable=False) + op.drop_column("templates", "template_category_id") + op.drop_column("templates_history", "template_category_id") + op.drop_table("template_categories") diff --git a/tests/app/conftest.py b/tests/app/conftest.py index 3088d1e88e..3c21efc8ab 100644 --- a/tests/app/conftest.py +++ b/tests/app/conftest.py @@ -21,6 +21,7 @@ from app.dao.organisation_dao import dao_create_organisation from app.dao.provider_rates_dao import create_provider_rates from app.dao.services_dao import dao_add_user_to_service, dao_create_service +from app.dao.template_categories_dao import dao_create_template_category from app.dao.templates_dao import dao_create_template from app.dao.users_dao import create_secret_code, create_user_code from app.history_meta import create_history @@ -52,6 +53,7 @@ ServiceEmailReplyTo, ServiceSafelist, Template, + TemplateCategory, TemplateHistory, ) from tests import create_authorization_header @@ -230,6 +232,98 @@ def _sample_service_custom_letter_contact_block(sample_service): return sample_service +@pytest.fixture(scope="function") +def sample_template_category_with_templates(notify_db, notify_db_session, sample_template_category): + create_sample_template(notify_db, notify_db_session, category=sample_template_category) + create_sample_template(notify_db, notify_db_session, category=sample_template_category) + return sample_template_category + + +@pytest.fixture(scope="function") +def populate_generic_categories(notify_db_session): + generic_categories = [ + { + "id": current_app.config["DEFAULT_TEMPLATE_CATEGORY_LOW"], + "name_en": "Low Category (Bulk)", + "name_fr": "Catégorie Basse (En Vrac)", + "sms_process_type": "low", + "email_process_type": "low", + "hidden": True, + }, + { + "id": current_app.config["DEFAULT_TEMPLATE_CATEGORY_MEDIUM"], + "name_en": "Medium Category (Normal)", + "name_fr": "Catégorie Moyenne (Normale)", + "sms_process_type": "normal", + "email_process_type": "normal", + "hidden": True, + }, + { + "id": current_app.config["DEFAULT_TEMPLATE_CATEGORY_HIGH"], + "name_en": "High Category (Priority)", + "name_fr": "Catégorie Haute (Priorité)", + "sms_process_type": "high", + "email_process_type": "high", + "hidden": True, + }, + ] + for category in generic_categories: + dao_create_template_category(TemplateCategory(**category)) + + yield + + +@pytest.fixture(scope="function") +def sample_template_category( + notify_db, + notify_db_session, + name_en="Category Name", + name_fr="Category Name (FR)", + description_en="Category Description", + description_fr="Category Description (FR)", + sms_process_type="normal", + email_process_type="normal", + hidden=False, +): + return create_template_category( + notify_db, + notify_db_session, + name_en="Category Name", + name_fr="Category Name (FR)", + description_en="Category Description", + description_fr="Category Description (FR)", + sms_process_type="normal", + email_process_type="normal", + hidden=False, + ) + + +def create_template_category( + notify_db, + notify_db_session, + name_en="Category Name", + name_fr="Category Name (FR)", + description_en="Category Description", + description_fr="Category Description (FR)", + sms_process_type="normal", + email_process_type="normal", + hidden=False, +): + 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, + } + template_category = TemplateCategory(**data) + dao_create_template_category(template_category) + + return template_category + + def create_sample_template( notify_db, notify_db_session, @@ -241,6 +335,7 @@ def create_sample_template( subject_line="Subject", user=None, service=None, + category=None, created_by=None, process_type="normal", permissions=[EMAIL_TYPE, SMS_TYPE], @@ -268,6 +363,11 @@ def create_sample_template( data.update({"subject": subject_line}) if template_type == "letter": data["postage"] = "second" + if category: + data["category"] = category + else: + cat = create_template_category(notify_db, notify_db_session, name_en=str(uuid.uuid4), name_fr=str(uuid.uuid4)) + data.update({"template_category_id": cat.id}) template = Template(**data) dao_create_template(template) @@ -303,6 +403,7 @@ def sample_template( service=None, created_by=None, process_type="normal", + category=None, permissions=[EMAIL_TYPE, SMS_TYPE], ) diff --git a/tests/app/dao/test_template_categories_dao.py b/tests/app/dao/test_template_categories_dao.py new file mode 100644 index 0000000000..73832a2a43 --- /dev/null +++ b/tests/app/dao/test_template_categories_dao.py @@ -0,0 +1,368 @@ +import pytest +from flask import current_app + +from app.dao.template_categories_dao import ( + dao_create_template_category, + dao_delete_template_category_by_id, + dao_get_all_template_categories, + dao_get_template_category_by_id, + dao_get_template_category_by_template_id, + dao_update_template_category, +) +from app.dao.templates_dao import dao_create_template +from app.models import BULK, NORMAL, Template, TemplateCategory +from tests.app.conftest import create_sample_template + + +def test_create_template_category(notify_db_session): + data = { + "name_en": "english", + "name_fr": "french", + "description_en": "english description", + "description_fr": "french description", + "sms_process_type": NORMAL, + "email_process_type": NORMAL, + "hidden": False, + } + + template_category = TemplateCategory(**data) + dao_create_template_category(template_category) + + assert TemplateCategory.query.count() == 1 + assert len(dao_get_all_template_categories()) == 1 + + +@pytest.mark.parametrize( + "category, updated_category", + [ + ( + { + "name_en": "english", + "name_fr": "french", + "description_en": "english description", + "description_fr": "french description", + "sms_process_type": NORMAL, + "email_process_type": NORMAL, + "hidden": False, + }, + { + "name_en": "new english", + "name_fr": "new french", + "description_en": "new english description", + "description_fr": "new french description", + "sms_process_type": BULK, + "email_process_type": BULK, + "hidden": True, + }, + ) + ], +) +def test_update_template_category(notify_db_session, category, updated_category): + template_category = TemplateCategory(**category) + dao_create_template_category(template_category) + + for key, value in updated_category.items(): + setattr(template_category, key, value) + + dao_update_template_category(template_category) + + fetched_category = dao_get_all_template_categories()[0] + + assert fetched_category.id == template_category.id + for key, value in updated_category.items(): + assert getattr(fetched_category, key) == value + + +@pytest.mark.parametrize( + "category, template", + [ + ( + { + "name_en": "english", + "name_fr": "french", + "description_en": "english description", + "description_fr": "french description", + "sms_process_type": NORMAL, + "email_process_type": NORMAL, + "hidden": False, + }, + { + "name": "Sample Template", + "template_type": "email", + "content": "Template content", + }, + ) + ], +) +def test_dao_get_template_category_by_template_id(category, template, notify_db_session, sample_service, sample_user): + template_category = TemplateCategory(**category) + dao_create_template_category(template_category) + + template = Template(**template) + template.service = sample_service + template.created_by = sample_user + template.category = template_category + dao_create_template(template) + + assert dao_get_template_category_by_template_id(template.id) == template_category + + +def test_get_template_category_by_id(notify_db_session): + data = { + "name_en": "english", + "name_fr": "french", + "description_en": "english description", + "description_fr": "french description", + "sms_process_type": NORMAL, + "email_process_type": NORMAL, + "hidden": False, + } + + template_category = TemplateCategory(**data) + dao_create_template_category(template_category) + + assert dao_get_template_category_by_id(template_category.id) == template_category + + +@pytest.mark.parametrize( + "template_type, hidden, expected_count, categories_to_insert", + [ + ( + None, + None, + 2, + [ + { + "name_en": "english", + "name_fr": "french", + "sms_process_type": "normal", + "email_process_type": "normal", + "hidden": False, + }, + { + "name_en": "english2", + "name_fr": "french2", + "sms_process_type": "bulk", + "email_process_type": "bulk", + "hidden": False, + }, + ], + ), + # Filter by template type SMS + ( + "sms", + None, + 2, + [ + { + "name_en": "english", + "name_fr": "french", + "sms_process_type": "normal", + "email_process_type": "normal", + "hidden": False, + }, + { + "name_en": "english2", + "name_fr": "french2", + "sms_process_type": "bulk", + "email_process_type": "bulk", + "hidden": False, + }, + ], + ), + # Filter by template type email + ( + "email", + None, + 2, + [ + { + "name_en": "english", + "name_fr": "french", + "sms_process_type": "normal", + "email_process_type": "normal", + "hidden": False, + }, + { + "name_en": "english2", + "name_fr": "french2", + "sms_process_type": "bulk", + "email_process_type": "bulk", + "hidden": False, + }, + ], + ), + # Filter by hidden False + ( + None, + False, + 1, + [ + { + "name_en": "english", + "name_fr": "french", + "sms_process_type": "normal", + "email_process_type": "normal", + "hidden": False, + }, + { + "name_en": "english2", + "name_fr": "french2", + "sms_process_type": "bulk", + "email_process_type": "bulk", + "hidden": True, + }, + ], + ), + # Filter by hidden True + ( + None, + True, + 1, + [ + { + "name_en": "english", + "name_fr": "french", + "sms_process_type": "normal", + "email_process_type": "normal", + "hidden": False, + }, + { + "name_en": "english2", + "name_fr": "french2", + "sms_process_type": "bulk", + "email_process_type": "bulk", + "hidden": True, + }, + ], + ), + # Filter by template type SMS and hidden False + ( + "sms", + False, + 1, + [ + { + "name_en": "english", + "name_fr": "french", + "sms_process_type": "normal", + "email_process_type": "normal", + "hidden": False, + }, + { + "name_en": "english2", + "name_fr": "french2", + "sms_process_type": "bulk", + "email_process_type": "bulk", + "hidden": True, + }, + ], + ), + ( + "sms", + False, + 0, + [ + { + "name_en": "english", + "name_fr": "french", + "sms_process_type": "normal", + "email_process_type": "normal", + "hidden": True, + }, + { + "name_en": "english2", + "name_fr": "french2", + "sms_process_type": "bulk", + "email_process_type": "bulk", + "hidden": True, + }, + ], + ), + # Filter by template type email and hidden True + ( + "email", + True, + 1, + [ + { + "name_en": "english", + "name_fr": "french", + "sms_process_type": "normal", + "email_process_type": "normal", + "hidden": False, + }, + { + "name_en": "english2", + "name_fr": "french2", + "sms_process_type": "bulk", + "email_process_type": "bulk", + "hidden": True, + }, + ], + ), + ( + "email", + True, + 0, + [ + { + "name_en": "english", + "name_fr": "french", + "sms_process_type": "normal", + "email_process_type": "normal", + "hidden": False, + }, + { + "name_en": "english2", + "name_fr": "french2", + "sms_process_type": "bulk", + "email_process_type": "bulk", + "hidden": False, + }, + ], + ), + ], +) +def test_get_all_template_categories_with_filters( + template_type, hidden, expected_count, categories_to_insert, notify_db, notify_db_session +): + for category_data in categories_to_insert: + template_category = TemplateCategory(**category_data) + dao_create_template_category(template_category) + + create_sample_template(notify_db, notify_db_session, template_type="email", category=template_category) + create_sample_template(notify_db, notify_db_session, template_type="sms", category=template_category) + + retrieved_categories = dao_get_all_template_categories(template_type=template_type, hidden=hidden) + + assert len(retrieved_categories) == expected_count + + +def test_dao_delete_template_category_by_id_should_delete_category_when_no_associated_templates( + notify_db_session, sample_template_category +): + dao_delete_template_category_by_id(sample_template_category.id) + + assert TemplateCategory.query.count() == 0 + + +def test_dao_delete_template_category_by_id_should_not_allow_deletion_when_associated_with_template( + notify_db, notify_db_session, sample_template_category +): + create_sample_template(notify_db, notify_db_session, category=sample_template_category) + + dao_delete_template_category_by_id(sample_template_category.id) + + assert TemplateCategory.query.count() == 1 + + +def test_dao_delete_template_category_by_id_should_allow_deletion_with_cascade_when_associated_with_template( + notify_db, notify_db_session, sample_template_category, populate_generic_categories +): + template = create_sample_template(notify_db, notify_db_session, category=sample_template_category) + + dao_delete_template_category_by_id(sample_template_category.id, cascade=True) + # 3 here because we have 3 generic defaut categories that will remain post-delete + assert TemplateCategory.query.count() == 3 + assert str(template.template_category_id) == current_app.config["DEFAULT_TEMPLATE_CATEGORY_MEDIUM"] diff --git a/tests/app/dao/test_templates_dao.py b/tests/app/dao/test_templates_dao.py index ce6c07bdc7..516ceb6220 100644 --- a/tests/app/dao/test_templates_dao.py +++ b/tests/app/dao/test_templates_dao.py @@ -16,6 +16,7 @@ dao_get_template_versions, dao_redact_template, dao_update_template, + dao_update_template_category, dao_update_template_reply_to, ) from app.models import Template, TemplateHistory, TemplateRedacted @@ -490,3 +491,16 @@ def test_template_postage_constraint_on_update(sample_service, sample_user): created.postage = "third" with pytest.raises(expected_exception=SQLAlchemyError): dao_update_template(created) + + +def test_dao_update_template_category(sample_template, sample_template_category): + dao_update_template_category(sample_template.id, sample_template_category.id) + + updated_template = Template.query.get(sample_template.id) + assert updated_template.template_category_id == sample_template_category.id + assert updated_template.updated_at is not None + assert updated_template.version == 2 + + history = TemplateHistory.query.filter_by(id=sample_template.id, version=updated_template.version).one() + assert not history.template_category_id + assert history.updated_at == updated_template.updated_at diff --git a/tests/app/db.py b/tests/app/db.py index c9ff33427c..30d265d1a1 100644 --- a/tests/app/db.py +++ b/tests/app/db.py @@ -188,6 +188,7 @@ def create_template( hidden=False, archived=False, folder=None, + category=None, postage=None, process_type="normal", ): @@ -200,6 +201,7 @@ def create_template( "reply_to": reply_to, "hidden": hidden, "folder": folder, + "category": category, "process_type": process_type, } if template_type == LETTER_TYPE: diff --git a/tests/app/template/test_rest.py b/tests/app/template/test_rest.py index 5ca28a1177..77c034af2c 100644 --- a/tests/app/template/test_rest.py +++ b/tests/app/template/test_rest.py @@ -1558,3 +1558,17 @@ def test_should_template_be_redacted(): dao_update_organisation(some_org.id, organisation_type="province_or_territory") assert should_template_be_redacted(some_org) + + +def test_update_templates_category(sample_template, sample_template_category, admin_request): + admin_request.post( + "template.update_templates_category", + service_id=sample_template.service_id, + template_id=sample_template.id, + template_category_id=sample_template_category.id, + _expected_status=200, + ) + + template = dao_get_template_by_id(sample_template.id) + + assert template.category.id == sample_template_category.id diff --git a/tests/app/template/test_template_category_rest.py b/tests/app/template/test_template_category_rest.py new file mode 100644 index 0000000000..b20528bada --- /dev/null +++ b/tests/app/template/test_template_category_rest.py @@ -0,0 +1,146 @@ +import pytest +from flask import url_for + +from tests import create_authorization_header +from tests.app.conftest import create_sample_template + + +def test_should_create_new_template_category(client, notify_db, notify_db_session): + data = { + "name_en": "new english", + "name_fr": "new french", + "description_en": "new english description", + "description_fr": "new french description", + "sms_process_type": "bulk", + "email_process_type": "bulk", + "hidden": True, + } + auth_header = create_authorization_header() + + response = client.post( + url_for("template_category.create_template_category"), + headers=[("Content-Type", "application/json"), auth_header], + json=data, + ) + + assert response.status_code == 201 + assert response.json["template_category"]["name_en"] == "new english" + assert response.json["template_category"]["name_fr"] == "new french" + assert response.json["template_category"]["description_en"] == "new english description" + assert response.json["template_category"]["description_fr"] == "new french description" + assert response.json["template_category"]["sms_process_type"] == "bulk" + assert response.json["template_category"]["email_process_type"] == "bulk" + assert response.json["template_category"]["hidden"] + + +def test_get_template_category_by_id(client, sample_template_category): + auth_header = create_authorization_header() + response = client.get( + url_for("template_category.get_template_category", template_category_id=sample_template_category.id), + headers=[("Content-Type", "application/json"), auth_header], + ) + + assert response.status_code == 200 + assert response.json["template_category"]["name_en"] == sample_template_category.name_en + assert response.json["template_category"]["name_fr"] == sample_template_category.name_fr + assert response.json["template_category"]["description_en"] == sample_template_category.description_en + assert response.json["template_category"]["description_fr"] == sample_template_category.description_fr + assert response.json["template_category"]["sms_process_type"] == sample_template_category.sms_process_type + assert response.json["template_category"]["email_process_type"] == sample_template_category.email_process_type + assert response.json["template_category"]["hidden"] == sample_template_category.hidden + + +def test_get_template_category_by_template_id(client, notify_db, notify_db_session, sample_template_category): + category = sample_template_category + template = create_sample_template(notify_db, notify_db_session, category=category) + + auth_header = create_authorization_header() + endpoint = url_for("template_category.get_template_category_by_template_id", template_id=template.id) + + response = client.get( + endpoint, + headers=[("Content-Type", "application/json"), auth_header], + ) + + assert response.status_code == 200 + assert response.json["template_category"]["name_en"] == category.name_en + assert response.json["template_category"]["name_fr"] == category.name_fr + assert response.json["template_category"]["description_en"] == category.description_en + assert response.json["template_category"]["description_fr"] == category.description_fr + assert response.json["template_category"]["sms_process_type"] == category.sms_process_type + assert response.json["template_category"]["email_process_type"] == category.email_process_type + assert response.json["template_category"]["hidden"] == category.hidden + + +@pytest.mark.parametrize( + "template_type, hidden, expected_status_code, expected_msg", + [ + ("invalid_template_type", True, 400, "Invalid filter 'template_type', valid template_types: 'sms', 'email'"), + ("sms", "not_a_boolean", 200, None), + ("email", "True", 200, None), + ("email", "False", 200, None), + ("email", None, 200, None), + ("sms", "True", 200, None), + ("sms", "False", 200, None), + ("sms", None, 200, None), + (None, None, 200, None), + (None, "True", 200, None), + (None, "False", 200, None), + ], +) +def test_get_template_categories( + template_type, + hidden, + expected_status_code, + expected_msg, + sample_template_category, + client, + notify_db, + notify_db_session, + mocker, +): + auth_header = create_authorization_header() + + endpoint = url_for("template_category.get_template_categories", template_type=template_type, hidden=hidden) + + mocker.patch("app.dao.template_categories_dao.dao_get_all_template_categories", return_value=[sample_template_category]) + + response = client.get( + endpoint, + headers=[("Content-Type", "application/json"), auth_header], + ) + + assert response.status_code == expected_status_code + if not expected_status_code == 200: + assert response.json["message"] == expected_msg + + +@pytest.mark.parametrize( + "cascade, expected_status_code, expected_msg", + [ + ("True", 200, ""), + ("False", 400, "Cannot delete a template category with templates assigned to it."), + ], +) +def test_delete_template_category_cascade( + cascade, expected_status_code, expected_msg, client, mocker, sample_template_category_with_templates +): + auth_header = create_authorization_header() + mocker.patch( + "app.dao.template_categories_dao.dao_get_template_category_by_id", return_value=sample_template_category_with_templates + ) + + endpoint = url_for( + "template_category.delete_template_category", + template_category_id=sample_template_category_with_templates.id, + cascade=cascade, + ) + + response = client.delete( + endpoint, + headers=[("Content-Type", "application/json"), auth_header], + ) + + assert response.status_code == expected_status_code + if expected_status_code == 400: + assert response.json["message"] == expected_msg diff --git a/tests/app/test_model.py b/tests/app/test_model.py index 1df0f58f2f..52e4abcbf6 100644 --- a/tests/app/test_model.py +++ b/tests/app/test_model.py @@ -4,8 +4,10 @@ from app import signer_personalisation from app.models import ( + BULK, EMAIL_TYPE, MOBILE_TYPE, + NORMAL, NOTIFICATION_CREATED, NOTIFICATION_DELIVERED, NOTIFICATION_FAILED, @@ -16,10 +18,12 @@ NOTIFICATION_STATUS_TYPES_FAILED, NOTIFICATION_TECHNICAL_FAILURE, PRECOMPILED_TEMPLATE_NAME, + PRIORITY, SMS_TYPE, Notification, ServiceSafelist, ) +from tests.app.conftest import create_template_category from tests.app.db import ( create_inbound_number, create_letter_contact, @@ -354,6 +358,34 @@ def test_template_folder_is_parent(sample_service): assert not folders[1].is_parent_of(folders[0]) +@pytest.mark.parametrize( + "template_type, process_type, sms_process_type, email_process_type, expected_template_process_type", + [ + (SMS_TYPE, None, NORMAL, BULK, NORMAL), + (EMAIL_TYPE, None, BULK, NORMAL, NORMAL), + (SMS_TYPE, BULK, PRIORITY, PRIORITY, BULK), + (EMAIL_TYPE, BULK, PRIORITY, PRIORITY, BULK), + ], +) +def test_template_process_type( + notify_db, + notify_db_session, + template_type, + process_type, + sms_process_type, + email_process_type, + expected_template_process_type, +): + category = create_template_category( + notify_db, notify_db_session, sms_process_type=sms_process_type, email_process_type=email_process_type + ) + template = create_template( + service=create_service(), template_type=template_type, process_type=process_type, category=category + ) + + assert template.template_process_type == expected_template_process_type + + def test_fido2_key_serialization(sample_fido2_key): json = sample_fido2_key.serialize() assert json["name"] == sample_fido2_key.name