Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .fides/db_dataset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2563,6 +2563,8 @@ dataset:
data_categories: [system.operations]
- name: content
data_categories: [system.operations]
- name: label
data_categories: [system.operations]
- name: created_at
data_categories: [system.operations]
- name: updated_at
Expand Down
7 changes: 7 additions & 0 deletions changelog/7900-messaging-template-label.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
type: Added
description: |
Add label column to MessagingTemplate model enabling multiple named
templates per MessagingActionType. Includes migration with backfill,
unique constraint on (type, label), and get_templates_by_type query.
pr: 7900
labels: ["db-migration"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Add label column to messaging_template

Revision ID: d71c7d274c04
Revises: a42ef09a3dfe
Create Date: 2026-04-10 18:00:00.000000

"""

import sqlalchemy as sa
from alembic import op

revision = "d71c7d274c04"
down_revision = "a42ef09a3dfe"
branch_labels = None
depends_on = None

# Default labels at the time this migration was written (2026-04-10).
# Intentionally hardcoded — migrations must not import application code.
DEFAULT_LABELS = {
"subject_identity_verification": "Subject identity verification",
"privacy_request_receipt": "Privacy request received",
"privacy_request_review_approve": "Privacy request approved",
"privacy_request_review_deny": "Privacy request denied",
"privacy_request_complete_access": "Access request completed",
"privacy_request_complete_deletion": "Erasure request completed",
"privacy_request_complete_consent": "Consent request completed",
"manual_task_digest": "Manual task digest",
"external_user_welcome": "External user welcome",
}


def upgrade():
# Phase 1: Add nullable column
op.add_column(
"messaging_template",
sa.Column("label", sa.String(), nullable=True),
)

# Phase 2: Backfill labels
conn = op.get_bind()

# Backfill from known defaults
for template_type, label in DEFAULT_LABELS.items():
conn.execute(
sa.text(
"UPDATE messaging_template SET label = :label "
"WHERE type = :type AND label IS NULL"
),
{"label": label, "type": template_type},
)

# Backfill any remaining rows (types not in DEFAULT_LABELS) with title-cased type
conn.execute(
sa.text(
"UPDATE messaging_template SET label = INITCAP(REPLACE(type, '_', ' ')) "
"WHERE label IS NULL"
)
)

# Deduplicate: if multiple rows share (type, label), append a suffix
dupes = conn.execute(
Comment thread
JadeCara marked this conversation as resolved.
sa.text(
"SELECT type, label FROM messaging_template "
"GROUP BY type, label HAVING COUNT(*) > 1"
)
).fetchall()

for template_type, label in dupes:
rows = conn.execute(
sa.text(
"SELECT id FROM messaging_template "
"WHERE type = :type AND label = :label "
"ORDER BY updated_at DESC"
),
{"type": template_type, "label": label},
).fetchall()
# Keep the first (most recently updated) as-is, suffix the rest
for i, row in enumerate(rows[1:], start=2):
conn.execute(
sa.text(
"UPDATE messaging_template SET label = :new_label WHERE id = :id"
),
{"new_label": f"{label} ({i})", "id": row[0]},
)

# Phase 3: Set NOT NULL and add unique constraint
op.alter_column("messaging_template", "label", nullable=False)
op.create_unique_constraint(
"uq_messaging_template_type_label",
"messaging_template",
["type", "label"],
)


def downgrade():
op.drop_constraint(
"uq_messaging_template_type_label",
"messaging_template",
type_="unique",
)
op.drop_column("messaging_template", "label")
9 changes: 8 additions & 1 deletion src/fides/api/models/messaging_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import Any, Dict, List, Optional, Type

from pydantic import ConfigDict
from sqlalchemy import Boolean, Column, String
from sqlalchemy import Boolean, Column, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.mutable import MutableDict
Expand Down Expand Up @@ -107,7 +107,14 @@ class MessagingTemplate(Base):
def __tablename__(self) -> str:
return "messaging_template"

__table_args__ = (
UniqueConstraint("type", "label", name="uq_messaging_template_type_label"),
)

type = Column(String, index=True, nullable=False)
# Defensive fallback — the service layer always sets an explicit label.
# "Unnamed" signals a missing label rather than implying "the default template".
label = Column(String, nullable=False, default="Unnamed")
content = Column(MutableDict.as_mutable(JSONB), nullable=False)
properties: RelationshipProperty[List[Property]] = relationship(
"Property",
Expand Down
4 changes: 4 additions & 0 deletions src/fides/api/schemas/messaging/messaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,7 @@ class UserEmailInviteStatus(BaseModel):
class MessagingTemplateWithPropertiesBase(BaseModel):
id: str
type: str
label: str
is_enabled: bool
properties: Optional[List[MinimalProperty]] = None

Expand All @@ -541,6 +542,7 @@ class MessagingTemplateWithPropertiesBase(BaseModel):

class MessagingTemplateDefault(BaseModel):
type: str
label: str
is_enabled: bool
content: Dict[str, Any] = Field(
examples=[
Expand Down Expand Up @@ -570,6 +572,7 @@ class MessagingTemplateWithPropertiesDetail(MessagingTemplateWithPropertiesBase)


class MessagingTemplateWithPropertiesBodyParams(BaseModel):
label: str | None = Field(None, min_length=1)
content: Dict[str, Any] = Field(
examples=[
{
Expand All @@ -583,6 +586,7 @@ class MessagingTemplateWithPropertiesBodyParams(BaseModel):


class MessagingTemplateWithPropertiesPatchBodyParams(BaseModel):
label: str | None = Field(None, min_length=1)
content: Optional[Dict[str, Any]] = Field(
None,
examples=[
Expand Down
109 changes: 103 additions & 6 deletions src/fides/api/service/messaging/messaging_crud_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from fideslang.validation import FidesKey
from loguru import logger
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from fides.api.common_exceptions import (
Expand Down Expand Up @@ -95,13 +96,15 @@ def get_all_basic_messaging_templates(db: Session) -> List[MessagingTemplate]:
templates.append(
MessagingTemplate(
type=template_type,
label=template_from_db.label,
content=template_from_db.content,
)
)
else:
templates.append(
MessagingTemplate(
type=template_type,
label=template["label"],
content=template["content"],
)
)
Expand Down Expand Up @@ -162,8 +165,12 @@ def get_basic_messaging_template_by_type_or_default(

# If no template is found in the database, use the default
if not template and template_type in DEFAULT_MESSAGING_TEMPLATES:
content = DEFAULT_MESSAGING_TEMPLATES[template_type]["content"]
template = MessagingTemplate(type=template_type, content=content)
default = DEFAULT_MESSAGING_TEMPLATES[template_type]
template = MessagingTemplate(
type=template_type,
label=default["label"],
content=default["content"],
)

return template

Expand All @@ -185,9 +192,15 @@ def create_or_update_basic_templates(
# basic templates
if template.properties:
data["properties"] = [{"id": prop.id} for prop in template.properties]
# Preserve existing label if not provided
if "label" not in data and template.label:
data["label"] = template.label
template = template.update(db=db, data=data)

else:
# Ensure a label exists for new basic templates
if "label" not in data and data["type"] in DEFAULT_MESSAGING_TEMPLATES:
data["label"] = DEFAULT_MESSAGING_TEMPLATES[data["type"]]["label"]
template = MessagingTemplate.create(db=db, data=data)
return template
Comment thread
JadeCara marked this conversation as resolved.

Expand Down Expand Up @@ -230,6 +243,59 @@ def get_enabled_messaging_template_by_type_and_property(
return template


def get_templates_by_type(db: Session, template_type: str) -> list[MessagingTemplate]:
"""Return all templates matching the given type, ordered by most recently updated."""
return (
MessagingTemplate.query(db=db)
.filter(MessagingTemplate.type == template_type)
.order_by(MessagingTemplate.updated_at.desc())
.all()
)


def _next_available_label(
db: Session,
template_type: str,
base_label: str,
) -> str:
"""Return ``base_label`` if available, otherwise append an incrementing number.

Produces labels like "Subject identity verification",
"Subject identity verification (2)", "Subject identity verification (3)", etc.
"""
existing_labels: set[str] = {
row[0] # column query returns Row tuples, not model instances
for row in db.query(MessagingTemplate.label).filter(
MessagingTemplate.type == template_type,
)
}
if base_label not in existing_labels:
return base_label
counter = 2
while f"{base_label} ({counter})" in existing_labels:
counter += 1
return f"{base_label} ({counter})"


def _validate_unique_label(
db: Session,
template_type: str,
label: str,
exclude_id: str | None = None,
) -> None:
"""Raise if a template with this (type, label) already exists."""
query = db.query(MessagingTemplate).filter(
MessagingTemplate.type == template_type,
MessagingTemplate.label == label,
)
if exclude_id:
query = query.filter(MessagingTemplate.id != exclude_id)
if query.first():
raise MessagingTemplateValidationException(
f"A template with type '{template_type}' and label '{label}' already exists."
)


def _validate_enabled_template_has_properties(
new_property_ids: Optional[List[str]], is_enabled: bool
) -> None:
Expand Down Expand Up @@ -307,6 +373,12 @@ def patch_property_specific_template(
get unlinked from the messaging template.
"""
messaging_template: MessagingTemplate = get_template_by_id(db, template_id)
# Validate label uniqueness if label is being changed
new_label = template_patch_data.get("label")
if new_label is not None and new_label != messaging_template.label:
_validate_unique_label(
db, messaging_template.type, new_label, exclude_id=template_id
)
# use passed-in values if they exist, otherwise fall back on existing values in DB
properties: Optional[List[str]] = (
template_patch_data["properties"]
Expand Down Expand Up @@ -346,6 +418,14 @@ def update_property_specific_template(
Updating template type is not allowed once it is created, so we don't intake it here.
"""
messaging_template: MessagingTemplate = get_template_by_id(db, template_id)
# Preserve existing label when not provided (backward compat with clients
# that don't yet send label on PUT).
label = (
template_update_body.label
if template_update_body.label is not None
else messaging_template.label
)
_validate_unique_label(db, messaging_template.type, label, exclude_id=template_id)
_validate_enabled_template_has_properties(
template_update_body.properties, template_update_body.is_enabled
)
Expand All @@ -357,7 +437,8 @@ def update_property_specific_template(
template_id,
)

data = {
data: dict[str, Any] = {
"label": label,
"content": template_update_body.content,
"is_enabled": template_update_body.is_enabled,
}
Expand All @@ -381,6 +462,13 @@ def create_property_specific_template_by_type(
raise MessagingTemplateValidationException(
f"Messaging template type {template_type} is not supported."
)
if template_create_body.label is not None:
label = template_create_body.label
else:
label = _next_available_label(
db, template_type, DEFAULT_MESSAGING_TEMPLATES[template_type]["label"]
)
_validate_unique_label(db, template_type, label)
_validate_enabled_template_has_properties(
template_create_body.properties, template_create_body.is_enabled
)
Expand All @@ -392,7 +480,8 @@ def create_property_specific_template_by_type(
None,
)

data = {
data: dict[str, Any] = {
"label": label,
"content": template_create_body.content,
"is_enabled": template_create_body.is_enabled,
"type": template_type,
Expand All @@ -401,7 +490,13 @@ def create_property_specific_template_by_type(
data["properties"] = [
{"id": property_id} for property_id in template_create_body.properties
]
return MessagingTemplate.create(db=db, data=data)
try:
return MessagingTemplate.create(db=db, data=data)
except IntegrityError:
db.rollback()
raise MessagingTemplateValidationException(
f"A template with type '{template_type}' and label '{label}' already exists."
)


def delete_template_by_id(db: Session, template_id: str) -> None:
Expand Down Expand Up @@ -442,6 +537,7 @@ def get_default_template_by_type(
template = MessagingTemplateDefault(
is_enabled=False,
type=template_type,
label=default_template["label"],
content=default_template["content"],
)
return template
Expand Down Expand Up @@ -469,8 +565,9 @@ def save_defaults_for_all_messaging_template_types(
db=db, field="type", value=template_type
)
if not any_db_template_with_type:
data = {
data: dict[str, Any] = {
"content": default_template["content"],
"label": default_template["label"],
"is_enabled": False,
"type": template_type,
}
Expand Down
Loading
Loading