Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Template_Categories table #2193

Open
wants to merge 28 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4bab291
Draft migration to add TemplateCategories table
whabanks Jun 11, 2024
da56d60
Fix prop logic for template_process_type
whabanks Jun 11, 2024
e7346fd
Add indexes, unique constraints
whabanks Jun 12, 2024
70334b0
Add CRUD methods for TemplateCategories
whabanks Jun 13, 2024
0430b4b
Insert low, med, high default categories during migration
whabanks Jun 13, 2024
28637f2
Fix prop logic for template_process_type again
whabanks Jun 13, 2024
b76744e
WIP: Add API endpoints for interacting with TemplateCategories
whabanks Jun 13, 2024
e34dc4a
Implement dao and api to update process_type
whabanks Jun 17, 2024
6a291fc
Address PR comments
whabanks Jun 17, 2024
fcb3550
Finish adding needed api endpoints
whabanks Jun 17, 2024
d8799ac
Chore: logic cleanup
whabanks Jun 17, 2024
6da3eaa
First batch of unit tests & bug fixes
whabanks Jun 18, 2024
a0fd757
Implement filtering when fetching all categories
whabanks Jun 19, 2024
e172bb7
Add lazy join on TemplateCategory
whabanks Jun 19, 2024
8a374c2
Clean up dao tests
whabanks Jun 19, 2024
46d0130
Add tests for deleting a template category
whabanks Jun 19, 2024
98c93bf
Add API tests, squash bugs
whabanks Jun 20, 2024
c7cab45
Fix pre-existing tests
whabanks Jun 24, 2024
6055586
Misc. fixes
whabanks Jun 24, 2024
231128f
We definitely didn't want that FK on templatehistory...
whabanks Jun 24, 2024
654d6b3
Merge branch 'main' into feat/add-template-categories-table
whabanks Jun 24, 2024
33006d7
Logic cleanups
whabanks Jun 24, 2024
8e0afa2
Rename migration
whabanks Jun 25, 2024
f927c99
Add tests for models
whabanks Jun 25, 2024
041a647
Add tests that were missed for template rest and dao
whabanks Jun 25, 2024
0be6d19
Rename /template/category to /template-category
whabanks Jun 25, 2024
44ad1f1
various fixes
whabanks Jun 25, 2024
d1c5c4e
Merge branch 'main' into feat/add-template-categories-table
jzbahrai Jun 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(",")]
Expand Down
87 changes: 87 additions & 0 deletions app/dao/template_categories_dao.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import uuid

from flask import current_app
Fixed Show fixed Hide fixed
from app import db
from app.dao.dao_utils import transactional
Fixed Show fixed Hide fixed
from app.models import Template, TemplateCategory


@transactional
def dao_create_template_category(template_category: TemplateCategory):
jzbahrai marked this conversation as resolved.
Show resolved Hide resolved
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().template_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)

query = query.distinct()

return query.all()


@transactional
def dao_update_template_category(template_category: TemplateCategory):
db.session.add(template_category)


@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. Dissociated templates will be assigned a default category based on the sms/email process type of the category it was associated with
previously
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.

Raises:
e: _description_
"""
template_category = dao_get_template_category_by_id(template_category_id)
templates = Template.query.filter_by(template_category_id=template_category_id).all()

if templates:
if cascade:
try:
for template in templates:
process_type = template_category.sms_process_type if template.template_type == 'sms' else template_category.email_process_type
template.category = dao_get_template_category_by_id(_get_default_category_id(process_type))
db.session.add(template)

db.session.delete(template_category)
except Exception as e:
db.session.rollback()
raise e
else:
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'])
65 changes: 65 additions & 0 deletions app/dao/templates_dao.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
jzbahrai marked this conversation as resolved.
Show resolved Hide resolved
Template.query.filter_by(id=template_id).update(
whabanks marked this conversation as resolved.
Show resolved Hide resolved
{
"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
Expand Down
46 changes: 46 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1033,6 +1033,36 @@ 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)

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,
}

@classmethod
def from_json(cls, data):
fields = data.copy()
return cls(**fields)


class TemplateBase(BaseModel):
__abstract__ = True

Expand Down Expand Up @@ -1078,6 +1108,10 @@ 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 created_by(cls):
return db.relationship("User")
Expand Down Expand Up @@ -1179,6 +1213,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",
Expand All @@ -1198,6 +1233,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:
whabanks marked this conversation as resolved.
Show resolved Hide resolved
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):
"""
Expand Down
27 changes: 27 additions & 0 deletions app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,32 @@ def make_instance(self, data, **kwargs):
return super(BaseSchema, self).make_instance(data)


class TemplateCategorySchema(BaseSchema):
class Meta(BaseSchema.Meta):
model = models.TemplateCategory
exclude = ("id",)

@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")
Expand Down Expand Up @@ -805,6 +831,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()
Expand Down
20 changes: 20 additions & 0 deletions app/template/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -132,6 +134,24 @@ def create_template(service_id):
return jsonify(data=template_schema.dump(new_template)), 201


@template_blueprint.route("/<uuid:template_id>/template-category/<uuid:template_category_id>", methods=["POST"])
def update_templates_category(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("/<uuid:template_id>/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("/<uuid:template_id>", 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)
Expand Down
Loading
Loading