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:
2026-02-01 21:02:56 +01:00
parent 09d7d282c6
commit d7a0ff8818
307 changed files with 5536 additions and 3826 deletions

View File

@@ -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",
]

View 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"]

View 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"]

View 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"]