feat: implement email template system with vendor overrides
Add comprehensive email template management for both admin and vendors:
Admin Features:
- Email templates management page at /admin/email-templates
- Edit platform templates with language support (en, fr, de, lb)
- Preview templates with sample variables
- Send test emails
- View email logs per template
Vendor Features:
- Email templates customization page at /vendor/{code}/email-templates
- Override platform templates with vendor-specific versions
- Preview and test customized templates
- Revert to platform defaults
Technical Changes:
- Migration for vendor_email_templates table
- VendorEmailTemplate model with override management
- Enhanced EmailService with language resolution chain
(customer preferred -> vendor preferred -> platform default)
- Branding resolution (Wizamart default, removed for whitelabel)
- Platform-only template protection (billing templates)
- Admin and vendor API endpoints with full CRUD
- Updated seed script with billing and team templates
Files: 22 changed, ~3,900 lines added
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,15 @@ Email system database models.
|
||||
Provides:
|
||||
- EmailTemplate: Multi-language email templates stored in database
|
||||
- EmailLog: Email sending history and tracking
|
||||
|
||||
Platform vs Vendor Templates:
|
||||
- Platform templates (EmailTemplate) are the defaults
|
||||
- Vendors can override templates via VendorEmailTemplate
|
||||
- Platform-only templates (is_platform_only=True) cannot be overridden
|
||||
"""
|
||||
|
||||
import enum
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
@@ -16,11 +22,12 @@ from sqlalchemy import (
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.orm import Session, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
@@ -81,11 +88,20 @@ class EmailTemplate(Base, TimestampMixin):
|
||||
# e.g., ["first_name", "company_name", "login_url"]
|
||||
variables = Column(Text, nullable=True)
|
||||
|
||||
# Required variables (JSON list of variables that MUST be provided)
|
||||
# Subset of variables that are mandatory for the template to render
|
||||
required_variables = Column(Text, nullable=True)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Platform-only flag: if True, vendors cannot override this template
|
||||
# Used for billing, subscription, and other platform-level emails
|
||||
is_platform_only = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Unique constraint: one template per code+language
|
||||
__table_args__ = (
|
||||
Index("ix_email_templates_code_language", "code", "language", unique=True),
|
||||
{"sqlite_autoincrement": True},
|
||||
)
|
||||
|
||||
@@ -95,8 +111,6 @@ class EmailTemplate(Base, TimestampMixin):
|
||||
@property
|
||||
def variables_list(self) -> list[str]:
|
||||
"""Parse variables JSON to list."""
|
||||
import json
|
||||
|
||||
if not self.variables:
|
||||
return []
|
||||
try:
|
||||
@@ -104,6 +118,106 @@ class EmailTemplate(Base, TimestampMixin):
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
@property
|
||||
def required_variables_list(self) -> list[str]:
|
||||
"""Parse required_variables JSON to list."""
|
||||
if not self.required_variables:
|
||||
return []
|
||||
try:
|
||||
return json.loads(self.required_variables)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_by_code_and_language(
|
||||
cls,
|
||||
db: Session,
|
||||
code: str,
|
||||
language: str,
|
||||
fallback_to_english: bool = True,
|
||||
) -> "EmailTemplate | None":
|
||||
"""
|
||||
Get a platform template by code and language.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
code: Template code (e.g., "password_reset")
|
||||
language: Language code (en, fr, de, lb)
|
||||
fallback_to_english: If True, fall back to English if language not found
|
||||
|
||||
Returns:
|
||||
EmailTemplate if found, None otherwise
|
||||
"""
|
||||
template = (
|
||||
db.query(cls)
|
||||
.filter(
|
||||
cls.code == code,
|
||||
cls.language == language,
|
||||
cls.is_active == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Fallback to English if requested language not found
|
||||
if not template and fallback_to_english and language != "en":
|
||||
template = (
|
||||
db.query(cls)
|
||||
.filter(
|
||||
cls.code == code,
|
||||
cls.language == "en",
|
||||
cls.is_active == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
return template
|
||||
|
||||
@classmethod
|
||||
def get_all_templates(
|
||||
cls,
|
||||
db: Session,
|
||||
category: str | None = None,
|
||||
include_inactive: bool = False,
|
||||
) -> list["EmailTemplate"]:
|
||||
"""
|
||||
Get all platform templates, optionally filtered by category.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
category: Optional category filter
|
||||
include_inactive: Include inactive templates
|
||||
|
||||
Returns:
|
||||
List of EmailTemplate objects
|
||||
"""
|
||||
query = db.query(cls)
|
||||
|
||||
if category:
|
||||
query = query.filter(cls.category == category)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(cls.is_active == True) # noqa: E712
|
||||
|
||||
return query.order_by(cls.code, cls.language).all()
|
||||
|
||||
@classmethod
|
||||
def get_overridable_templates(cls, db: Session) -> list["EmailTemplate"]:
|
||||
"""
|
||||
Get all templates that vendors can override.
|
||||
|
||||
Returns:
|
||||
List of EmailTemplate objects where is_platform_only=False
|
||||
"""
|
||||
return (
|
||||
db.query(cls)
|
||||
.filter(
|
||||
cls.is_platform_only == False, # noqa: E712
|
||||
cls.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(cls.code, cls.language)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
class EmailLog(Base, TimestampMixin):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user