refactor: complete module-driven architecture migration
This commit completes the migration to a fully module-driven architecture: ## Models Migration - Moved all domain models from models/database/ to their respective modules: - tenancy: User, Admin, Vendor, Company, Platform, VendorDomain, etc. - cms: MediaFile, VendorTheme - messaging: Email, VendorEmailSettings, VendorEmailTemplate - core: AdminMenuConfig - models/database/ now only contains Base and TimestampMixin (infrastructure) ## Schemas Migration - Moved all domain schemas from models/schema/ to their respective modules: - tenancy: company, vendor, admin, team, vendor_domain - cms: media, image, vendor_theme - messaging: email - models/schema/ now only contains base.py and auth.py (infrastructure) ## Routes Migration - Moved admin routes from app/api/v1/admin/ to modules: - menu_config.py -> core module - modules.py -> tenancy module - module_config.py -> tenancy module - app/api/v1/admin/ now only aggregates auto-discovered module routes ## Menu System - Implemented module-driven menu system with MenuDiscoveryService - Extended FrontendType enum: PLATFORM, ADMIN, VENDOR, STOREFRONT - Added MenuItemDefinition and MenuSectionDefinition dataclasses - Each module now defines its own menu items in definition.py - MenuService integrates with MenuDiscoveryService for template rendering ## Documentation - Updated docs/architecture/models-structure.md - Updated docs/architecture/menu-management.md - Updated architecture validation rules for new exceptions ## Architecture Validation - Updated MOD-019 rule to allow base.py in models/schema/ - Created core module exceptions.py and schemas/ directory - All validation errors resolved (only warnings remain) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,8 @@ Defines the messaging module including its features, menu items,
|
||||
route configurations, and self-contained module settings.
|
||||
"""
|
||||
|
||||
from app.modules.base import ModuleDefinition
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
|
||||
def _get_admin_router():
|
||||
@@ -47,6 +47,95 @@ messaging_module = ModuleDefinition(
|
||||
"notifications", # Vendor notifications
|
||||
],
|
||||
},
|
||||
# New module-driven menu definitions
|
||||
menus={
|
||||
FrontendType.ADMIN: [
|
||||
MenuSectionDefinition(
|
||||
id="platformAdmin",
|
||||
label_key="messaging.menu.platform_admin",
|
||||
icon="chat-bubble-left-right",
|
||||
order=20,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="messages",
|
||||
label_key="messaging.menu.messages",
|
||||
icon="chat-bubble-left-right",
|
||||
route="/admin/messages",
|
||||
order=30,
|
||||
),
|
||||
],
|
||||
),
|
||||
MenuSectionDefinition(
|
||||
id="monitoring",
|
||||
label_key="messaging.menu.platform_monitoring",
|
||||
icon="bell",
|
||||
order=80,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="notifications",
|
||||
label_key="messaging.menu.notifications",
|
||||
icon="bell",
|
||||
route="/admin/notifications",
|
||||
order=40,
|
||||
),
|
||||
],
|
||||
),
|
||||
MenuSectionDefinition(
|
||||
id="settings",
|
||||
label_key="messaging.menu.platform_settings",
|
||||
icon="mail",
|
||||
order=900,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="email-templates",
|
||||
label_key="messaging.menu.email_templates",
|
||||
icon="mail",
|
||||
route="/admin/email-templates",
|
||||
order=20,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
FrontendType.VENDOR: [
|
||||
MenuSectionDefinition(
|
||||
id="customers",
|
||||
label_key="messaging.menu.customers",
|
||||
icon="chat-bubble-left-right",
|
||||
order=30,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="messages",
|
||||
label_key="messaging.menu.messages",
|
||||
icon="chat-bubble-left-right",
|
||||
route="/vendor/{vendor_code}/messages",
|
||||
order=20,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="notifications",
|
||||
label_key="messaging.menu.notifications",
|
||||
icon="bell",
|
||||
route="/vendor/{vendor_code}/notifications",
|
||||
order=30,
|
||||
),
|
||||
],
|
||||
),
|
||||
MenuSectionDefinition(
|
||||
id="account",
|
||||
label_key="messaging.menu.account_settings",
|
||||
icon="mail",
|
||||
order=900,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="email-templates",
|
||||
label_key="messaging.menu.email_templates",
|
||||
icon="mail",
|
||||
route="/vendor/{vendor_code}/email-templates",
|
||||
order=40,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
is_core=False,
|
||||
# =========================================================================
|
||||
# Self-Contained Module Configuration
|
||||
|
||||
@@ -1 +1,40 @@
|
||||
{}
|
||||
{
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"mark_read": "Als gelesen markieren",
|
||||
"mark_all_read": "Alle als gelesen markieren",
|
||||
"no_notifications": "Keine Benachrichtigungen",
|
||||
"new_order": "Neue Bestellung",
|
||||
"order_updated": "Bestellung aktualisiert",
|
||||
"low_stock": "Warnung bei geringem Bestand",
|
||||
"import_complete": "Import abgeschlossen",
|
||||
"import_failed": "Import fehlgeschlagen"
|
||||
},
|
||||
"messages": {
|
||||
"failed_to_load_template": "Failed to load template",
|
||||
"template_saved_successfully": "Template saved successfully",
|
||||
"reverted_to_platform_default": "Reverted to platform default",
|
||||
"failed_to_load_preview": "Failed to load preview",
|
||||
"failed_to_send_test_email": "Failed to send test email",
|
||||
"failed_to_load_conversations": "Failed to load conversations",
|
||||
"failed_to_load_conversation": "Failed to load conversation",
|
||||
"conversation_closed": "Conversation closed",
|
||||
"failed_to_close_conversation": "Failed to close conversation",
|
||||
"conversation_reopened": "Conversation reopened",
|
||||
"failed_to_reopen_conversation": "Failed to reopen conversation",
|
||||
"conversation_created": "Conversation created",
|
||||
"notification_marked_as_read": "Notification marked as read",
|
||||
"all_notifications_marked_as_read": "All notifications marked as read",
|
||||
"notification_deleted": "Notification deleted",
|
||||
"notification_settings_saved": "Notification settings saved",
|
||||
"failed_to_load_templates": "Failed to load templates",
|
||||
"failed_to_load_recipients": "Failed to load recipients",
|
||||
"failed_to_load_notifications": "Failed to load notifications",
|
||||
"failed_to_mark_notification_as_read": "Failed to mark notification as read",
|
||||
"failed_to_mark_all_as_read": "Failed to mark all as read",
|
||||
"failed_to_delete_notification": "Failed to delete notification",
|
||||
"failed_to_load_alerts": "Failed to load alerts",
|
||||
"alert_resolved_successfully": "Alert resolved successfully",
|
||||
"failed_to_resolve_alert": "Failed to resolve alert"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,40 @@
|
||||
{}
|
||||
{
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"mark_read": "Marquer comme lu",
|
||||
"mark_all_read": "Tout marquer comme lu",
|
||||
"no_notifications": "Aucune notification",
|
||||
"new_order": "Nouvelle commande",
|
||||
"order_updated": "Commande mise à jour",
|
||||
"low_stock": "Alerte stock faible",
|
||||
"import_complete": "Importation terminée",
|
||||
"import_failed": "Échec de l'importation"
|
||||
},
|
||||
"messages": {
|
||||
"failed_to_load_template": "Failed to load template",
|
||||
"template_saved_successfully": "Template saved successfully",
|
||||
"reverted_to_platform_default": "Reverted to platform default",
|
||||
"failed_to_load_preview": "Failed to load preview",
|
||||
"failed_to_send_test_email": "Failed to send test email",
|
||||
"failed_to_load_conversations": "Failed to load conversations",
|
||||
"failed_to_load_conversation": "Failed to load conversation",
|
||||
"conversation_closed": "Conversation closed",
|
||||
"failed_to_close_conversation": "Failed to close conversation",
|
||||
"conversation_reopened": "Conversation reopened",
|
||||
"failed_to_reopen_conversation": "Failed to reopen conversation",
|
||||
"conversation_created": "Conversation created",
|
||||
"notification_marked_as_read": "Notification marked as read",
|
||||
"all_notifications_marked_as_read": "All notifications marked as read",
|
||||
"notification_deleted": "Notification deleted",
|
||||
"notification_settings_saved": "Notification settings saved",
|
||||
"failed_to_load_templates": "Failed to load templates",
|
||||
"failed_to_load_recipients": "Failed to load recipients",
|
||||
"failed_to_load_notifications": "Failed to load notifications",
|
||||
"failed_to_mark_notification_as_read": "Failed to mark notification as read",
|
||||
"failed_to_mark_all_as_read": "Failed to mark all as read",
|
||||
"failed_to_delete_notification": "Failed to delete notification",
|
||||
"failed_to_load_alerts": "Failed to load alerts",
|
||||
"alert_resolved_successfully": "Alert resolved successfully",
|
||||
"failed_to_resolve_alert": "Failed to resolve alert"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,40 @@
|
||||
{}
|
||||
{
|
||||
"notifications": {
|
||||
"title": "Notifikatiounen",
|
||||
"mark_read": "Als gelies markéieren",
|
||||
"mark_all_read": "Alles als gelies markéieren",
|
||||
"no_notifications": "Keng Notifikatiounen",
|
||||
"new_order": "Nei Bestellung",
|
||||
"order_updated": "Bestellung aktualiséiert",
|
||||
"low_stock": "Niddreg Lager Alarm",
|
||||
"import_complete": "Import fäerdeg",
|
||||
"import_failed": "Import feelgeschloen"
|
||||
},
|
||||
"messages": {
|
||||
"failed_to_load_template": "Failed to load template",
|
||||
"template_saved_successfully": "Template saved successfully",
|
||||
"reverted_to_platform_default": "Reverted to platform default",
|
||||
"failed_to_load_preview": "Failed to load preview",
|
||||
"failed_to_send_test_email": "Failed to send test email",
|
||||
"failed_to_load_conversations": "Failed to load conversations",
|
||||
"failed_to_load_conversation": "Failed to load conversation",
|
||||
"conversation_closed": "Conversation closed",
|
||||
"failed_to_close_conversation": "Failed to close conversation",
|
||||
"conversation_reopened": "Conversation reopened",
|
||||
"failed_to_reopen_conversation": "Failed to reopen conversation",
|
||||
"conversation_created": "Conversation created",
|
||||
"notification_marked_as_read": "Notification marked as read",
|
||||
"all_notifications_marked_as_read": "All notifications marked as read",
|
||||
"notification_deleted": "Notification deleted",
|
||||
"notification_settings_saved": "Notification settings saved",
|
||||
"failed_to_load_templates": "Failed to load templates",
|
||||
"failed_to_load_recipients": "Failed to load recipients",
|
||||
"failed_to_load_notifications": "Failed to load notifications",
|
||||
"failed_to_mark_notification_as_read": "Failed to mark notification as read",
|
||||
"failed_to_mark_all_as_read": "Failed to mark all as read",
|
||||
"failed_to_delete_notification": "Failed to delete notification",
|
||||
"failed_to_load_alerts": "Failed to load alerts",
|
||||
"alert_resolved_successfully": "Alert resolved successfully",
|
||||
"failed_to_resolve_alert": "Failed to resolve alert"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
"""
|
||||
Messaging module database models.
|
||||
|
||||
This module contains the canonical implementations of messaging-related models.
|
||||
This module contains the canonical implementations of messaging-related models:
|
||||
- Message, Conversation: In-app messaging
|
||||
- AdminNotification: Admin notifications
|
||||
- Email templates and settings: Email system
|
||||
"""
|
||||
|
||||
from app.modules.messaging.models.message import (
|
||||
@@ -14,13 +17,37 @@ from app.modules.messaging.models.message import (
|
||||
ParticipantType,
|
||||
)
|
||||
from app.modules.messaging.models.admin_notification import AdminNotification
|
||||
from app.modules.messaging.models.email import (
|
||||
EmailCategory,
|
||||
EmailLog,
|
||||
EmailStatus,
|
||||
EmailTemplate,
|
||||
)
|
||||
from app.modules.messaging.models.vendor_email_settings import (
|
||||
EmailProvider,
|
||||
PREMIUM_EMAIL_PROVIDERS,
|
||||
VendorEmailSettings,
|
||||
)
|
||||
from app.modules.messaging.models.vendor_email_template import VendorEmailTemplate
|
||||
|
||||
__all__ = [
|
||||
# Conversations and messages
|
||||
"Conversation",
|
||||
"ConversationParticipant",
|
||||
"ConversationType",
|
||||
"Message",
|
||||
"MessageAttachment",
|
||||
"ParticipantType",
|
||||
# Admin notifications
|
||||
"AdminNotification",
|
||||
# Email templates
|
||||
"EmailCategory",
|
||||
"EmailLog",
|
||||
"EmailStatus",
|
||||
"EmailTemplate",
|
||||
# Vendor email settings
|
||||
"EmailProvider",
|
||||
"PREMIUM_EMAIL_PROVIDERS",
|
||||
"VendorEmailSettings",
|
||||
"VendorEmailTemplate",
|
||||
]
|
||||
|
||||
307
app/modules/messaging/models/email.py
Normal file
307
app/modules/messaging/models/email.py
Normal file
@@ -0,0 +1,307 @@
|
||||
# app/modules/messaging/models/email.py
|
||||
"""
|
||||
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 (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Session, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class EmailCategory(str, enum.Enum):
|
||||
"""Email template categories."""
|
||||
|
||||
AUTH = "auth" # signup, password reset, verification
|
||||
ORDERS = "orders" # order confirmations, shipping
|
||||
BILLING = "billing" # invoices, payment failures
|
||||
SYSTEM = "system" # team invites, notifications
|
||||
MARKETING = "marketing" # newsletters, promotions
|
||||
|
||||
|
||||
class EmailStatus(str, enum.Enum):
|
||||
"""Email sending status."""
|
||||
|
||||
PENDING = "pending"
|
||||
SENT = "sent"
|
||||
FAILED = "failed"
|
||||
BOUNCED = "bounced"
|
||||
DELIVERED = "delivered"
|
||||
OPENED = "opened"
|
||||
CLICKED = "clicked"
|
||||
|
||||
|
||||
class EmailTemplate(Base, TimestampMixin):
|
||||
"""
|
||||
Multi-language email templates.
|
||||
|
||||
Templates use Jinja2 syntax for variable interpolation.
|
||||
Each template can have multiple language versions.
|
||||
"""
|
||||
|
||||
__tablename__ = "email_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Template identification
|
||||
code = Column(String(100), nullable=False, index=True) # e.g., "signup_welcome"
|
||||
language = Column(String(5), nullable=False, default="en") # e.g., "en", "fr", "de", "lb"
|
||||
|
||||
# Template metadata
|
||||
name = Column(String(255), nullable=False) # Human-readable name
|
||||
description = Column(Text, nullable=True) # Template purpose description
|
||||
category = Column(
|
||||
String(50), default=EmailCategory.SYSTEM.value, nullable=False, index=True
|
||||
)
|
||||
|
||||
# Email content
|
||||
subject = Column(String(500), nullable=False) # Subject line (supports variables)
|
||||
body_html = Column(Text, nullable=False) # HTML body
|
||||
body_text = Column(Text, nullable=True) # Plain text fallback
|
||||
|
||||
# Template variables (JSON list of expected variables)
|
||||
# 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},
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmailTemplate(code='{self.code}', language='{self.language}')>"
|
||||
|
||||
@property
|
||||
def variables_list(self) -> list[str]:
|
||||
"""Parse variables JSON to list."""
|
||||
if not self.variables:
|
||||
return []
|
||||
try:
|
||||
return json.loads(self.variables)
|
||||
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):
|
||||
"""
|
||||
Email sending history and tracking.
|
||||
|
||||
Logs all sent emails for debugging, analytics, and compliance.
|
||||
"""
|
||||
|
||||
__tablename__ = "email_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Template reference
|
||||
template_code = Column(String(100), nullable=True, index=True)
|
||||
template_id = Column(Integer, ForeignKey("email_templates.id"), nullable=True)
|
||||
|
||||
# Recipient info
|
||||
recipient_email = Column(String(255), nullable=False, index=True)
|
||||
recipient_name = Column(String(255), nullable=True)
|
||||
|
||||
# Email content (snapshot at send time)
|
||||
subject = Column(String(500), nullable=False)
|
||||
body_html = Column(Text, nullable=True)
|
||||
body_text = Column(Text, nullable=True)
|
||||
|
||||
# Sending info
|
||||
from_email = Column(String(255), nullable=False)
|
||||
from_name = Column(String(255), nullable=True)
|
||||
reply_to = Column(String(255), nullable=True)
|
||||
|
||||
# Status tracking
|
||||
status = Column(
|
||||
String(20), default=EmailStatus.PENDING.value, nullable=False, index=True
|
||||
)
|
||||
sent_at = Column(DateTime, nullable=True)
|
||||
delivered_at = Column(DateTime, nullable=True)
|
||||
opened_at = Column(DateTime, nullable=True)
|
||||
clicked_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Error handling
|
||||
error_message = Column(Text, nullable=True)
|
||||
retry_count = Column(Integer, default=0, nullable=False)
|
||||
|
||||
# Provider info
|
||||
provider = Column(String(50), nullable=True) # smtp, sendgrid, mailgun, ses
|
||||
provider_message_id = Column(String(255), nullable=True, index=True)
|
||||
|
||||
# Context linking (optional - link to related entities)
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
related_type = Column(String(50), nullable=True) # e.g., "order", "subscription"
|
||||
related_id = Column(Integer, nullable=True)
|
||||
|
||||
# Extra data (JSON for additional context)
|
||||
extra_data = Column(Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
template = relationship("EmailTemplate", foreign_keys=[template_id])
|
||||
vendor = relationship("Vendor", foreign_keys=[vendor_id])
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmailLog(id={self.id}, recipient='{self.recipient_email}', status='{self.status}')>"
|
||||
|
||||
def mark_sent(self, provider_message_id: str | None = None):
|
||||
"""Mark email as sent."""
|
||||
self.status = EmailStatus.SENT.value
|
||||
self.sent_at = datetime.utcnow()
|
||||
if provider_message_id:
|
||||
self.provider_message_id = provider_message_id
|
||||
|
||||
def mark_failed(self, error_message: str):
|
||||
"""Mark email as failed."""
|
||||
self.status = EmailStatus.FAILED.value
|
||||
self.error_message = error_message
|
||||
self.retry_count += 1
|
||||
|
||||
def mark_delivered(self):
|
||||
"""Mark email as delivered."""
|
||||
self.status = EmailStatus.DELIVERED.value
|
||||
self.delivered_at = datetime.utcnow()
|
||||
|
||||
def mark_opened(self):
|
||||
"""Mark email as opened."""
|
||||
self.status = EmailStatus.OPENED.value
|
||||
self.opened_at = datetime.utcnow()
|
||||
|
||||
|
||||
__all__ = ["EmailCategory", "EmailStatus", "EmailTemplate", "EmailLog"]
|
||||
258
app/modules/messaging/models/vendor_email_settings.py
Normal file
258
app/modules/messaging/models/vendor_email_settings.py
Normal file
@@ -0,0 +1,258 @@
|
||||
# app/modules/messaging/models/vendor_email_settings.py
|
||||
"""
|
||||
Vendor Email Settings model for vendor-specific email configuration.
|
||||
|
||||
This model stores vendor SMTP/email provider settings, enabling vendors to:
|
||||
- Send emails from their own domain/email address
|
||||
- Use their own SMTP server or email provider (tier-gated)
|
||||
- Customize sender name, reply-to address, and signature
|
||||
|
||||
Architecture:
|
||||
- Vendors MUST configure email settings to send transactional emails
|
||||
- Platform emails (billing, subscription) still use platform settings
|
||||
- Advanced providers (SendGrid, Mailgun, SES) are tier-gated (Business+)
|
||||
- "Powered by Wizamart" footer is added for Essential/Professional tiers
|
||||
"""
|
||||
|
||||
import enum
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class EmailProvider(str, enum.Enum):
|
||||
"""Supported email providers."""
|
||||
|
||||
SMTP = "smtp" # Standard SMTP (all tiers)
|
||||
SENDGRID = "sendgrid" # SendGrid API (Business+ tier)
|
||||
MAILGUN = "mailgun" # Mailgun API (Business+ tier)
|
||||
SES = "ses" # Amazon SES (Business+ tier)
|
||||
|
||||
|
||||
# Providers that require Business+ tier
|
||||
PREMIUM_EMAIL_PROVIDERS = {
|
||||
EmailProvider.SENDGRID,
|
||||
EmailProvider.MAILGUN,
|
||||
EmailProvider.SES,
|
||||
}
|
||||
|
||||
|
||||
class VendorEmailSettings(Base, TimestampMixin):
|
||||
"""
|
||||
Vendor email configuration for sending transactional emails.
|
||||
|
||||
This is a one-to-one relationship with Vendor.
|
||||
Vendors must configure this to send emails to their customers.
|
||||
"""
|
||||
|
||||
__tablename__ = "vendor_email_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
vendor_id = Column(
|
||||
Integer,
|
||||
ForeignKey("vendors.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Sender Identity (Required)
|
||||
# =========================================================================
|
||||
from_email = Column(String(255), nullable=False) # e.g., orders@vendorshop.lu
|
||||
from_name = Column(String(100), nullable=False) # e.g., "VendorShop"
|
||||
reply_to_email = Column(String(255), nullable=True) # Optional reply-to address
|
||||
|
||||
# =========================================================================
|
||||
# Email Signature/Footer (Optional)
|
||||
# =========================================================================
|
||||
signature_text = Column(Text, nullable=True) # Plain text signature
|
||||
signature_html = Column(Text, nullable=True) # HTML signature (footer)
|
||||
|
||||
# =========================================================================
|
||||
# Provider Configuration
|
||||
# =========================================================================
|
||||
provider = Column(
|
||||
String(20),
|
||||
default=EmailProvider.SMTP.value,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# SMTP Settings (used when provider=smtp)
|
||||
# =========================================================================
|
||||
smtp_host = Column(String(255), nullable=True)
|
||||
smtp_port = Column(Integer, nullable=True, default=587)
|
||||
smtp_username = Column(String(255), nullable=True)
|
||||
smtp_password = Column(String(500), nullable=True) # Encrypted at rest
|
||||
smtp_use_tls = Column(Boolean, default=True, nullable=False)
|
||||
smtp_use_ssl = Column(Boolean, default=False, nullable=False) # For port 465
|
||||
|
||||
# =========================================================================
|
||||
# SendGrid Settings (used when provider=sendgrid, Business+ tier)
|
||||
# =========================================================================
|
||||
sendgrid_api_key = Column(String(500), nullable=True) # Encrypted at rest
|
||||
|
||||
# =========================================================================
|
||||
# Mailgun Settings (used when provider=mailgun, Business+ tier)
|
||||
# =========================================================================
|
||||
mailgun_api_key = Column(String(500), nullable=True) # Encrypted at rest
|
||||
mailgun_domain = Column(String(255), nullable=True)
|
||||
|
||||
# =========================================================================
|
||||
# Amazon SES Settings (used when provider=ses, Business+ tier)
|
||||
# =========================================================================
|
||||
ses_access_key_id = Column(String(100), nullable=True)
|
||||
ses_secret_access_key = Column(String(500), nullable=True) # Encrypted at rest
|
||||
ses_region = Column(String(50), nullable=True, default="eu-west-1")
|
||||
|
||||
# =========================================================================
|
||||
# Status & Verification
|
||||
# =========================================================================
|
||||
is_configured = Column(Boolean, default=False, nullable=False) # Has complete config
|
||||
is_verified = Column(Boolean, default=False, nullable=False) # Test email succeeded
|
||||
last_verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
verification_error = Column(Text, nullable=True) # Last verification error message
|
||||
|
||||
# =========================================================================
|
||||
# Relationship
|
||||
# =========================================================================
|
||||
vendor = relationship("Vendor", back_populates="email_settings")
|
||||
|
||||
# =========================================================================
|
||||
# Indexes
|
||||
# =========================================================================
|
||||
__table_args__ = (
|
||||
Index("idx_vendor_email_settings_configured", "vendor_id", "is_configured"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<VendorEmailSettings(vendor_id={self.vendor_id}, provider='{self.provider}', from='{self.from_email}')>"
|
||||
|
||||
# =========================================================================
|
||||
# Helper Methods
|
||||
# =========================================================================
|
||||
|
||||
def is_smtp_configured(self) -> bool:
|
||||
"""Check if SMTP settings are complete."""
|
||||
if self.provider != EmailProvider.SMTP.value:
|
||||
return False
|
||||
return bool(
|
||||
self.smtp_host
|
||||
and self.smtp_port
|
||||
and self.smtp_username
|
||||
and self.smtp_password
|
||||
)
|
||||
|
||||
def is_sendgrid_configured(self) -> bool:
|
||||
"""Check if SendGrid settings are complete."""
|
||||
if self.provider != EmailProvider.SENDGRID.value:
|
||||
return False
|
||||
return bool(self.sendgrid_api_key)
|
||||
|
||||
def is_mailgun_configured(self) -> bool:
|
||||
"""Check if Mailgun settings are complete."""
|
||||
if self.provider != EmailProvider.MAILGUN.value:
|
||||
return False
|
||||
return bool(self.mailgun_api_key and self.mailgun_domain)
|
||||
|
||||
def is_ses_configured(self) -> bool:
|
||||
"""Check if Amazon SES settings are complete."""
|
||||
if self.provider != EmailProvider.SES.value:
|
||||
return False
|
||||
return bool(
|
||||
self.ses_access_key_id
|
||||
and self.ses_secret_access_key
|
||||
and self.ses_region
|
||||
)
|
||||
|
||||
def is_provider_configured(self) -> bool:
|
||||
"""Check if the current provider is fully configured."""
|
||||
provider_checks = {
|
||||
EmailProvider.SMTP.value: self.is_smtp_configured,
|
||||
EmailProvider.SENDGRID.value: self.is_sendgrid_configured,
|
||||
EmailProvider.MAILGUN.value: self.is_mailgun_configured,
|
||||
EmailProvider.SES.value: self.is_ses_configured,
|
||||
}
|
||||
check_fn = provider_checks.get(self.provider)
|
||||
return check_fn() if check_fn else False
|
||||
|
||||
def is_fully_configured(self) -> bool:
|
||||
"""Check if email settings are fully configured (identity + provider)."""
|
||||
return bool(
|
||||
self.from_email
|
||||
and self.from_name
|
||||
and self.is_provider_configured()
|
||||
)
|
||||
|
||||
def update_configuration_status(self) -> None:
|
||||
"""Update the is_configured flag based on current settings."""
|
||||
self.is_configured = self.is_fully_configured()
|
||||
|
||||
def mark_verified(self) -> None:
|
||||
"""Mark settings as verified (test email succeeded)."""
|
||||
self.is_verified = True
|
||||
self.last_verified_at = datetime.now(UTC)
|
||||
self.verification_error = None
|
||||
|
||||
def mark_verification_failed(self, error: str) -> None:
|
||||
"""Mark settings as verification failed."""
|
||||
self.is_verified = False
|
||||
self.verification_error = error
|
||||
|
||||
def requires_premium_tier(self) -> bool:
|
||||
"""Check if current provider requires Business+ tier."""
|
||||
return self.provider in [p.value for p in PREMIUM_EMAIL_PROVIDERS]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for API responses (excludes sensitive data)."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"vendor_id": self.vendor_id,
|
||||
"from_email": self.from_email,
|
||||
"from_name": self.from_name,
|
||||
"reply_to_email": self.reply_to_email,
|
||||
"signature_text": self.signature_text,
|
||||
"signature_html": self.signature_html,
|
||||
"provider": self.provider,
|
||||
# SMTP (mask password)
|
||||
"smtp_host": self.smtp_host,
|
||||
"smtp_port": self.smtp_port,
|
||||
"smtp_username": self.smtp_username,
|
||||
"smtp_password_set": bool(self.smtp_password),
|
||||
"smtp_use_tls": self.smtp_use_tls,
|
||||
"smtp_use_ssl": self.smtp_use_ssl,
|
||||
# SendGrid (mask API key)
|
||||
"sendgrid_api_key_set": bool(self.sendgrid_api_key),
|
||||
# Mailgun (mask API key)
|
||||
"mailgun_api_key_set": bool(self.mailgun_api_key),
|
||||
"mailgun_domain": self.mailgun_domain,
|
||||
# SES (mask credentials)
|
||||
"ses_access_key_id_set": bool(self.ses_access_key_id),
|
||||
"ses_region": self.ses_region,
|
||||
# Status
|
||||
"is_configured": self.is_configured,
|
||||
"is_verified": self.is_verified,
|
||||
"last_verified_at": self.last_verified_at.isoformat() if self.last_verified_at else None,
|
||||
"verification_error": self.verification_error,
|
||||
"requires_premium_tier": self.requires_premium_tier(),
|
||||
# Timestamps
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["EmailProvider", "PREMIUM_EMAIL_PROVIDERS", "VendorEmailSettings"]
|
||||
230
app/modules/messaging/models/vendor_email_template.py
Normal file
230
app/modules/messaging/models/vendor_email_template.py
Normal file
@@ -0,0 +1,230 @@
|
||||
# app/modules/messaging/models/vendor_email_template.py
|
||||
"""
|
||||
Vendor email template override model.
|
||||
|
||||
Allows vendors to customize platform email templates with their own content.
|
||||
Platform-only templates cannot be overridden (e.g., billing, subscription emails).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Session, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class VendorEmailTemplate(Base, TimestampMixin):
|
||||
"""
|
||||
Vendor-specific email template override.
|
||||
|
||||
Each vendor can customize email templates for their shop.
|
||||
Overrides are per-template-code and per-language.
|
||||
|
||||
When sending emails:
|
||||
1. Check if vendor has an override for the template+language
|
||||
2. If yes, use vendor's version
|
||||
3. If no, fall back to platform template
|
||||
|
||||
Platform-only templates (is_platform_only=True on EmailTemplate)
|
||||
cannot be overridden.
|
||||
"""
|
||||
|
||||
__tablename__ = "vendor_email_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
|
||||
# Vendor relationship
|
||||
vendor_id = Column(
|
||||
Integer,
|
||||
ForeignKey("vendors.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Template identification (references EmailTemplate.code, not FK)
|
||||
template_code = Column(String(100), nullable=False, index=True)
|
||||
language = Column(String(5), nullable=False, default="en")
|
||||
|
||||
# Optional custom name (if None, uses platform template name)
|
||||
name = Column(String(255), nullable=True)
|
||||
|
||||
# Email content
|
||||
subject = Column(String(500), nullable=False)
|
||||
body_html = Column(Text, nullable=False)
|
||||
body_text = Column(Text, nullable=True)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor", back_populates="email_templates")
|
||||
|
||||
# Unique constraint: one override per vendor+template+language
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"vendor_id",
|
||||
"template_code",
|
||||
"language",
|
||||
name="uq_vendor_email_template_code_language",
|
||||
),
|
||||
{"sqlite_autoincrement": True},
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<VendorEmailTemplate("
|
||||
f"vendor_id={self.vendor_id}, "
|
||||
f"code='{self.template_code}', "
|
||||
f"language='{self.language}')>"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_override(
|
||||
cls,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
template_code: str,
|
||||
language: str,
|
||||
) -> "VendorEmailTemplate | None":
|
||||
"""
|
||||
Get vendor's template override if it exists.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
template_code: Template code to look up
|
||||
language: Language code (en, fr, de, lb)
|
||||
|
||||
Returns:
|
||||
VendorEmailTemplate if override exists, None otherwise
|
||||
"""
|
||||
return (
|
||||
db.query(cls)
|
||||
.filter(
|
||||
cls.vendor_id == vendor_id,
|
||||
cls.template_code == template_code,
|
||||
cls.language == language,
|
||||
cls.is_active == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_all_overrides_for_vendor(
|
||||
cls,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
) -> list["VendorEmailTemplate"]:
|
||||
"""
|
||||
Get all template overrides for a vendor.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
|
||||
Returns:
|
||||
List of VendorEmailTemplate objects
|
||||
"""
|
||||
return (
|
||||
db.query(cls)
|
||||
.filter(
|
||||
cls.vendor_id == vendor_id,
|
||||
cls.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(cls.template_code, cls.language)
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_or_update(
|
||||
cls,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
template_code: str,
|
||||
language: str,
|
||||
subject: str,
|
||||
body_html: str,
|
||||
body_text: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> "VendorEmailTemplate":
|
||||
"""
|
||||
Create or update a vendor email template override.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
template_code: Template code
|
||||
language: Language code
|
||||
subject: Email subject
|
||||
body_html: HTML body content
|
||||
body_text: Optional plain text body
|
||||
name: Optional custom name
|
||||
|
||||
Returns:
|
||||
Created or updated VendorEmailTemplate
|
||||
"""
|
||||
existing = cls.get_override(db, vendor_id, template_code, language)
|
||||
|
||||
if existing:
|
||||
existing.subject = subject
|
||||
existing.body_html = body_html
|
||||
existing.body_text = body_text
|
||||
existing.name = name
|
||||
existing.updated_at = datetime.utcnow()
|
||||
return existing
|
||||
|
||||
new_template = cls(
|
||||
vendor_id=vendor_id,
|
||||
template_code=template_code,
|
||||
language=language,
|
||||
subject=subject,
|
||||
body_html=body_html,
|
||||
body_text=body_text,
|
||||
name=name,
|
||||
)
|
||||
db.add(new_template)
|
||||
return new_template
|
||||
|
||||
@classmethod
|
||||
def delete_override(
|
||||
cls,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
template_code: str,
|
||||
language: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a vendor's template override (revert to platform default).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
template_code: Template code
|
||||
language: Language code
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
deleted = (
|
||||
db.query(cls)
|
||||
.filter(
|
||||
cls.vendor_id == vendor_id,
|
||||
cls.template_code == template_code,
|
||||
cls.language == language,
|
||||
)
|
||||
.delete()
|
||||
)
|
||||
return deleted > 0
|
||||
|
||||
|
||||
__all__ = ["VendorEmailTemplate"]
|
||||
@@ -20,7 +20,7 @@ from app.modules.messaging.services.admin_notification_service import (
|
||||
platform_alert_service,
|
||||
)
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.admin import (
|
||||
from app.modules.tenancy.schemas.admin import (
|
||||
AdminNotificationCreate,
|
||||
AdminNotificationListResponse,
|
||||
AdminNotificationResponse,
|
||||
|
||||
@@ -487,7 +487,7 @@ def _get_other_participant_name(conversation, customer_id: int) -> str:
|
||||
"""Get the name of the other participant (the vendor user)."""
|
||||
for participant in conversation.participants:
|
||||
if participant.participant_type == ParticipantType.VENDOR:
|
||||
from models.database.user import User
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
user = (
|
||||
User.query.filter_by(id=participant.participant_id).first()
|
||||
@@ -514,7 +514,7 @@ def _get_sender_name(message) -> str:
|
||||
return f"{customer.first_name} {customer.last_name}"
|
||||
return "Customer"
|
||||
elif message.sender_type == ParticipantType.VENDOR:
|
||||
from models.database.user import User
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
user = (
|
||||
User.query.filter_by(id=message.sender_id).first()
|
||||
|
||||
@@ -16,8 +16,8 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_db, require_menu_access
|
||||
from app.modules.core.utils.page_context import get_admin_context
|
||||
from app.templates_config import templates
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from models.database.user import User
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
|
||||
from app.modules.core.utils.page_context import get_vendor_context
|
||||
from app.templates_config import templates
|
||||
from models.database.user import User
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -57,6 +57,23 @@ from app.modules.messaging.schemas.notification import (
|
||||
AlertStatisticsResponse,
|
||||
)
|
||||
|
||||
# Email template schemas
|
||||
from app.modules.messaging.schemas.email import (
|
||||
EmailPreviewRequest,
|
||||
EmailPreviewResponse,
|
||||
EmailTemplateBase,
|
||||
EmailTemplateCreate,
|
||||
EmailTemplateResponse,
|
||||
EmailTemplateSummary,
|
||||
EmailTemplateUpdate,
|
||||
EmailTemplateWithOverrideStatus,
|
||||
EmailTestRequest,
|
||||
EmailTestResponse,
|
||||
VendorEmailTemplateCreate,
|
||||
VendorEmailTemplateResponse,
|
||||
VendorEmailTemplateUpdate,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Attachment schemas
|
||||
"AttachmentResponse",
|
||||
@@ -104,4 +121,18 @@ __all__ = [
|
||||
"TestNotificationRequest",
|
||||
# Alert statistics
|
||||
"AlertStatisticsResponse",
|
||||
# Email template schemas
|
||||
"EmailPreviewRequest",
|
||||
"EmailPreviewResponse",
|
||||
"EmailTemplateBase",
|
||||
"EmailTemplateCreate",
|
||||
"EmailTemplateResponse",
|
||||
"EmailTemplateSummary",
|
||||
"EmailTemplateUpdate",
|
||||
"EmailTemplateWithOverrideStatus",
|
||||
"EmailTestRequest",
|
||||
"EmailTestResponse",
|
||||
"VendorEmailTemplateCreate",
|
||||
"VendorEmailTemplateResponse",
|
||||
"VendorEmailTemplateUpdate",
|
||||
]
|
||||
|
||||
247
app/modules/messaging/schemas/email.py
Normal file
247
app/modules/messaging/schemas/email.py
Normal file
@@ -0,0 +1,247 @@
|
||||
# app/modules/messaging/schemas/email.py
|
||||
"""
|
||||
Email template Pydantic schemas for API responses and requests.
|
||||
|
||||
Provides schemas for:
|
||||
- EmailTemplate: Platform email templates
|
||||
- VendorEmailTemplate: Vendor-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 vendors"
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# Vendor Email Template Schemas
|
||||
|
||||
|
||||
class VendorEmailTemplateCreate(BaseModel):
|
||||
"""Schema for creating a vendor 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 VendorEmailTemplateUpdate(BaseModel):
|
||||
"""Schema for updating a vendor 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 VendorEmailTemplateResponse(BaseModel):
|
||||
"""Schema for vendor email template override API response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor_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 vendor override status.
|
||||
|
||||
Used in vendor 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 vendor has customized this template"
|
||||
)
|
||||
override_languages: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Languages with vendor 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
|
||||
@@ -16,8 +16,8 @@ from sqlalchemy import and_, case, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.messaging.models.admin_notification import AdminNotification
|
||||
from models.database.admin import PlatformAlert
|
||||
from models.schema.admin import AdminNotificationCreate, PlatformAlertCreate
|
||||
from app.modules.tenancy.models import PlatformAlert
|
||||
from app.modules.tenancy.schemas.admin import AdminNotificationCreate, PlatformAlertCreate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ from jinja2 import Environment, BaseLoader
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from models.database.email import EmailLog, EmailStatus, EmailTemplate
|
||||
from models.database.vendor_email_template import VendorEmailTemplate
|
||||
from app.modules.messaging.models import EmailLog, EmailStatus, EmailTemplate
|
||||
from app.modules.messaging.models import VendorEmailTemplate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -368,7 +368,7 @@ def get_platform_email_config(db: Session) -> dict:
|
||||
Returns:
|
||||
Dictionary with all email configuration values
|
||||
"""
|
||||
from models.database.admin import AdminSetting
|
||||
from app.modules.tenancy.models import AdminSetting
|
||||
|
||||
def get_db_setting(key: str) -> str | None:
|
||||
setting = db.query(AdminSetting).filter(AdminSetting.key == key).first()
|
||||
@@ -1002,7 +1002,7 @@ class EmailService:
|
||||
def _get_vendor(self, vendor_id: int):
|
||||
"""Get vendor with caching."""
|
||||
if vendor_id not in self._vendor_cache:
|
||||
from models.database.vendor import Vendor
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
self._vendor_cache[vendor_id] = (
|
||||
self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
@@ -1026,7 +1026,7 @@ class EmailService:
|
||||
def _get_vendor_email_settings(self, vendor_id: int):
|
||||
"""Get vendor email settings with caching."""
|
||||
if vendor_id not in self._vendor_email_settings_cache:
|
||||
from models.database.vendor_email_settings import VendorEmailSettings
|
||||
from app.modules.messaging.models import VendorEmailSettings
|
||||
|
||||
self._vendor_email_settings_cache[vendor_id] = (
|
||||
self.db.query(VendorEmailSettings)
|
||||
|
||||
@@ -24,8 +24,8 @@ from app.exceptions.base import (
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from models.database.email import EmailCategory, EmailLog, EmailTemplate
|
||||
from models.database.vendor_email_template import VendorEmailTemplate
|
||||
from app.modules.messaging.models import EmailCategory, EmailLog, EmailTemplate
|
||||
from app.modules.messaging.models import VendorEmailTemplate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from app.modules.messaging.models.message import (
|
||||
ParticipantType,
|
||||
)
|
||||
from app.modules.customers.models.customer import Customer
|
||||
from models.database.user import User
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -592,7 +592,7 @@ class MessagingService:
|
||||
Returns:
|
||||
Tuple of (recipients list, total count)
|
||||
"""
|
||||
from models.database.vendor import VendorUser
|
||||
from app.modules.tenancy.models import VendorUser
|
||||
|
||||
query = (
|
||||
db.query(User, VendorUser)
|
||||
|
||||
@@ -51,6 +51,9 @@ function emailTemplatesPage() {
|
||||
|
||||
// Lifecycle
|
||||
async init() {
|
||||
// Load i18n translations
|
||||
await I18n.loadModule('messaging');
|
||||
|
||||
if (window._adminEmailTemplatesInitialized) return;
|
||||
window._adminEmailTemplatesInitialized = true;
|
||||
|
||||
@@ -70,7 +73,7 @@ function emailTemplatesPage() {
|
||||
this.categories = categoriesData.categories || [];
|
||||
} catch (error) {
|
||||
emailTemplatesLog.error('Failed to load email templates:', error);
|
||||
Utils.showToast('Failed to load templates', 'error');
|
||||
Utils.showToast(I18n.t('messaging.messages.failed_to_load_templates'), 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -122,10 +125,10 @@ function emailTemplatesPage() {
|
||||
variables: [],
|
||||
required_variables: []
|
||||
};
|
||||
Utils.showToast(`No template for ${this.editLanguage.toUpperCase()} - create one by saving`, 'info');
|
||||
Utils.showToast(I18n.t('messaging.messages.no_template_for_language', { language: this.editLanguage.toUpperCase() }), 'info');
|
||||
} else {
|
||||
emailTemplatesLog.error('Failed to load template:', error);
|
||||
Utils.showToast('Failed to load template', 'error');
|
||||
Utils.showToast(I18n.t('messaging.messages.failed_to_load_template'), 'error');
|
||||
}
|
||||
} finally {
|
||||
this.loadingTemplate = false;
|
||||
@@ -158,12 +161,12 @@ function emailTemplatesPage() {
|
||||
}
|
||||
);
|
||||
|
||||
Utils.showToast('Template saved successfully', 'success');
|
||||
Utils.showToast(I18n.t('messaging.messages.template_saved_successfully'), 'success');
|
||||
// Refresh templates list
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
emailTemplatesLog.error('Failed to save template:', error);
|
||||
Utils.showToast(error.detail || 'Failed to save template', 'error');
|
||||
Utils.showToast(error.detail || I18n.t('messaging.messages.failed_to_save_template'), 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
@@ -188,7 +191,7 @@ function emailTemplatesPage() {
|
||||
this.showPreviewModal = true;
|
||||
} catch (error) {
|
||||
emailTemplatesLog.error('Failed to preview template:', error);
|
||||
Utils.showToast('Failed to load preview', 'error');
|
||||
Utils.showToast(I18n.t('messaging.messages.failed_to_load_preview'), 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -250,15 +253,15 @@ function emailTemplatesPage() {
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
Utils.showToast(`Test email sent to ${this.testEmailAddress}`, 'success');
|
||||
Utils.showToast(I18n.t('messaging.messages.test_email_sent', { email: this.testEmailAddress }), 'success');
|
||||
this.showTestEmailModal = false;
|
||||
this.testEmailAddress = '';
|
||||
} else {
|
||||
Utils.showToast(result.message || 'Failed to send test email', 'error');
|
||||
Utils.showToast(result.message || I18n.t('messaging.messages.failed_to_send_test_email'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
emailTemplatesLog.error('Failed to send test email:', error);
|
||||
Utils.showToast('Failed to send test email', 'error');
|
||||
Utils.showToast(I18n.t('messaging.messages.failed_to_send_test_email'), 'error');
|
||||
} finally {
|
||||
this.sendingTest = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user