Files
orion/app/services/email_template_service.py
Samir Boulahtit 6df7167f80 fix: resolve JS-005, JS-006, SVC-006 architecture violations
- JS-005: Add initialization guards to email-templates.js (admin/vendor)
- JS-006: Add try/catch error handling to content-pages.js init
- SVC-006: Move db.commit() from services to endpoints for proper
  transaction control in email_template_service and vendor_team_service

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:00:10 +01:00

718 lines
23 KiB
Python

# app/services/email_template_service.py
"""
Email Template Service
Handles business logic for email template management:
- Platform template CRUD operations
- Vendor template override management
- Template preview and testing
- Email log queries
This service layer separates business logic from API endpoints
to follow the project's layered architecture.
"""
import logging
from dataclasses import dataclass
from typing import Any
from jinja2 import Template
from sqlalchemy.orm import Session
from app.exceptions.base import (
AuthorizationException,
ResourceNotFoundException,
ValidationException,
)
from models.database.email import EmailCategory, EmailLog, EmailTemplate
from models.database.vendor_email_template import VendorEmailTemplate
logger = logging.getLogger(__name__)
# Supported languages
SUPPORTED_LANGUAGES = ["en", "fr", "de", "lb"]
@dataclass
class TemplateData:
"""Template data container."""
code: str
language: str
name: str
description: str | None
category: str
subject: str
body_html: str
body_text: str | None
variables: list[str]
required_variables: list[str]
is_platform_only: bool
@dataclass
class VendorOverrideData:
"""Vendor override data container."""
code: str
language: str
subject: str
body_html: str
body_text: str | None
name: str | None
updated_at: str | None
class EmailTemplateService:
"""Service for managing email templates."""
def __init__(self, db: Session):
self.db = db
# =========================================================================
# ADMIN OPERATIONS
# =========================================================================
def list_platform_templates(self) -> list[dict[str, Any]]:
"""
List all platform email templates grouped by code.
Returns:
List of template summaries with language availability.
"""
templates = (
self.db.query(EmailTemplate)
.order_by(EmailTemplate.category, EmailTemplate.code)
.all()
)
# Group by code
grouped: dict[str, dict] = {}
for template in templates:
if template.code not in grouped:
grouped[template.code] = {
"code": template.code,
"name": template.name,
"category": template.category,
"description": template.description,
"is_platform_only": template.is_platform_only,
"languages": [],
"variables": [],
}
grouped[template.code]["languages"].append(template.language)
if template.variables and not grouped[template.code]["variables"]:
try:
import json
grouped[template.code]["variables"] = json.loads(template.variables)
except (json.JSONDecodeError, TypeError):
pass
return list(grouped.values())
def get_template_categories(self) -> list[str]:
"""Get list of all template categories."""
return [cat.value for cat in EmailCategory]
def get_platform_template(self, code: str) -> dict[str, Any]:
"""
Get a platform template with all language versions.
Args:
code: Template code
Returns:
Template details with all language versions
Raises:
NotFoundError: If template not found
"""
templates = (
self.db.query(EmailTemplate)
.filter(EmailTemplate.code == code)
.all()
)
if not templates:
raise ResourceNotFoundException(f"Template not found: {code}")
first = templates[0]
languages = {}
for t in templates:
languages[t.language] = {
"subject": t.subject,
"body_html": t.body_html,
"body_text": t.body_text,
}
return {
"code": code,
"name": first.name,
"description": first.description,
"category": first.category,
"is_platform_only": first.is_platform_only,
"variables": self._parse_variables(first.variables),
"required_variables": self._parse_required_variables(first.required_variables),
"languages": languages,
}
def get_platform_template_language(self, code: str, language: str) -> TemplateData:
"""
Get a specific language version of a platform template.
Args:
code: Template code
language: Language code
Returns:
Template data for the specific language
Raises:
NotFoundError: If template or language not found
"""
template = EmailTemplate.get_by_code_and_language(self.db, code, language)
if not template:
raise ResourceNotFoundException(f"Template not found: {code}/{language}")
return TemplateData(
code=template.code,
language=template.language,
name=template.name,
description=template.description,
category=template.category,
subject=template.subject,
body_html=template.body_html,
body_text=template.body_text,
variables=self._parse_variables(template.variables),
required_variables=self._parse_required_variables(template.required_variables),
is_platform_only=template.is_platform_only,
)
def update_platform_template(
self,
code: str,
language: str,
subject: str,
body_html: str,
body_text: str | None = None,
) -> None:
"""
Update a platform email template.
Args:
code: Template code
language: Language code
subject: New subject line
body_html: New HTML body
body_text: New plain text body (optional)
Raises:
NotFoundError: If template not found
ValidationError: If template syntax is invalid
"""
template = EmailTemplate.get_by_code_and_language(self.db, code, language)
if not template:
raise ResourceNotFoundException(f"Template not found: {code}/{language}")
# Validate Jinja2 syntax
self._validate_template_syntax(subject, body_html, body_text)
template.subject = subject
template.body_html = body_html
template.body_text = body_text
logger.info(f"Updated platform template: {code}/{language}")
def preview_template(
self,
code: str,
language: str,
variables: dict[str, Any],
) -> dict[str, str]:
"""
Preview a template with sample variables.
Args:
code: Template code
language: Language code
variables: Variables to render
Returns:
Rendered subject and body
Raises:
NotFoundError: If template not found
ValidationError: If rendering fails
"""
template = EmailTemplate.get_by_code_and_language(self.db, code, language)
if not template:
raise ResourceNotFoundException(f"Template not found: {code}/{language}")
try:
rendered_subject = Template(template.subject).render(variables)
rendered_html = Template(template.body_html).render(variables)
rendered_text = Template(template.body_text).render(variables) if template.body_text else None
except Exception as e:
raise ValidationException(f"Template rendering error: {str(e)}")
return {
"subject": rendered_subject,
"body_html": rendered_html,
"body_text": rendered_text,
}
def get_template_logs(
self,
code: str,
limit: int = 50,
offset: int = 0,
) -> tuple[list[dict], int]:
"""
Get email logs for a specific template.
Args:
code: Template code
limit: Max results
offset: Skip results
Returns:
Tuple of (logs list, total count)
"""
query = (
self.db.query(EmailLog)
.filter(EmailLog.template_code == code)
.order_by(EmailLog.created_at.desc())
)
total = query.count()
logs = query.offset(offset).limit(limit).all()
return (
[
{
"id": log.id,
"to_email": log.to_email,
"status": log.status,
"language": log.language,
"created_at": log.created_at.isoformat() if log.created_at else None,
"error_message": log.error_message,
}
for log in logs
],
total,
)
# =========================================================================
# VENDOR OPERATIONS
# =========================================================================
def list_overridable_templates(self, vendor_id: int) -> dict[str, Any]:
"""
List all templates that a vendor can customize.
Args:
vendor_id: Vendor ID
Returns:
Dict with templates list and supported languages
"""
# Get all overridable platform templates
platform_templates = EmailTemplate.get_overridable_templates(self.db)
# Get all vendor overrides
vendor_overrides = VendorEmailTemplate.get_all_overrides_for_vendor(
self.db, vendor_id
)
# Build override lookup
override_lookup = {}
for override in vendor_overrides:
key = (override.template_code, override.language)
override_lookup[key] = override
# Build response
templates = []
for template in platform_templates:
# Check which languages have overrides
override_languages = []
for lang in SUPPORTED_LANGUAGES:
if (template.code, lang) in override_lookup:
override_languages.append(lang)
templates.append({
"code": template.code,
"name": template.name,
"category": template.category,
"description": template.description,
"available_languages": self._get_template_languages(template.code),
"override_languages": override_languages,
"has_override": len(override_languages) > 0,
"variables": self._parse_required_variables(template.required_variables),
})
return {
"templates": templates,
"supported_languages": SUPPORTED_LANGUAGES,
}
def get_vendor_template(self, vendor_id: int, code: str) -> dict[str, Any]:
"""
Get a template with all language versions for a vendor.
Args:
vendor_id: Vendor ID
code: Template code
Returns:
Template details with vendor overrides status
Raises:
NotFoundError: If template not found
ForbiddenError: If template is platform-only
"""
# Get platform template
platform_template = (
self.db.query(EmailTemplate)
.filter(EmailTemplate.code == code)
.first()
)
if not platform_template:
raise ResourceNotFoundException(f"Template not found: {code}")
if platform_template.is_platform_only:
raise AuthorizationException("This is a platform-only template and cannot be customized")
# Get all language versions
platform_versions = (
self.db.query(EmailTemplate)
.filter(EmailTemplate.code == code)
.all()
)
# Get vendor overrides
vendor_overrides = (
self.db.query(VendorEmailTemplate)
.filter(
VendorEmailTemplate.vendor_id == vendor_id,
VendorEmailTemplate.template_code == code,
)
.all()
)
override_lookup = {v.language: v for v in vendor_overrides}
platform_lookup = {t.language: t for t in platform_versions}
# Build language versions
languages = {}
for lang in SUPPORTED_LANGUAGES:
platform_ver = platform_lookup.get(lang)
override_ver = override_lookup.get(lang)
languages[lang] = {
"has_platform_template": platform_ver is not None,
"has_vendor_override": override_ver is not None,
"platform": {
"subject": platform_ver.subject,
"body_html": platform_ver.body_html,
"body_text": platform_ver.body_text,
} if platform_ver else None,
"vendor_override": {
"subject": override_ver.subject,
"body_html": override_ver.body_html,
"body_text": override_ver.body_text,
"name": override_ver.name,
"updated_at": override_ver.updated_at.isoformat() if override_ver else None,
} if override_ver else None,
}
return {
"code": code,
"name": platform_template.name,
"category": platform_template.category,
"description": platform_template.description,
"variables": self._parse_required_variables(platform_template.required_variables),
"languages": languages,
}
def get_vendor_template_language(
self,
vendor_id: int,
code: str,
language: str,
) -> dict[str, Any]:
"""
Get a specific language version for a vendor (override or platform).
Args:
vendor_id: Vendor ID
code: Template code
language: Language code
Returns:
Template data with source indicator
Raises:
NotFoundError: If template not found
ForbiddenError: If template is platform-only
ValidationError: If language not supported
"""
if language not in SUPPORTED_LANGUAGES:
raise ValidationException(f"Unsupported language: {language}")
# Check if template is overridable
platform_template = (
self.db.query(EmailTemplate)
.filter(EmailTemplate.code == code)
.first()
)
if not platform_template:
raise ResourceNotFoundException(f"Template not found: {code}")
if platform_template.is_platform_only:
raise AuthorizationException("This is a platform-only template and cannot be customized")
# Check for vendor override
vendor_override = VendorEmailTemplate.get_override(
self.db, vendor_id, code, language
)
# Get platform version
platform_version = EmailTemplate.get_by_code_and_language(
self.db, code, language
)
if vendor_override:
return {
"code": code,
"language": language,
"source": "vendor_override",
"subject": vendor_override.subject,
"body_html": vendor_override.body_html,
"body_text": vendor_override.body_text,
"name": vendor_override.name,
"variables": self._parse_required_variables(platform_template.required_variables),
"platform_template": {
"subject": platform_version.subject,
"body_html": platform_version.body_html,
} if platform_version else None,
}
elif platform_version:
return {
"code": code,
"language": language,
"source": "platform",
"subject": platform_version.subject,
"body_html": platform_version.body_html,
"body_text": platform_version.body_text,
"name": platform_version.name,
"variables": self._parse_required_variables(platform_template.required_variables),
"platform_template": None,
}
else:
raise ResourceNotFoundException(f"No template found for language: {language}")
def create_or_update_vendor_override(
self,
vendor_id: int,
code: str,
language: str,
subject: str,
body_html: str,
body_text: str | None = None,
name: str | None = None,
) -> dict[str, Any]:
"""
Create or update a vendor template override.
Args:
vendor_id: Vendor ID
code: Template code
language: Language code
subject: Custom subject
body_html: Custom HTML body
body_text: Custom plain text body
name: Custom template name
Returns:
Result with is_new indicator
Raises:
NotFoundError: If template not found
ForbiddenError: If template is platform-only
ValidationError: If syntax invalid or language not supported
"""
if language not in SUPPORTED_LANGUAGES:
raise ValidationException(f"Unsupported language: {language}")
# Check if template exists and is overridable
platform_template = (
self.db.query(EmailTemplate)
.filter(EmailTemplate.code == code)
.first()
)
if not platform_template:
raise ResourceNotFoundException(f"Template not found: {code}")
if platform_template.is_platform_only:
raise AuthorizationException("This is a platform-only template and cannot be customized")
# Validate template syntax
self._validate_template_syntax(subject, body_html, body_text)
# Create or update
override = VendorEmailTemplate.create_or_update(
db=self.db,
vendor_id=vendor_id,
template_code=code,
language=language,
subject=subject,
body_html=body_html,
body_text=body_text,
name=name,
)
logger.info(f"Vendor {vendor_id} updated template override: {code}/{language}")
return {
"message": "Template override saved",
"code": code,
"language": language,
"is_new": override.created_at == override.updated_at,
}
def delete_vendor_override(
self,
vendor_id: int,
code: str,
language: str,
) -> None:
"""
Delete a vendor template override.
Args:
vendor_id: Vendor ID
code: Template code
language: Language code
Raises:
NotFoundError: If override not found
ValidationError: If language not supported
"""
if language not in SUPPORTED_LANGUAGES:
raise ValidationException(f"Unsupported language: {language}")
deleted = VendorEmailTemplate.delete_override(
self.db, vendor_id, code, language
)
if not deleted:
raise ResourceNotFoundException("No override found for this template and language")
logger.info(f"Vendor {vendor_id} deleted template override: {code}/{language}")
def preview_vendor_template(
self,
vendor_id: int,
code: str,
language: str,
variables: dict[str, Any],
) -> dict[str, Any]:
"""
Preview a vendor template (override or platform).
Args:
vendor_id: Vendor ID
code: Template code
language: Language code
variables: Variables to render
Returns:
Rendered template with source indicator
Raises:
NotFoundError: If template not found
ValidationError: If rendering fails
"""
# Get template content
vendor_override = VendorEmailTemplate.get_override(
self.db, vendor_id, code, language
)
platform_version = EmailTemplate.get_by_code_and_language(
self.db, code, language
)
if vendor_override:
subject = vendor_override.subject
body_html = vendor_override.body_html
body_text = vendor_override.body_text
source = "vendor_override"
elif platform_version:
subject = platform_version.subject
body_html = platform_version.body_html
body_text = platform_version.body_text
source = "platform"
else:
raise ResourceNotFoundException(f"No template found for language: {language}")
try:
rendered_subject = Template(subject).render(variables)
rendered_html = Template(body_html).render(variables)
rendered_text = Template(body_text).render(variables) if body_text else None
except Exception as e:
raise ValidationException(f"Template rendering error: {str(e)}")
return {
"source": source,
"language": language,
"subject": rendered_subject,
"body_html": rendered_html,
"body_text": rendered_text,
}
# =========================================================================
# HELPER METHODS
# =========================================================================
def _validate_template_syntax(
self,
subject: str,
body_html: str,
body_text: str | None,
) -> None:
"""Validate Jinja2 template syntax."""
try:
Template(subject).render({})
Template(body_html).render({})
if body_text:
Template(body_text).render({})
except Exception as e:
raise ValidationException(f"Invalid template syntax: {str(e)}")
def _parse_variables(self, variables_json: str | None) -> list[str]:
"""Parse variables JSON string to list."""
if not variables_json:
return []
try:
import json
return json.loads(variables_json)
except (json.JSONDecodeError, TypeError):
return []
def _parse_required_variables(self, required_vars: str | None) -> list[str]:
"""Parse required variables comma-separated string to list."""
if not required_vars:
return []
return [v.strip() for v in required_vars.split(",") if v.strip()]
def _get_template_languages(self, code: str) -> list[str]:
"""Get list of languages available for a template."""
templates = (
self.db.query(EmailTemplate.language)
.filter(EmailTemplate.code == code)
.all()
)
return [t.language for t in templates]