- Fix loyalty & monitoring router bugs (_get_router → named routers) - Implement team invitation email with send_template + seed templates (en/fr/de) - Add SecurityHeadersMiddleware (nosniff, HSTS, referrer-policy, permissions-policy) - Build email audit admin page: service, schemas, API, page route, menu, i18n, HTML, JS - Clean stale TODO in platform-menu-config.js - Add 67 tests (unit + integration) covering all new functionality Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
319 lines
9.7 KiB
Python
319 lines
9.7 KiB
Python
# app/modules/messaging/schemas/email.py
|
|
"""
|
|
Email template Pydantic schemas for API responses and requests.
|
|
|
|
Provides schemas for:
|
|
- EmailTemplate: Platform email templates
|
|
- StoreEmailTemplate: Store-specific template overrides
|
|
"""
|
|
|
|
from datetime import datetime
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
|
|
class EmailTemplateBase(BaseModel):
|
|
"""Base schema for email templates."""
|
|
|
|
code: str = Field(..., description="Template code (e.g., 'password_reset')")
|
|
language: str = Field(default="en", description="Language code (en, fr, de, lb)")
|
|
name: str = Field(..., description="Human-readable template name")
|
|
description: str | None = Field(None, description="Template description")
|
|
category: str = Field(..., description="Template category (auth, orders, billing, etc.)")
|
|
subject: str = Field(..., description="Email subject (supports Jinja2 variables)")
|
|
body_html: str = Field(..., description="HTML email body")
|
|
body_text: str | None = Field(None, description="Plain text fallback")
|
|
variables: list[str] = Field(default_factory=list, description="Available variables")
|
|
|
|
|
|
class EmailTemplateCreate(EmailTemplateBase):
|
|
"""Schema for creating an email template."""
|
|
|
|
required_variables: list[str] = Field(
|
|
default_factory=list, description="Required variables"
|
|
)
|
|
is_platform_only: bool = Field(
|
|
default=False, description="Cannot be overridden by stores"
|
|
)
|
|
|
|
|
|
class EmailTemplateUpdate(BaseModel):
|
|
"""Schema for updating an email template."""
|
|
|
|
name: str | None = Field(None, description="Human-readable template name")
|
|
description: str | None = Field(None, description="Template description")
|
|
subject: str | None = Field(None, description="Email subject")
|
|
body_html: str | None = Field(None, description="HTML email body")
|
|
body_text: str | None = Field(None, description="Plain text fallback")
|
|
variables: list[str] | None = Field(None, description="Available variables")
|
|
required_variables: list[str] | None = Field(None, description="Required variables")
|
|
is_active: bool | None = Field(None, description="Template active status")
|
|
|
|
|
|
class EmailTemplateResponse(BaseModel):
|
|
"""Schema for email template API response."""
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: int
|
|
code: str
|
|
language: str
|
|
name: str
|
|
description: str | None
|
|
category: str
|
|
subject: str
|
|
body_html: str
|
|
body_text: str | None
|
|
variables: list[str] = Field(default_factory=list)
|
|
required_variables: list[str] = Field(default_factory=list)
|
|
is_active: bool
|
|
is_platform_only: bool
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
@classmethod
|
|
def from_db(cls, template) -> "EmailTemplateResponse":
|
|
"""Create response from database model."""
|
|
return cls(
|
|
id=template.id,
|
|
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=template.variables_list,
|
|
required_variables=template.required_variables_list,
|
|
is_active=template.is_active,
|
|
is_platform_only=template.is_platform_only,
|
|
created_at=template.created_at,
|
|
updated_at=template.updated_at,
|
|
)
|
|
|
|
|
|
class EmailTemplateSummary(BaseModel):
|
|
"""Summary schema for template list views."""
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: int
|
|
code: str
|
|
name: str
|
|
category: str
|
|
languages: list[str] = Field(default_factory=list)
|
|
is_platform_only: bool
|
|
is_active: bool
|
|
|
|
@classmethod
|
|
def from_db_list(cls, templates: list) -> list["EmailTemplateSummary"]:
|
|
"""
|
|
Create summaries from database models, grouping by code.
|
|
|
|
Args:
|
|
templates: List of EmailTemplate models
|
|
|
|
Returns:
|
|
List of EmailTemplateSummary grouped by template code
|
|
"""
|
|
# Group templates by code
|
|
by_code: dict[str, list] = {}
|
|
for t in templates:
|
|
if t.code not in by_code:
|
|
by_code[t.code] = []
|
|
by_code[t.code].append(t)
|
|
|
|
summaries = []
|
|
for code, group in by_code.items():
|
|
first = group[0]
|
|
summaries.append(
|
|
cls(
|
|
id=first.id,
|
|
code=code,
|
|
name=first.name,
|
|
category=first.category,
|
|
languages=[t.language for t in group],
|
|
is_platform_only=first.is_platform_only,
|
|
is_active=first.is_active,
|
|
)
|
|
)
|
|
|
|
return summaries
|
|
|
|
|
|
# Store Email Template Schemas
|
|
|
|
|
|
class StoreEmailTemplateCreate(BaseModel):
|
|
"""Schema for creating a store email template override."""
|
|
|
|
template_code: str = Field(..., description="Template code to override")
|
|
language: str = Field(default="en", description="Language code")
|
|
name: str | None = Field(None, description="Custom name (uses platform default if None)")
|
|
subject: str = Field(..., description="Custom email subject")
|
|
body_html: str = Field(..., description="Custom HTML body")
|
|
body_text: str | None = Field(None, description="Custom plain text body")
|
|
|
|
|
|
class StoreEmailTemplateUpdate(BaseModel):
|
|
"""Schema for updating a store email template override."""
|
|
|
|
name: str | None = Field(None, description="Custom name")
|
|
subject: str | None = Field(None, description="Custom email subject")
|
|
body_html: str | None = Field(None, description="Custom HTML body")
|
|
body_text: str | None = Field(None, description="Custom plain text body")
|
|
is_active: bool | None = Field(None, description="Override active status")
|
|
|
|
|
|
class StoreEmailTemplateResponse(BaseModel):
|
|
"""Schema for store email template override API response."""
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: int
|
|
store_id: int
|
|
template_code: str
|
|
language: str
|
|
name: str | None
|
|
subject: str
|
|
body_html: str
|
|
body_text: str | None
|
|
is_active: bool
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
class EmailTemplateWithOverrideStatus(BaseModel):
|
|
"""
|
|
Schema showing template with store override status.
|
|
|
|
Used in store UI to show which templates have been customized.
|
|
"""
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
code: str
|
|
name: str
|
|
category: str
|
|
languages: list[str]
|
|
is_platform_only: bool
|
|
has_override: bool = Field(
|
|
default=False, description="Whether store has customized this template"
|
|
)
|
|
override_languages: list[str] = Field(
|
|
default_factory=list,
|
|
description="Languages with store overrides",
|
|
)
|
|
|
|
|
|
# Email Preview/Test Schemas
|
|
|
|
|
|
class EmailPreviewRequest(BaseModel):
|
|
"""Schema for requesting an email preview."""
|
|
|
|
template_code: str = Field(..., description="Template code")
|
|
language: str = Field(default="en", description="Language code")
|
|
variables: dict[str, str] = Field(
|
|
default_factory=dict, description="Variables to inject"
|
|
)
|
|
|
|
|
|
class EmailPreviewResponse(BaseModel):
|
|
"""Schema for email preview response."""
|
|
|
|
subject: str
|
|
body_html: str
|
|
body_text: str | None
|
|
|
|
|
|
class EmailTestRequest(BaseModel):
|
|
"""Schema for sending a test email."""
|
|
|
|
template_code: str = Field(..., description="Template code")
|
|
language: str = Field(default="en", description="Language code")
|
|
to_email: str = Field(..., description="Recipient email address")
|
|
variables: dict[str, str] = Field(
|
|
default_factory=dict, description="Variables to inject"
|
|
)
|
|
|
|
|
|
class EmailTestResponse(BaseModel):
|
|
"""Schema for test email response."""
|
|
|
|
success: bool
|
|
message: str
|
|
email_log_id: int | None = None
|
|
|
|
|
|
# =============================================================================
|
|
# Email Log (Audit) Schemas
|
|
# =============================================================================
|
|
|
|
|
|
class EmailLogListItem(BaseModel):
|
|
"""Compact email log item (no body content)."""
|
|
|
|
id: int
|
|
recipient_email: str
|
|
recipient_name: str | None = None
|
|
subject: str
|
|
status: str
|
|
template_code: str | None = None
|
|
provider: str | None = None
|
|
store_id: int | None = None
|
|
related_type: str | None = None
|
|
related_id: int | None = None
|
|
created_at: str | None = None
|
|
sent_at: str | None = None
|
|
error_message: str | None = None
|
|
|
|
|
|
class EmailLogDetail(BaseModel):
|
|
"""Full email log detail including body content."""
|
|
|
|
id: int
|
|
recipient_email: str
|
|
recipient_name: str | None = None
|
|
subject: str
|
|
status: str
|
|
template_code: str | None = None
|
|
provider: str | None = None
|
|
store_id: int | None = None
|
|
user_id: int | None = None
|
|
related_type: str | None = None
|
|
related_id: int | None = None
|
|
from_email: str | None = None
|
|
from_name: str | None = None
|
|
reply_to: str | None = None
|
|
body_html: str | None = None
|
|
body_text: str | None = None
|
|
error_message: str | None = None
|
|
retry_count: int = 0
|
|
provider_message_id: str | None = None
|
|
created_at: str | None = None
|
|
sent_at: str | None = None
|
|
delivered_at: str | None = None
|
|
opened_at: str | None = None
|
|
clicked_at: str | None = None
|
|
extra_data: str | None = None
|
|
|
|
|
|
class EmailLogListResponse(BaseModel):
|
|
"""Paginated email log list."""
|
|
|
|
items: list[EmailLogListItem]
|
|
total: int
|
|
page: int
|
|
per_page: int
|
|
total_pages: int
|
|
|
|
|
|
class EmailLogStatsResponse(BaseModel):
|
|
"""Email log statistics."""
|
|
|
|
by_status: dict[str, int] = Field(default_factory=dict)
|
|
by_template: dict[str, int] = Field(default_factory=dict)
|
|
total: int = 0
|