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:
@@ -1,256 +1,30 @@
|
||||
# models/database/__init__.py
|
||||
"""
|
||||
Database models package.
|
||||
Database models package - Base classes and mixins only.
|
||||
|
||||
This package imports all SQLAlchemy models to ensure they are registered
|
||||
with Base.metadata. This includes:
|
||||
1. Core models (defined in this directory)
|
||||
2. Module models (discovered from app/modules/<module>/models/)
|
||||
This package provides the base infrastructure for SQLAlchemy models:
|
||||
- Base: SQLAlchemy declarative base
|
||||
- TimestampMixin: Mixin for created_at/updated_at timestamps
|
||||
|
||||
Module Model Discovery:
|
||||
- Modules can define their own models in app/modules/<module>/models/
|
||||
- These are automatically imported when this package loads
|
||||
- Module models must use `from app.core.database import Base`
|
||||
IMPORTANT: Domain models have been migrated to their respective modules:
|
||||
- Tenancy models: app.modules.tenancy.models
|
||||
- Catalog models: app.modules.catalog.models
|
||||
- Orders models: app.modules.orders.models
|
||||
- Inventory models: app.modules.inventory.models
|
||||
- Billing models: app.modules.billing.models
|
||||
- Messaging models: app.modules.messaging.models
|
||||
- CMS models: app.modules.cms.models
|
||||
- Marketplace models: app.modules.marketplace.models
|
||||
- Customers models: app.modules.customers.models
|
||||
- Dev Tools models: app.modules.dev_tools.models
|
||||
- Core models: app.modules.core.models
|
||||
|
||||
Import models from their canonical module locations instead of this package.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# CORE MODELS (always loaded)
|
||||
# ============================================================================
|
||||
|
||||
from .admin import (
|
||||
AdminAuditLog,
|
||||
AdminSession,
|
||||
AdminSetting,
|
||||
PlatformAlert,
|
||||
)
|
||||
from app.modules.messaging.models import AdminNotification
|
||||
from .admin_menu_config import AdminMenuConfig, FrontendType, MANDATORY_MENU_ITEMS
|
||||
from .admin_platform import AdminPlatform
|
||||
from app.modules.dev_tools.models import (
|
||||
ArchitectureScan,
|
||||
ArchitectureViolation,
|
||||
ViolationAssignment,
|
||||
ViolationComment,
|
||||
)
|
||||
from .base import Base
|
||||
from .company import Company
|
||||
from .platform import Platform
|
||||
from .platform_module import PlatformModule
|
||||
from .vendor_platform import VendorPlatform
|
||||
from app.modules.customers.models import Customer, CustomerAddress
|
||||
from app.modules.customers.models import PasswordResetToken
|
||||
from .email import EmailCategory, EmailLog, EmailStatus, EmailTemplate
|
||||
from .vendor_email_template import VendorEmailTemplate
|
||||
from .vendor_email_settings import EmailProvider, VendorEmailSettings, PREMIUM_EMAIL_PROVIDERS
|
||||
from app.modules.billing.models import Feature, FeatureCategory, FeatureCode, FeatureUILocation
|
||||
from app.modules.inventory.models import Inventory
|
||||
from app.modules.inventory.models import InventoryTransaction, TransactionType
|
||||
from app.modules.orders.models import (
|
||||
Invoice,
|
||||
InvoiceStatus,
|
||||
VATRegime,
|
||||
VendorInvoiceSettings,
|
||||
)
|
||||
from app.modules.marketplace.models import (
|
||||
LetzshopFulfillmentQueue,
|
||||
LetzshopHistoricalImportJob,
|
||||
LetzshopSyncLog,
|
||||
VendorLetzshopCredentials,
|
||||
MarketplaceImportError,
|
||||
MarketplaceImportJob,
|
||||
DigitalDeliveryMethod,
|
||||
MarketplaceProduct,
|
||||
ProductType,
|
||||
MarketplaceProductTranslation,
|
||||
)
|
||||
from app.modules.messaging.models import (
|
||||
Conversation,
|
||||
ConversationParticipant,
|
||||
ConversationType,
|
||||
Message,
|
||||
MessageAttachment,
|
||||
ParticipantType,
|
||||
)
|
||||
from .media import MediaFile
|
||||
from app.modules.catalog.models import ProductMedia
|
||||
from app.modules.marketplace.models import OnboardingStatus, OnboardingStep, VendorOnboarding
|
||||
from app.modules.orders.models import Order, OrderItem
|
||||
from app.modules.orders.models import OrderItemException
|
||||
from app.modules.catalog.models import Product, ProductTranslation
|
||||
from app.modules.billing.models import (
|
||||
AddOnCategory,
|
||||
AddOnProduct,
|
||||
BillingHistory,
|
||||
BillingPeriod,
|
||||
StripeWebhookEvent,
|
||||
SubscriptionStatus,
|
||||
SubscriptionTier,
|
||||
TierCode,
|
||||
TIER_LIMITS,
|
||||
VendorAddOn,
|
||||
VendorSubscription,
|
||||
)
|
||||
from app.modules.dev_tools.models import TestCollection, TestResult, TestRun
|
||||
from .user import User
|
||||
from .vendor import Role, Vendor, VendorUser
|
||||
from .vendor_domain import VendorDomain
|
||||
from .vendor_theme import VendorTheme
|
||||
|
||||
# ============================================================================
|
||||
# MODULE MODELS (dynamically discovered)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _discover_module_models():
|
||||
"""
|
||||
Discover and import models from app/modules/<module>/models/ directories.
|
||||
|
||||
This ensures module models are registered with Base.metadata for:
|
||||
1. Alembic migrations
|
||||
2. SQLAlchemy queries
|
||||
|
||||
Module models must:
|
||||
- Be in app/modules/<module>/models/__init__.py or individual files
|
||||
- Import Base from app.core.database
|
||||
"""
|
||||
modules_dir = Path(__file__).parent.parent.parent / "app" / "modules"
|
||||
|
||||
if not modules_dir.exists():
|
||||
return
|
||||
|
||||
for module_dir in sorted(modules_dir.iterdir()):
|
||||
if not module_dir.is_dir():
|
||||
continue
|
||||
|
||||
models_init = module_dir / "models" / "__init__.py"
|
||||
if models_init.exists():
|
||||
module_name = f"app.modules.{module_dir.name}.models"
|
||||
try:
|
||||
importlib.import_module(module_name)
|
||||
logger.debug(f"[Models] Loaded module models: {module_name}")
|
||||
except ImportError as e:
|
||||
logger.warning(f"[Models] Failed to import {module_name}: {e}")
|
||||
|
||||
|
||||
# Run discovery at import time
|
||||
_discover_module_models()
|
||||
|
||||
# ============================================================================
|
||||
# EXPORTS
|
||||
# ============================================================================
|
||||
from .base import Base, TimestampMixin
|
||||
|
||||
__all__ = [
|
||||
# Admin-specific models
|
||||
"AdminAuditLog",
|
||||
"AdminMenuConfig",
|
||||
"FrontendType",
|
||||
"AdminNotification",
|
||||
"AdminPlatform",
|
||||
"AdminSetting",
|
||||
"MANDATORY_MENU_ITEMS",
|
||||
"PlatformAlert",
|
||||
"AdminSession",
|
||||
# Architecture/Code Quality
|
||||
"ArchitectureScan",
|
||||
"ArchitectureViolation",
|
||||
"ViolationAssignment",
|
||||
"ViolationComment",
|
||||
# Test Runs
|
||||
"TestRun",
|
||||
"TestResult",
|
||||
"TestCollection",
|
||||
# Base
|
||||
"Base",
|
||||
# User & Auth
|
||||
"User",
|
||||
# Company & Vendor
|
||||
"Company",
|
||||
"Vendor",
|
||||
"VendorUser",
|
||||
"Role",
|
||||
"VendorDomain",
|
||||
"VendorTheme",
|
||||
# Platform
|
||||
"Platform",
|
||||
"PlatformModule",
|
||||
"VendorPlatform",
|
||||
# Customer & Auth
|
||||
"Customer",
|
||||
"CustomerAddress",
|
||||
"PasswordResetToken",
|
||||
# Email
|
||||
"EmailCategory",
|
||||
"EmailLog",
|
||||
"EmailStatus",
|
||||
"EmailTemplate",
|
||||
"VendorEmailTemplate",
|
||||
"VendorEmailSettings",
|
||||
"EmailProvider",
|
||||
"PREMIUM_EMAIL_PROVIDERS",
|
||||
# Features
|
||||
"Feature",
|
||||
"FeatureCategory",
|
||||
"FeatureCode",
|
||||
"FeatureUILocation",
|
||||
# Product - Enums
|
||||
"ProductType",
|
||||
"DigitalDeliveryMethod",
|
||||
# Product - Models
|
||||
"MarketplaceProduct",
|
||||
"MarketplaceProductTranslation",
|
||||
"Product",
|
||||
"ProductTranslation",
|
||||
# Import
|
||||
"MarketplaceImportJob",
|
||||
"MarketplaceImportError",
|
||||
# Inventory
|
||||
"Inventory",
|
||||
"InventoryTransaction",
|
||||
"TransactionType",
|
||||
# Media
|
||||
"MediaFile",
|
||||
"ProductMedia",
|
||||
# Invoicing
|
||||
"Invoice",
|
||||
"InvoiceStatus",
|
||||
"VATRegime",
|
||||
"VendorInvoiceSettings",
|
||||
# Orders
|
||||
"Order",
|
||||
"OrderItem",
|
||||
"OrderItemException",
|
||||
# Letzshop Integration
|
||||
"VendorLetzshopCredentials",
|
||||
"LetzshopFulfillmentQueue",
|
||||
"LetzshopSyncLog",
|
||||
"LetzshopHistoricalImportJob",
|
||||
# Subscription & Billing
|
||||
"VendorSubscription",
|
||||
"SubscriptionStatus",
|
||||
"SubscriptionTier",
|
||||
"TierCode",
|
||||
"TIER_LIMITS",
|
||||
"AddOnProduct",
|
||||
"AddOnCategory",
|
||||
"BillingPeriod",
|
||||
"VendorAddOn",
|
||||
"BillingHistory",
|
||||
"StripeWebhookEvent",
|
||||
# Messaging
|
||||
"Conversation",
|
||||
"ConversationParticipant",
|
||||
"ConversationType",
|
||||
"Message",
|
||||
"MessageAttachment",
|
||||
"ParticipantType",
|
||||
# Onboarding
|
||||
"OnboardingStatus",
|
||||
"OnboardingStep",
|
||||
"VendorOnboarding",
|
||||
"TimestampMixin",
|
||||
]
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
# Admin-specific models
|
||||
# models/database/admin.py
|
||||
"""
|
||||
Admin-specific database models.
|
||||
|
||||
This module provides models for:
|
||||
- Admin audit logging (compliance and security tracking)
|
||||
- Admin notifications (system alerts and warnings)
|
||||
- Platform settings (global configuration)
|
||||
- Platform alerts (system-wide issues)
|
||||
- Application logs (critical events logging)
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
from .base import TimestampMixin
|
||||
|
||||
|
||||
class AdminAuditLog(Base, TimestampMixin):
|
||||
"""
|
||||
Track all admin actions for compliance and security.
|
||||
|
||||
Separate from regular audit logs - focuses on admin-specific operations
|
||||
like vendor creation, user management, and system configuration changes.
|
||||
"""
|
||||
|
||||
__tablename__ = "admin_audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
admin_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
action = Column(
|
||||
String(100), nullable=False, index=True
|
||||
) # create_vendor, delete_vendor, etc.
|
||||
target_type = Column(
|
||||
String(50), nullable=False, index=True
|
||||
) # vendor, user, import_job, setting
|
||||
target_id = Column(String(100), nullable=False, index=True)
|
||||
details = Column(JSON) # Additional context about the action
|
||||
ip_address = Column(String(45)) # IPv4 or IPv6
|
||||
user_agent = Column(Text)
|
||||
request_id = Column(String(100)) # For correlating with application logs
|
||||
|
||||
# Relationships
|
||||
admin_user = relationship("User", foreign_keys=[admin_user_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AdminAuditLog(id={self.id}, action='{self.action}', target={self.target_type}:{self.target_id})>"
|
||||
|
||||
|
||||
# AdminNotification has been moved to app/modules/messaging/models/admin_notification.py
|
||||
# It's re-exported via models/database/__init__.py for backwards compatibility
|
||||
|
||||
|
||||
class AdminSetting(Base, TimestampMixin):
|
||||
"""
|
||||
Platform-wide admin settings and configuration.
|
||||
|
||||
Stores global settings that affect the entire platform, different from
|
||||
vendor-specific settings. Supports encryption for sensitive values.
|
||||
|
||||
Examples:
|
||||
- max_vendors_allowed
|
||||
- maintenance_mode
|
||||
- default_vendor_trial_days
|
||||
- smtp_settings
|
||||
- stripe_api_keys (encrypted)
|
||||
"""
|
||||
|
||||
__tablename__ = "admin_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String(100), unique=True, nullable=False, index=True)
|
||||
value = Column(Text, nullable=False)
|
||||
value_type = Column(String(20), default="string") # string, integer, boolean, json
|
||||
category = Column(
|
||||
String(50), index=True
|
||||
) # system, security, marketplace, notifications
|
||||
description = Column(Text)
|
||||
is_encrypted = Column(Boolean, default=False)
|
||||
is_public = Column(Boolean, default=False) # Can be exposed to frontend?
|
||||
last_modified_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
last_modified_by = relationship("User", foreign_keys=[last_modified_by_user_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AdminSetting(key='{self.key}', category='{self.category}')>"
|
||||
|
||||
|
||||
class PlatformAlert(Base, TimestampMixin):
|
||||
"""
|
||||
System-wide alerts that admins need to be aware of.
|
||||
|
||||
Tracks platform issues, performance problems, security incidents,
|
||||
and other system-level concerns that require admin attention.
|
||||
"""
|
||||
|
||||
__tablename__ = "platform_alerts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
alert_type = Column(
|
||||
String(50), nullable=False, index=True
|
||||
) # security, performance, capacity, integration
|
||||
severity = Column(
|
||||
String(20), nullable=False, index=True
|
||||
) # info, warning, error, critical
|
||||
title = Column(String(200), nullable=False)
|
||||
description = Column(Text)
|
||||
affected_vendors = Column(JSON) # List of affected vendor IDs
|
||||
affected_systems = Column(JSON) # List of affected system components
|
||||
is_resolved = Column(Boolean, default=False, index=True)
|
||||
resolved_at = Column(DateTime, nullable=True)
|
||||
resolved_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
resolution_notes = Column(Text)
|
||||
auto_generated = Column(Boolean, default=True) # System-generated vs manual
|
||||
occurrence_count = Column(Integer, default=1) # Track repeated occurrences
|
||||
first_occurred_at = Column(DateTime, nullable=False)
|
||||
last_occurred_at = Column(DateTime, nullable=False)
|
||||
|
||||
# Relationships
|
||||
resolved_by = relationship("User", foreign_keys=[resolved_by_user_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PlatformAlert(id={self.id}, type='{self.alert_type}', severity='{self.severity}')>"
|
||||
|
||||
|
||||
class AdminSession(Base, TimestampMixin):
|
||||
"""
|
||||
Track admin login sessions for security monitoring.
|
||||
|
||||
Helps identify suspicious login patterns, track concurrent sessions,
|
||||
and enforce session policies for admin users.
|
||||
"""
|
||||
|
||||
__tablename__ = "admin_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
admin_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
session_token = Column(String(255), unique=True, nullable=False, index=True)
|
||||
ip_address = Column(String(45), nullable=False)
|
||||
user_agent = Column(Text)
|
||||
login_at = Column(DateTime, nullable=False, index=True)
|
||||
last_activity_at = Column(DateTime, nullable=False)
|
||||
logout_at = Column(DateTime, nullable=True)
|
||||
is_active = Column(Boolean, default=True, index=True)
|
||||
logout_reason = Column(String(50)) # manual, timeout, forced, suspicious
|
||||
|
||||
# Relationships
|
||||
admin_user = relationship("User", foreign_keys=[admin_user_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AdminSession(id={self.id}, admin_user_id={self.admin_user_id}, is_active={self.is_active})>"
|
||||
|
||||
|
||||
class ApplicationLog(Base, TimestampMixin):
|
||||
"""
|
||||
Application-level logs stored in database for critical events.
|
||||
|
||||
Stores WARNING, ERROR, and CRITICAL level logs for easy searching,
|
||||
filtering, and compliance. INFO and DEBUG logs are kept in files only.
|
||||
"""
|
||||
|
||||
__tablename__ = "application_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
timestamp = Column(DateTime, nullable=False, index=True)
|
||||
level = Column(String(20), nullable=False, index=True) # WARNING, ERROR, CRITICAL
|
||||
logger_name = Column(String(200), nullable=False, index=True)
|
||||
module = Column(String(200))
|
||||
function_name = Column(String(100))
|
||||
line_number = Column(Integer)
|
||||
message = Column(Text, nullable=False)
|
||||
exception_type = Column(String(200))
|
||||
exception_message = Column(Text)
|
||||
stack_trace = Column(Text)
|
||||
request_id = Column(String(100), index=True) # For correlating logs
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=True, index=True)
|
||||
context = Column(JSON) # Additional context data
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
vendor = relationship("Vendor", foreign_keys=[vendor_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ApplicationLog(id={self.id}, level='{self.level}', logger='{self.logger_name}')>"
|
||||
@@ -1,223 +0,0 @@
|
||||
# models/database/admin_menu_config.py
|
||||
"""
|
||||
Menu visibility configuration for admin and vendor frontends.
|
||||
|
||||
Supports two frontend types:
|
||||
- 'admin': Admin panel menus (for super admins and platform admins)
|
||||
- 'vendor': Vendor dashboard menus (configured per platform)
|
||||
|
||||
Supports two scopes:
|
||||
- Platform-level: Menu config for a platform (platform_id is set)
|
||||
→ For admin frontend: applies to platform admins
|
||||
→ For vendor frontend: applies to all vendors on that platform
|
||||
- User-level: Menu config for a specific super admin (user_id is set)
|
||||
→ Only for admin frontend (super admins configuring their own menu)
|
||||
|
||||
Design:
|
||||
- Opt-out model: All items visible by default, store hidden items
|
||||
- Mandatory items: Some items cannot be hidden (defined per frontend type)
|
||||
- Only stores non-default state (is_visible=False) to keep table small
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Column,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
# Import FrontendType and MANDATORY_MENU_ITEMS from the central location
|
||||
# and re-export for backward compatibility with existing imports.
|
||||
# These were moved to app.modules.enums to break a circular import:
|
||||
# app.modules.base -> models.database -> model discovery -> module definitions -> app.modules.base
|
||||
from app.modules.enums import FrontendType, MANDATORY_MENU_ITEMS
|
||||
|
||||
|
||||
class AdminMenuConfig(Base, TimestampMixin):
|
||||
"""
|
||||
Menu visibility configuration for admin and vendor frontends.
|
||||
|
||||
Supports two frontend types:
|
||||
- 'admin': Admin panel menus
|
||||
- 'vendor': Vendor dashboard menus
|
||||
|
||||
Supports two scopes:
|
||||
- Platform scope: platform_id is set
|
||||
→ Admin: applies to platform admins of that platform
|
||||
→ Vendor: applies to all vendors on that platform
|
||||
- User scope: user_id is set (admin frontend only)
|
||||
→ Applies to a specific super admin user
|
||||
|
||||
Resolution order for admin frontend:
|
||||
- Platform admins: Check platform config → fall back to default
|
||||
- Super admins: Check user config → fall back to default
|
||||
|
||||
Resolution order for vendor frontend:
|
||||
- Check platform config → fall back to default
|
||||
|
||||
Examples:
|
||||
- Platform "OMS" wants to hide "inventory" from admin panel
|
||||
→ frontend_type='admin', platform_id=1, menu_item_id="inventory", is_visible=False
|
||||
|
||||
- Platform "OMS" wants to hide "letzshop" from vendor dashboard
|
||||
→ frontend_type='vendor', platform_id=1, menu_item_id="letzshop", is_visible=False
|
||||
|
||||
- Super admin "john" wants to hide "code-quality" from their admin panel
|
||||
→ frontend_type='admin', user_id=5, menu_item_id="code-quality", is_visible=False
|
||||
"""
|
||||
|
||||
__tablename__ = "admin_menu_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# ========================================================================
|
||||
# Frontend Type
|
||||
# ========================================================================
|
||||
|
||||
frontend_type = Column(
|
||||
Enum(FrontendType, values_callable=lambda obj: [e.value for e in obj]),
|
||||
nullable=False,
|
||||
default=FrontendType.ADMIN,
|
||||
index=True,
|
||||
comment="Which frontend this config applies to (admin or vendor)",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Scope: Platform scope OR User scope (for admin frontend only)
|
||||
# ========================================================================
|
||||
|
||||
platform_id = Column(
|
||||
Integer,
|
||||
ForeignKey("platforms.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Platform scope - applies to users/vendors of this platform",
|
||||
)
|
||||
|
||||
user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="User scope - applies to this specific super admin (admin frontend only)",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Menu Item Configuration
|
||||
# ========================================================================
|
||||
|
||||
menu_item_id = Column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Menu item identifier from registry (e.g., 'products', 'inventory')",
|
||||
)
|
||||
|
||||
is_visible = Column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
comment="Whether this menu item is visible (False = hidden)",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Relationships
|
||||
# ========================================================================
|
||||
|
||||
platform = relationship(
|
||||
"Platform",
|
||||
back_populates="menu_configs",
|
||||
)
|
||||
|
||||
user = relationship(
|
||||
"User",
|
||||
back_populates="menu_configs",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Constraints
|
||||
# ========================================================================
|
||||
|
||||
__table_args__ = (
|
||||
# Unique constraint: one config per frontend+platform+menu_item
|
||||
UniqueConstraint(
|
||||
"frontend_type",
|
||||
"platform_id",
|
||||
"menu_item_id",
|
||||
name="uq_frontend_platform_menu_config",
|
||||
),
|
||||
# Unique constraint: one config per frontend+user+menu_item
|
||||
UniqueConstraint(
|
||||
"frontend_type",
|
||||
"user_id",
|
||||
"menu_item_id",
|
||||
name="uq_frontend_user_menu_config",
|
||||
),
|
||||
# Check: exactly one scope must be set (platform_id XOR user_id)
|
||||
CheckConstraint(
|
||||
"(platform_id IS NOT NULL AND user_id IS NULL) OR "
|
||||
"(platform_id IS NULL AND user_id IS NOT NULL)",
|
||||
name="ck_admin_menu_config_scope",
|
||||
),
|
||||
# Check: user_id scope only allowed for admin frontend
|
||||
CheckConstraint(
|
||||
"(user_id IS NULL) OR (frontend_type = 'admin')",
|
||||
name="ck_user_scope_admin_only",
|
||||
),
|
||||
# Performance indexes
|
||||
Index(
|
||||
"idx_admin_menu_config_frontend_platform",
|
||||
"frontend_type",
|
||||
"platform_id",
|
||||
),
|
||||
Index(
|
||||
"idx_admin_menu_config_frontend_user",
|
||||
"frontend_type",
|
||||
"user_id",
|
||||
),
|
||||
Index(
|
||||
"idx_admin_menu_config_platform_visible",
|
||||
"platform_id",
|
||||
"is_visible",
|
||||
),
|
||||
Index(
|
||||
"idx_admin_menu_config_user_visible",
|
||||
"user_id",
|
||||
"is_visible",
|
||||
),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Properties
|
||||
# ========================================================================
|
||||
|
||||
@property
|
||||
def scope_type(self) -> str:
|
||||
"""Get the scope type for this config."""
|
||||
if self.platform_id:
|
||||
return "platform"
|
||||
return "user"
|
||||
|
||||
@property
|
||||
def scope_id(self) -> int:
|
||||
"""Get the scope ID (platform_id or user_id)."""
|
||||
return self.platform_id or self.user_id
|
||||
|
||||
def __repr__(self) -> str:
|
||||
scope = f"platform_id={self.platform_id}" if self.platform_id else f"user_id={self.user_id}"
|
||||
return (
|
||||
f"<AdminMenuConfig("
|
||||
f"frontend_type='{self.frontend_type.value}', "
|
||||
f"{scope}, "
|
||||
f"menu_item_id='{self.menu_item_id}', "
|
||||
f"is_visible={self.is_visible})>"
|
||||
)
|
||||
@@ -1,161 +0,0 @@
|
||||
# models/database/admin_platform.py
|
||||
"""
|
||||
AdminPlatform junction table for many-to-many relationship between Admin Users and Platforms.
|
||||
|
||||
This enables platform-scoped admin access:
|
||||
- Super Admins: Have is_super_admin=True on User model, bypass this table
|
||||
- Platform Admins: Assigned to specific platforms via this junction table
|
||||
|
||||
A platform admin CAN be assigned to multiple platforms (e.g., both OMS and Loyalty).
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class AdminPlatform(Base, TimestampMixin):
|
||||
"""
|
||||
Junction table linking admin users to platforms they can manage.
|
||||
|
||||
Allows a platform admin to:
|
||||
- Manage specific platforms only (not all)
|
||||
- Be assigned to multiple platforms
|
||||
- Have assignment tracked for audit purposes
|
||||
|
||||
Example:
|
||||
- User "john@example.com" (admin) can manage OMS platform only
|
||||
- User "jane@example.com" (admin) can manage both OMS and Loyalty platforms
|
||||
"""
|
||||
|
||||
__tablename__ = "admin_platforms"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# ========================================================================
|
||||
# Foreign Keys
|
||||
# ========================================================================
|
||||
|
||||
user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Reference to the admin user",
|
||||
)
|
||||
|
||||
platform_id = Column(
|
||||
Integer,
|
||||
ForeignKey("platforms.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Reference to the platform",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Assignment Status
|
||||
# ========================================================================
|
||||
|
||||
is_active = Column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
comment="Whether the admin assignment is active",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Audit Fields
|
||||
# ========================================================================
|
||||
|
||||
assigned_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
nullable=False,
|
||||
comment="When the admin was assigned to this platform",
|
||||
)
|
||||
|
||||
assigned_by_user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
comment="Super admin who made this assignment",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Relationships
|
||||
# ========================================================================
|
||||
|
||||
user = relationship(
|
||||
"User",
|
||||
foreign_keys=[user_id],
|
||||
back_populates="admin_platforms",
|
||||
)
|
||||
|
||||
platform = relationship(
|
||||
"Platform",
|
||||
back_populates="admin_platforms",
|
||||
)
|
||||
|
||||
assigned_by = relationship(
|
||||
"User",
|
||||
foreign_keys=[assigned_by_user_id],
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Constraints & Indexes
|
||||
# ========================================================================
|
||||
|
||||
__table_args__ = (
|
||||
# Each admin can only be assigned to a platform once
|
||||
UniqueConstraint(
|
||||
"user_id",
|
||||
"platform_id",
|
||||
name="uq_admin_platform",
|
||||
),
|
||||
# Performance indexes
|
||||
Index(
|
||||
"idx_admin_platform_active",
|
||||
"user_id",
|
||||
"platform_id",
|
||||
"is_active",
|
||||
),
|
||||
Index(
|
||||
"idx_admin_platform_user_active",
|
||||
"user_id",
|
||||
"is_active",
|
||||
),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Properties
|
||||
# ========================================================================
|
||||
|
||||
@property
|
||||
def platform_code(self) -> str | None:
|
||||
"""Get the platform code for this assignment."""
|
||||
return self.platform.code if self.platform else None
|
||||
|
||||
@property
|
||||
def platform_name(self) -> str | None:
|
||||
"""Get the platform name for this assignment."""
|
||||
return self.platform.name if self.platform else None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<AdminPlatform("
|
||||
f"user_id={self.user_id}, "
|
||||
f"platform_id={self.platform_id}, "
|
||||
f"is_active={self.is_active})>"
|
||||
)
|
||||
@@ -1,106 +0,0 @@
|
||||
# models/database/company.py
|
||||
"""
|
||||
Company model representing the business entity that owns one or more vendor brands.
|
||||
|
||||
A Company represents the legal/business entity with contact information,
|
||||
while Vendors represent the individual brands/storefronts operated by that company.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class Company(Base, TimestampMixin):
|
||||
"""
|
||||
Represents a company (business entity) in the system.
|
||||
|
||||
A company owns one or more vendor brands. All business/contact information
|
||||
is stored at the company level to avoid duplication.
|
||||
"""
|
||||
|
||||
__tablename__ = "companies"
|
||||
|
||||
# ========================================================================
|
||||
# Basic Information
|
||||
# ========================================================================
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
"""Unique identifier for the company."""
|
||||
|
||||
name = Column(String, nullable=False, index=True)
|
||||
"""Company legal/business name."""
|
||||
|
||||
description = Column(Text)
|
||||
"""Optional description of the company."""
|
||||
|
||||
# ========================================================================
|
||||
# Ownership
|
||||
# ========================================================================
|
||||
owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
"""Foreign key to the user who owns this company."""
|
||||
|
||||
# ========================================================================
|
||||
# Contact Information
|
||||
# ========================================================================
|
||||
contact_email = Column(String, nullable=False)
|
||||
"""Primary business contact email."""
|
||||
|
||||
contact_phone = Column(String)
|
||||
"""Business phone number."""
|
||||
|
||||
website = Column(String)
|
||||
"""Company website URL."""
|
||||
|
||||
# ========================================================================
|
||||
# Business Details
|
||||
# ========================================================================
|
||||
business_address = Column(Text)
|
||||
"""Physical business address."""
|
||||
|
||||
tax_number = Column(String)
|
||||
"""Tax/VAT registration number."""
|
||||
|
||||
# ========================================================================
|
||||
# Status Flags
|
||||
# ========================================================================
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
"""Whether the company is active. Affects all associated vendors."""
|
||||
|
||||
is_verified = Column(Boolean, default=False, nullable=False)
|
||||
"""Whether the company has been verified by platform admins."""
|
||||
|
||||
# ========================================================================
|
||||
# Relationships
|
||||
# ========================================================================
|
||||
owner = relationship("User", back_populates="owned_companies")
|
||||
"""The user who owns this company."""
|
||||
|
||||
vendors = relationship(
|
||||
"Vendor",
|
||||
back_populates="company",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Vendor.name",
|
||||
)
|
||||
"""All vendor brands operated by this company."""
|
||||
|
||||
def __repr__(self):
|
||||
"""String representation of the Company object."""
|
||||
return f"<Company(id={self.id}, name='{self.name}', vendors={len(self.vendors) if self.vendors else 0})>"
|
||||
|
||||
# ========================================================================
|
||||
# Helper Properties
|
||||
# ========================================================================
|
||||
|
||||
@property
|
||||
def vendor_count(self) -> int:
|
||||
"""Get the number of vendors belonging to this company."""
|
||||
return len(self.vendors) if self.vendors else 0
|
||||
|
||||
@property
|
||||
def active_vendor_count(self) -> int:
|
||||
"""Get the number of active vendors belonging to this company."""
|
||||
if not self.vendors:
|
||||
return 0
|
||||
return sum(1 for v in self.vendors if v.is_active)
|
||||
@@ -1,306 +0,0 @@
|
||||
# models/database/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,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Session, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
from .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()
|
||||
@@ -1,127 +0,0 @@
|
||||
# models/database/media.py
|
||||
"""
|
||||
CORE media file model for vendor media library.
|
||||
|
||||
This is a CORE framework model used across multiple modules.
|
||||
MediaFile provides vendor-uploaded media files (images, documents, videos).
|
||||
|
||||
For product-media associations, use:
|
||||
from app.modules.catalog.models import ProductMedia
|
||||
|
||||
Files are stored in vendor-specific directories:
|
||||
uploads/vendors/{vendor_id}/{folder}/{filename}
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class MediaFile(Base, TimestampMixin):
|
||||
"""Vendor media file record.
|
||||
|
||||
Stores metadata about uploaded files. Actual files are stored
|
||||
in the filesystem at uploads/vendors/{vendor_id}/{folder}/
|
||||
"""
|
||||
|
||||
__tablename__ = "media_files"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
||||
|
||||
# File identification
|
||||
filename = Column(String(255), nullable=False) # Stored filename (UUID-based)
|
||||
original_filename = Column(String(255)) # Original uploaded filename
|
||||
file_path = Column(String(500), nullable=False) # Relative path from uploads/
|
||||
|
||||
# File properties
|
||||
media_type = Column(String(20), nullable=False) # image, video, document
|
||||
mime_type = Column(String(100))
|
||||
file_size = Column(Integer) # bytes
|
||||
|
||||
# Image/video dimensions
|
||||
width = Column(Integer)
|
||||
height = Column(Integer)
|
||||
|
||||
# Thumbnail (for images/videos)
|
||||
thumbnail_path = Column(String(500))
|
||||
|
||||
# Metadata
|
||||
alt_text = Column(String(500))
|
||||
description = Column(Text)
|
||||
folder = Column(String(100), default="general") # products, general, etc.
|
||||
tags = Column(JSON) # List of tags for categorization
|
||||
extra_metadata = Column(JSON) # Additional metadata (EXIF, etc.)
|
||||
|
||||
# Status
|
||||
is_optimized = Column(Boolean, default=False)
|
||||
optimized_size = Column(Integer) # Size after optimization
|
||||
|
||||
# Usage tracking
|
||||
usage_count = Column(Integer, default=0) # How many times used
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor", back_populates="media_files")
|
||||
# ProductMedia relationship uses string reference to avoid circular import
|
||||
product_associations = relationship(
|
||||
"ProductMedia",
|
||||
back_populates="media",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_media_vendor_id", "vendor_id"),
|
||||
Index("idx_media_vendor_folder", "vendor_id", "folder"),
|
||||
Index("idx_media_vendor_type", "vendor_id", "media_type"),
|
||||
Index("idx_media_filename", "filename"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<MediaFile(id={self.id}, vendor_id={self.vendor_id}, "
|
||||
f"filename='{self.filename}', type='{self.media_type}')>"
|
||||
)
|
||||
|
||||
@property
|
||||
def file_url(self) -> str:
|
||||
"""Get the public URL for this file."""
|
||||
return f"/uploads/{self.file_path}"
|
||||
|
||||
@property
|
||||
def thumbnail_url(self) -> str | None:
|
||||
"""Get the thumbnail URL if available."""
|
||||
if self.thumbnail_path:
|
||||
return f"/uploads/{self.thumbnail_path}"
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_image(self) -> bool:
|
||||
"""Check if this is an image file."""
|
||||
return self.media_type == "image"
|
||||
|
||||
@property
|
||||
def is_video(self) -> bool:
|
||||
"""Check if this is a video file."""
|
||||
return self.media_type == "video"
|
||||
|
||||
@property
|
||||
def is_document(self) -> bool:
|
||||
"""Check if this is a document file."""
|
||||
return self.media_type == "document"
|
||||
|
||||
|
||||
# Re-export ProductMedia from its canonical location for backwards compatibility
|
||||
from app.modules.catalog.models import ProductMedia # noqa: E402, F401
|
||||
|
||||
__all__ = ["MediaFile", "ProductMedia"]
|
||||
@@ -1,239 +0,0 @@
|
||||
# models/database/platform.py
|
||||
"""
|
||||
Platform model representing a business offering/product line.
|
||||
|
||||
Platforms are independent business products (e.g., OMS, Loyalty Program, Site Builder)
|
||||
that can have their own:
|
||||
- Marketing pages (homepage, pricing, about)
|
||||
- Vendor default pages (fallback storefront pages)
|
||||
- Subscription tiers with platform-specific features
|
||||
- Branding and configuration
|
||||
|
||||
Each vendor can belong to multiple platforms via the VendorPlatform junction table.
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Column,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class Platform(Base, TimestampMixin):
|
||||
"""
|
||||
Represents a business offering/product line.
|
||||
|
||||
Examples:
|
||||
- Wizamart OMS (Order Management System)
|
||||
- Loyalty+ (Loyalty Program Platform)
|
||||
- Site Builder (Website Builder for Local Businesses)
|
||||
|
||||
Each platform has:
|
||||
- Its own domain (production) or path prefix (development)
|
||||
- Independent CMS pages (marketing pages + vendor defaults)
|
||||
- Platform-specific subscription tiers
|
||||
- Custom branding and theme
|
||||
"""
|
||||
|
||||
__tablename__ = "platforms"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# ========================================================================
|
||||
# Identity
|
||||
# ========================================================================
|
||||
|
||||
code = Column(
|
||||
String(50),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Unique platform identifier (e.g., 'oms', 'loyalty', 'sites')",
|
||||
)
|
||||
|
||||
name = Column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
comment="Display name (e.g., 'Wizamart OMS')",
|
||||
)
|
||||
|
||||
description = Column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="Platform description for admin/marketing purposes",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Domain Routing
|
||||
# ========================================================================
|
||||
|
||||
domain = Column(
|
||||
String(255),
|
||||
unique=True,
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Production domain (e.g., 'oms.lu', 'loyalty.lu')",
|
||||
)
|
||||
|
||||
path_prefix = Column(
|
||||
String(50),
|
||||
unique=True,
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Development path prefix (e.g., 'oms' for localhost:9999/oms/*)",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Branding
|
||||
# ========================================================================
|
||||
|
||||
logo = Column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
comment="Logo URL for light mode",
|
||||
)
|
||||
|
||||
logo_dark = Column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
comment="Logo URL for dark mode",
|
||||
)
|
||||
|
||||
favicon = Column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
comment="Favicon URL",
|
||||
)
|
||||
|
||||
theme_config = Column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
default=dict,
|
||||
comment="Theme configuration (colors, fonts, etc.)",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Localization
|
||||
# ========================================================================
|
||||
|
||||
default_language = Column(
|
||||
String(5),
|
||||
default="fr",
|
||||
nullable=False,
|
||||
comment="Default language code (e.g., 'fr', 'en', 'de')",
|
||||
)
|
||||
|
||||
supported_languages = Column(
|
||||
JSON,
|
||||
default=["fr", "de", "en"],
|
||||
nullable=False,
|
||||
comment="List of supported language codes",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Status
|
||||
# ========================================================================
|
||||
|
||||
is_active = Column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
comment="Whether the platform is active and accessible",
|
||||
)
|
||||
|
||||
is_public = Column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
comment="Whether the platform is visible in public listings",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Configuration
|
||||
# ========================================================================
|
||||
|
||||
settings = Column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
default=dict,
|
||||
comment="Platform-specific settings and feature flags",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Relationships
|
||||
# ========================================================================
|
||||
|
||||
# Content pages belonging to this platform
|
||||
content_pages = relationship(
|
||||
"ContentPage",
|
||||
back_populates="platform",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Vendors on this platform (via junction table)
|
||||
vendor_platforms = relationship(
|
||||
"VendorPlatform",
|
||||
back_populates="platform",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Subscription tiers for this platform
|
||||
subscription_tiers = relationship(
|
||||
"SubscriptionTier",
|
||||
back_populates="platform",
|
||||
foreign_keys="SubscriptionTier.platform_id",
|
||||
)
|
||||
|
||||
# Admin assignments for this platform
|
||||
admin_platforms = relationship(
|
||||
"AdminPlatform",
|
||||
back_populates="platform",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Menu visibility configuration for platform admins
|
||||
menu_configs = relationship(
|
||||
"AdminMenuConfig",
|
||||
back_populates="platform",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Module enablement configuration
|
||||
modules = relationship(
|
||||
"PlatformModule",
|
||||
back_populates="platform",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Indexes
|
||||
# ========================================================================
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_platform_active", "is_active"),
|
||||
Index("idx_platform_public", "is_public", "is_active"),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Properties
|
||||
# ========================================================================
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
"""Get the base URL for this platform (for link generation)."""
|
||||
if self.domain:
|
||||
return f"https://{self.domain}"
|
||||
if self.path_prefix:
|
||||
return f"/{self.path_prefix}"
|
||||
return "/"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Platform(code='{self.code}', name='{self.name}')>"
|
||||
@@ -1,162 +0,0 @@
|
||||
# models/database/platform_module.py
|
||||
"""
|
||||
PlatformModule model for tracking module enablement per platform.
|
||||
|
||||
This junction table provides:
|
||||
- Auditability: Track when modules were enabled/disabled and by whom
|
||||
- Configuration: Per-module settings specific to each platform
|
||||
- State tracking: Explicit enabled/disabled states with timestamps
|
||||
|
||||
Replaces the simpler Platform.settings["enabled_modules"] JSON approach
|
||||
for better auditability and query capabilities.
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class PlatformModule(Base, TimestampMixin):
|
||||
"""
|
||||
Junction table tracking module enablement per platform.
|
||||
|
||||
This provides a normalized, auditable way to track which modules
|
||||
are enabled for each platform, with configuration options.
|
||||
|
||||
Example:
|
||||
PlatformModule(
|
||||
platform_id=1,
|
||||
module_code="billing",
|
||||
is_enabled=True,
|
||||
enabled_at=datetime.now(),
|
||||
enabled_by_user_id=42,
|
||||
config={"stripe_mode": "live", "default_trial_days": 14}
|
||||
)
|
||||
"""
|
||||
|
||||
__tablename__ = "platform_modules"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# ========================================================================
|
||||
# Identity
|
||||
# ========================================================================
|
||||
|
||||
platform_id = Column(
|
||||
Integer,
|
||||
ForeignKey("platforms.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="Platform this module configuration belongs to",
|
||||
)
|
||||
|
||||
module_code = Column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
comment="Module code (e.g., 'billing', 'inventory', 'orders')",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# State
|
||||
# ========================================================================
|
||||
|
||||
is_enabled = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
comment="Whether this module is currently enabled for the platform",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Audit Trail - Enable
|
||||
# ========================================================================
|
||||
|
||||
enabled_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="When the module was last enabled",
|
||||
)
|
||||
|
||||
enabled_by_user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
comment="User who enabled the module",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Audit Trail - Disable
|
||||
# ========================================================================
|
||||
|
||||
disabled_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="When the module was last disabled",
|
||||
)
|
||||
|
||||
disabled_by_user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
comment="User who disabled the module",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Configuration
|
||||
# ========================================================================
|
||||
|
||||
config = Column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
default=dict,
|
||||
comment="Module-specific configuration for this platform",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Relationships
|
||||
# ========================================================================
|
||||
|
||||
platform = relationship(
|
||||
"Platform",
|
||||
back_populates="modules",
|
||||
)
|
||||
|
||||
enabled_by = relationship(
|
||||
"User",
|
||||
foreign_keys=[enabled_by_user_id],
|
||||
)
|
||||
|
||||
disabled_by = relationship(
|
||||
"User",
|
||||
foreign_keys=[disabled_by_user_id],
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Constraints & Indexes
|
||||
# ========================================================================
|
||||
|
||||
__table_args__ = (
|
||||
# Each platform can only have one configuration per module
|
||||
UniqueConstraint("platform_id", "module_code", name="uq_platform_module"),
|
||||
# Index for querying by platform
|
||||
Index("idx_platform_module_platform_id", "platform_id"),
|
||||
# Index for querying by module code
|
||||
Index("idx_platform_module_code", "module_code"),
|
||||
# Index for querying enabled modules
|
||||
Index("idx_platform_module_enabled", "platform_id", "is_enabled"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "enabled" if self.is_enabled else "disabled"
|
||||
return f"<PlatformModule(platform_id={self.platform_id}, module='{self.module_code}', {status})>"
|
||||
@@ -1,199 +0,0 @@
|
||||
# models/database/user.py - IMPROVED VERSION
|
||||
"""
|
||||
User model with authentication support.
|
||||
|
||||
ROLE CLARIFICATION:
|
||||
- User.role should ONLY contain platform-level roles:
|
||||
* "admin" - Platform administrator (full system access)
|
||||
* "vendor" - Any user who owns or is part of a vendor team
|
||||
|
||||
- Vendor-specific roles (manager, staff, etc.) are stored in VendorUser.role
|
||||
- Customers are NOT in the User table - they use the Customer model
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
"""Platform-level user roles."""
|
||||
|
||||
ADMIN = "admin" # Platform administrator
|
||||
VENDOR = "vendor" # Vendor owner or team member
|
||||
|
||||
|
||||
class User(Base, TimestampMixin):
|
||||
"""Represents a platform user (admins and vendors only)."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
username = Column(String, unique=True, index=True, nullable=False)
|
||||
first_name = Column(String)
|
||||
last_name = Column(String)
|
||||
hashed_password = Column(String, nullable=False)
|
||||
|
||||
# Platform-level role only (admin or vendor)
|
||||
role = Column(String, nullable=False, default=UserRole.VENDOR.value)
|
||||
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
is_email_verified = Column(Boolean, default=False, nullable=False)
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
|
||||
# Super admin flag (only meaningful when role='admin')
|
||||
# Super admins have access to ALL platforms and global settings
|
||||
# Platform admins (is_super_admin=False) are assigned to specific platforms
|
||||
is_super_admin = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Language preference (NULL = use context default: vendor dashboard_language or system default)
|
||||
# Supported: en, fr, de, lb
|
||||
preferred_language = Column(String(5), nullable=True)
|
||||
|
||||
# Relationships
|
||||
marketplace_import_jobs = relationship(
|
||||
"MarketplaceImportJob", back_populates="user"
|
||||
)
|
||||
owned_companies = relationship("Company", back_populates="owner")
|
||||
vendor_memberships = relationship(
|
||||
"VendorUser", foreign_keys="[VendorUser.user_id]", back_populates="user"
|
||||
)
|
||||
|
||||
# Admin-platform assignments (for platform admins only)
|
||||
# Super admins don't need assignments - they have access to all platforms
|
||||
admin_platforms = relationship(
|
||||
"AdminPlatform",
|
||||
foreign_keys="AdminPlatform.user_id",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Menu visibility configuration (for super admins only)
|
||||
# Platform admins get menu config from their platform, not user-level
|
||||
menu_configs = relationship(
|
||||
"AdminMenuConfig",
|
||||
foreign_keys="AdminMenuConfig.user_id",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
"""String representation of the User object."""
|
||||
return f"<User(id={self.id}, username='{self.username}', email='{self.email}', role='{self.role}')>"
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
"""Returns the full name of the user, combining first and last names if available."""
|
||||
if self.first_name and self.last_name:
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
return self.username
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""Check if user is a platform admin."""
|
||||
return self.role == UserRole.ADMIN.value
|
||||
|
||||
@property
|
||||
def is_vendor(self) -> bool:
|
||||
"""Check if user is a vendor (owner or team member)."""
|
||||
return self.role == UserRole.VENDOR.value
|
||||
|
||||
def is_owner_of(self, vendor_id: int) -> bool:
|
||||
"""
|
||||
Check if user is the owner of a specific vendor.
|
||||
|
||||
Ownership is determined via company ownership:
|
||||
User owns Company → Company has Vendor → User owns Vendor
|
||||
"""
|
||||
for company in self.owned_companies:
|
||||
if any(v.id == vendor_id for v in company.vendors):
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_member_of(self, vendor_id: int) -> bool:
|
||||
"""Check if user is a member of a specific vendor (owner or team)."""
|
||||
# Check if owner (via company)
|
||||
if self.is_owner_of(vendor_id):
|
||||
return True
|
||||
# Check if team member
|
||||
return any(
|
||||
vm.vendor_id == vendor_id and vm.is_active for vm in self.vendor_memberships
|
||||
)
|
||||
|
||||
def get_vendor_role(self, vendor_id: int) -> str:
|
||||
"""Get user's role within a specific vendor."""
|
||||
# Check if owner (via company)
|
||||
if self.is_owner_of(vendor_id):
|
||||
return "owner"
|
||||
|
||||
# Check team membership
|
||||
for vm in self.vendor_memberships:
|
||||
if vm.vendor_id == vendor_id and vm.is_active:
|
||||
return vm.role.name if vm.role else "member"
|
||||
|
||||
return None
|
||||
|
||||
def has_vendor_permission(self, vendor_id: int, permission: str) -> bool:
|
||||
"""Check if user has a specific permission in a vendor."""
|
||||
# Owners have all permissions
|
||||
if self.is_owner_of(vendor_id):
|
||||
return True
|
||||
|
||||
# Check team member permissions
|
||||
for vm in self.vendor_memberships:
|
||||
if vm.vendor_id == vendor_id and vm.is_active:
|
||||
if vm.role and permission in vm.role.permissions:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# Admin Platform Access Methods
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def is_super_admin_user(self) -> bool:
|
||||
"""Check if user is a super admin (can access all platforms)."""
|
||||
return self.role == UserRole.ADMIN.value and self.is_super_admin
|
||||
|
||||
@property
|
||||
def is_platform_admin(self) -> bool:
|
||||
"""Check if user is a platform admin (access to assigned platforms only)."""
|
||||
return self.role == UserRole.ADMIN.value and not self.is_super_admin
|
||||
|
||||
def can_access_platform(self, platform_id: int) -> bool:
|
||||
"""
|
||||
Check if admin can access a specific platform.
|
||||
|
||||
- Super admins can access all platforms
|
||||
- Platform admins can only access assigned platforms
|
||||
- Non-admins return False
|
||||
"""
|
||||
if not self.is_admin:
|
||||
return False
|
||||
if self.is_super_admin:
|
||||
return True
|
||||
return any(
|
||||
ap.platform_id == platform_id and ap.is_active
|
||||
for ap in self.admin_platforms
|
||||
)
|
||||
|
||||
def get_accessible_platform_ids(self) -> list[int] | None:
|
||||
"""
|
||||
Get list of platform IDs this admin can access.
|
||||
|
||||
Returns:
|
||||
- None for super admins (means ALL platforms)
|
||||
- List of platform IDs for platform admins
|
||||
- Empty list for non-admins
|
||||
"""
|
||||
if not self.is_admin:
|
||||
return []
|
||||
if self.is_super_admin:
|
||||
return None # None means ALL platforms
|
||||
return [ap.platform_id for ap in self.admin_platforms if ap.is_active]
|
||||
@@ -1,568 +0,0 @@
|
||||
# models/database/vendor.py
|
||||
"""
|
||||
Vendor model representing entities that sell products or services.
|
||||
|
||||
This module defines the Vendor model along with its relationships to
|
||||
other models such as User (owner), Product, Customer, Order, and MarketplaceImportJob.
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# Import Base from the central database module instead of creating a new one
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class Vendor(Base, TimestampMixin):
|
||||
"""Represents a vendor in the system."""
|
||||
|
||||
__tablename__ = "vendors" # Name of the table in the database
|
||||
|
||||
id = Column(
|
||||
Integer, primary_key=True, index=True
|
||||
) # Primary key and indexed column for vendor ID
|
||||
|
||||
# Company relationship
|
||||
company_id = Column(
|
||||
Integer, ForeignKey("companies.id"), nullable=False, index=True
|
||||
) # Foreign key to the parent company
|
||||
|
||||
vendor_code = Column(
|
||||
String, unique=True, index=True, nullable=False
|
||||
) # Unique, indexed, non-nullable vendor code column
|
||||
subdomain = Column(
|
||||
String(100), unique=True, nullable=False, index=True
|
||||
) # Unique, non-nullable subdomain column with indexing
|
||||
name = Column(
|
||||
String, nullable=False
|
||||
) # Non-nullable name column for the vendor (brand name)
|
||||
description = Column(Text) # Optional text description column for the vendor
|
||||
|
||||
# Letzshop URLs - multi-language support (brand-specific marketplace feeds)
|
||||
letzshop_csv_url_fr = Column(String) # URL for French CSV in Letzshop
|
||||
letzshop_csv_url_en = Column(String) # URL for English CSV in Letzshop
|
||||
letzshop_csv_url_de = Column(String) # URL for German CSV in Letzshop
|
||||
|
||||
# Letzshop Vendor Identity (for linking to Letzshop marketplace profile)
|
||||
letzshop_vendor_id = Column(
|
||||
String(100), unique=True, nullable=True, index=True
|
||||
) # Letzshop's vendor identifier
|
||||
letzshop_vendor_slug = Column(
|
||||
String(200), nullable=True, index=True
|
||||
) # Letzshop shop URL slug (e.g., "my-shop" from letzshop.lu/vendors/my-shop)
|
||||
|
||||
# ========================================================================
|
||||
# Letzshop Feed Settings (atalanda namespace)
|
||||
# ========================================================================
|
||||
# These are default values applied to all products in the Letzshop feed
|
||||
# See https://letzshop.lu/en/dev#google_csv for documentation
|
||||
|
||||
# Default VAT rate for new products: 0 (exempt), 3 (super-reduced), 8 (reduced), 14 (intermediate), 17 (standard)
|
||||
letzshop_default_tax_rate = Column(Integer, default=17, nullable=False)
|
||||
|
||||
# Product sort priority on Letzshop (0.0-10.0, higher = displayed first)
|
||||
# Note: Having all products rated above 7 is not permitted by Letzshop
|
||||
letzshop_boost_sort = Column(String(10), default="5.0") # Stored as string for precision
|
||||
|
||||
# Delivery method: 'nationwide', 'package_delivery', 'self_collect' (comma-separated for multiple)
|
||||
# 'nationwide' automatically includes package_delivery and self_collect
|
||||
letzshop_delivery_method = Column(String(100), default="package_delivery")
|
||||
|
||||
# Pre-order days: number of days before item ships (default 1 day)
|
||||
letzshop_preorder_days = Column(Integer, default=1)
|
||||
|
||||
# Status (vendor-specific, can differ from company status)
|
||||
is_active = Column(
|
||||
Boolean, default=True
|
||||
) # Boolean to indicate if the vendor brand is active
|
||||
is_verified = Column(
|
||||
Boolean, default=False
|
||||
) # Boolean to indicate if the vendor brand is verified
|
||||
|
||||
# ========================================================================
|
||||
# Contact Information (nullable = inherit from company)
|
||||
# ========================================================================
|
||||
# These fields allow vendor-specific branding/identity.
|
||||
# If null, the value is inherited from the parent company.
|
||||
contact_email = Column(String(255), nullable=True) # Override company contact email
|
||||
contact_phone = Column(String(50), nullable=True) # Override company contact phone
|
||||
website = Column(String(255), nullable=True) # Override company website
|
||||
business_address = Column(Text, nullable=True) # Override company business address
|
||||
tax_number = Column(String(100), nullable=True) # Override company tax number
|
||||
|
||||
# ========================================================================
|
||||
# Language Settings
|
||||
# ========================================================================
|
||||
# Supported languages: en, fr, de, lb (Luxembourgish)
|
||||
default_language = Column(
|
||||
String(5), nullable=False, default="fr"
|
||||
) # Default language for vendor content (products, emails, etc.)
|
||||
dashboard_language = Column(
|
||||
String(5), nullable=False, default="fr"
|
||||
) # Language for vendor team dashboard UI
|
||||
storefront_language = Column(
|
||||
String(5), nullable=False, default="fr"
|
||||
) # Default language for customer-facing storefront
|
||||
storefront_languages = Column(
|
||||
JSON, nullable=False, default=["fr", "de", "en"]
|
||||
) # Array of enabled languages for storefront language selector
|
||||
|
||||
# Currency/number formatting locale (e.g., 'fr-LU' = "29,99 €", 'en-GB' = "€29.99")
|
||||
# NULL means inherit from platform default (AdminSetting 'default_storefront_locale')
|
||||
storefront_locale = Column(String(10), nullable=True)
|
||||
|
||||
# ========================================================================
|
||||
# Relationships
|
||||
# ========================================================================
|
||||
company = relationship(
|
||||
"Company", back_populates="vendors"
|
||||
) # Relationship with Company model for the parent company
|
||||
vendor_users = relationship(
|
||||
"VendorUser", back_populates="vendor"
|
||||
) # Relationship with VendorUser model for users in this vendor
|
||||
products = relationship(
|
||||
"Product", back_populates="vendor"
|
||||
) # Relationship with Product model for products of this vendor
|
||||
customers = relationship(
|
||||
"Customer", back_populates="vendor"
|
||||
) # Relationship with Customer model for customers of this vendor
|
||||
orders = relationship(
|
||||
"Order", back_populates="vendor"
|
||||
) # Relationship with Order model for orders placed by this vendor
|
||||
marketplace_import_jobs = relationship(
|
||||
"MarketplaceImportJob", back_populates="vendor"
|
||||
) # Relationship with MarketplaceImportJob model for import jobs related to this vendor
|
||||
|
||||
# Letzshop integration credentials (one-to-one)
|
||||
letzshop_credentials = relationship(
|
||||
"VendorLetzshopCredentials",
|
||||
back_populates="vendor",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Invoice settings (one-to-one)
|
||||
invoice_settings = relationship(
|
||||
"VendorInvoiceSettings",
|
||||
back_populates="vendor",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Invoices (one-to-many)
|
||||
invoices = relationship(
|
||||
"Invoice",
|
||||
back_populates="vendor",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Email template overrides (one-to-many)
|
||||
email_templates = relationship(
|
||||
"VendorEmailTemplate",
|
||||
back_populates="vendor",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Email settings (one-to-one) - vendor SMTP/provider configuration
|
||||
email_settings = relationship(
|
||||
"VendorEmailSettings",
|
||||
back_populates="vendor",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Subscription (one-to-one)
|
||||
subscription = relationship(
|
||||
"VendorSubscription",
|
||||
back_populates="vendor",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Add-ons purchased by vendor (one-to-many)
|
||||
addons = relationship(
|
||||
"VendorAddOn",
|
||||
back_populates="vendor",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Billing/invoice history (one-to-many)
|
||||
billing_history = relationship(
|
||||
"BillingHistory",
|
||||
back_populates="vendor",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="BillingHistory.invoice_date.desc()",
|
||||
)
|
||||
|
||||
domains = relationship(
|
||||
"VendorDomain",
|
||||
back_populates="vendor",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="VendorDomain.is_primary.desc()",
|
||||
) # Relationship with VendorDomain model for custom domains of the vendor
|
||||
|
||||
# Single theme relationship (ONE vendor = ONE theme)
|
||||
# A vendor has ONE active theme stored in the vendor_themes table.
|
||||
# Theme presets available: default, modern, classic, minimal, vibrant
|
||||
vendor_theme = relationship(
|
||||
"VendorTheme",
|
||||
back_populates="vendor",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
) # Relationship with VendorTheme model for the active theme of the vendor
|
||||
|
||||
# Content pages relationship (vendor can override platform default pages)
|
||||
content_pages = relationship(
|
||||
"ContentPage", back_populates="vendor", cascade="all, delete-orphan"
|
||||
) # Relationship with ContentPage model for vendor-specific content pages
|
||||
|
||||
# Onboarding progress (one-to-one)
|
||||
onboarding = relationship(
|
||||
"VendorOnboarding",
|
||||
back_populates="vendor",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Media library (one-to-many)
|
||||
media_files = relationship(
|
||||
"MediaFile",
|
||||
back_populates="vendor",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Platform memberships (many-to-many via junction table)
|
||||
vendor_platforms = relationship(
|
||||
"VendorPlatform",
|
||||
back_populates="vendor",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Loyalty program (one-to-one)
|
||||
loyalty_program = relationship(
|
||||
"LoyaltyProgram",
|
||||
back_populates="vendor",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
"""String representation of the Vendor object."""
|
||||
return f"<Vendor(id={self.id}, vendor_code='{self.vendor_code}', name='{self.name}', subdomain='{self.subdomain}')>"
|
||||
|
||||
# ========================================================================
|
||||
# Theme Helper Methods to get active theme and other related information
|
||||
# ========================================================================
|
||||
|
||||
def get_effective_theme(self) -> dict:
|
||||
"""
|
||||
Get active theme for this vendor.
|
||||
|
||||
Returns theme from vendor_themes table, or default theme if not set.
|
||||
|
||||
Returns:
|
||||
dict: Theme configuration with colors, fonts, layout, etc.
|
||||
"""
|
||||
# Check vendor_themes table
|
||||
if self.vendor_theme and self.vendor_theme.is_active:
|
||||
return self.vendor_theme.to_dict()
|
||||
|
||||
# Return default theme
|
||||
return self._get_default_theme()
|
||||
|
||||
def _get_default_theme(self) -> dict:
|
||||
"""Return the default theme configuration."""
|
||||
return {
|
||||
"theme_name": "default",
|
||||
"colors": {
|
||||
"primary": "#6366f1",
|
||||
"secondary": "#8b5cf6",
|
||||
"accent": "#ec4899",
|
||||
"background": "#ffffff",
|
||||
"text": "#1f2937",
|
||||
"border": "#e5e7eb",
|
||||
},
|
||||
"fonts": {"heading": "Inter, sans-serif", "body": "Inter, sans-serif"},
|
||||
"branding": {
|
||||
"logo": None,
|
||||
"logo_dark": None,
|
||||
"favicon": None,
|
||||
"banner": None,
|
||||
},
|
||||
"layout": {"style": "grid", "header": "fixed", "product_card": "modern"},
|
||||
"social_links": {},
|
||||
"custom_css": None,
|
||||
"css_variables": {
|
||||
"--color-primary": "#6366f1",
|
||||
"--color-secondary": "#8b5cf6",
|
||||
"--color-accent": "#ec4899",
|
||||
"--color-background": "#ffffff",
|
||||
"--color-text": "#1f2937",
|
||||
"--color-border": "#e5e7eb",
|
||||
"--font-heading": "Inter, sans-serif",
|
||||
"--font-body": "Inter, sans-serif",
|
||||
},
|
||||
}
|
||||
|
||||
def get_primary_color(self) -> str:
|
||||
"""Get primary color from active theme."""
|
||||
theme = self.get_effective_theme()
|
||||
return theme.get("colors", {}).get(
|
||||
"primary", "#6366f1"
|
||||
) # Default to default theme if not found
|
||||
|
||||
def get_logo_url(self) -> str:
|
||||
"""Get logo URL from active theme."""
|
||||
theme = self.get_effective_theme()
|
||||
return theme.get("branding", {}).get(
|
||||
"logo"
|
||||
) # Return None or the logo URL if found
|
||||
|
||||
# ========================================================================
|
||||
# Domain Helper Methods
|
||||
# ========================================================================
|
||||
|
||||
@property
|
||||
def primary_domain(self):
|
||||
"""Get the primary custom domain for this vendor."""
|
||||
for domain in self.domains:
|
||||
if domain.is_primary and domain.is_active:
|
||||
return domain.domain # Return the domain if it's primary and active
|
||||
return None
|
||||
|
||||
@property
|
||||
def all_domains(self):
|
||||
"""Get all active domains (subdomain + custom domains)."""
|
||||
domains = [
|
||||
f"{self.subdomain}.{settings.platform_domain}"
|
||||
] # Start with the main subdomain
|
||||
for domain in self.domains:
|
||||
if domain.is_active:
|
||||
domains.append(domain.domain) # Add other active custom domains
|
||||
return domains
|
||||
|
||||
# ========================================================================
|
||||
# Contact Resolution Helper Properties
|
||||
# ========================================================================
|
||||
# These properties return the effective value (vendor override or company fallback)
|
||||
|
||||
@property
|
||||
def effective_contact_email(self) -> str | None:
|
||||
"""Get contact email (vendor override or company fallback)."""
|
||||
if self.contact_email is not None:
|
||||
return self.contact_email
|
||||
return self.company.contact_email if self.company else None
|
||||
|
||||
@property
|
||||
def effective_contact_phone(self) -> str | None:
|
||||
"""Get contact phone (vendor override or company fallback)."""
|
||||
if self.contact_phone is not None:
|
||||
return self.contact_phone
|
||||
return self.company.contact_phone if self.company else None
|
||||
|
||||
@property
|
||||
def effective_website(self) -> str | None:
|
||||
"""Get website (vendor override or company fallback)."""
|
||||
if self.website is not None:
|
||||
return self.website
|
||||
return self.company.website if self.company else None
|
||||
|
||||
@property
|
||||
def effective_business_address(self) -> str | None:
|
||||
"""Get business address (vendor override or company fallback)."""
|
||||
if self.business_address is not None:
|
||||
return self.business_address
|
||||
return self.company.business_address if self.company else None
|
||||
|
||||
@property
|
||||
def effective_tax_number(self) -> str | None:
|
||||
"""Get tax number (vendor override or company fallback)."""
|
||||
if self.tax_number is not None:
|
||||
return self.tax_number
|
||||
return self.company.tax_number if self.company else None
|
||||
|
||||
def get_contact_info_with_inheritance(self) -> dict:
|
||||
"""
|
||||
Get all contact info with inheritance flags.
|
||||
|
||||
Returns dict with resolved values and flags indicating if inherited from company.
|
||||
"""
|
||||
company = self.company
|
||||
return {
|
||||
"contact_email": self.effective_contact_email,
|
||||
"contact_email_inherited": self.contact_email is None
|
||||
and company is not None,
|
||||
"contact_phone": self.effective_contact_phone,
|
||||
"contact_phone_inherited": self.contact_phone is None
|
||||
and company is not None,
|
||||
"website": self.effective_website,
|
||||
"website_inherited": self.website is None and company is not None,
|
||||
"business_address": self.effective_business_address,
|
||||
"business_address_inherited": self.business_address is None
|
||||
and company is not None,
|
||||
"tax_number": self.effective_tax_number,
|
||||
"tax_number_inherited": self.tax_number is None and company is not None,
|
||||
}
|
||||
|
||||
|
||||
class VendorUserType(str, enum.Enum):
|
||||
"""Types of vendor users."""
|
||||
|
||||
OWNER = "owner" # Vendor owner (full access to vendor area)
|
||||
TEAM_MEMBER = "member" # Team member (role-based access to vendor area)
|
||||
|
||||
|
||||
class VendorUser(Base, TimestampMixin):
|
||||
"""
|
||||
Represents a user's membership in a vendor.
|
||||
|
||||
- Owner: Created automatically when vendor is created
|
||||
- Team Member: Invited by owner via email
|
||||
"""
|
||||
|
||||
__tablename__ = "vendor_users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
"""Unique identifier for each VendorUser entry."""
|
||||
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
||||
"""Foreign key linking to the associated Vendor."""
|
||||
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
"""Foreign key linking to the associated User."""
|
||||
|
||||
# Distinguish between owner and team member
|
||||
user_type = Column(String, nullable=False, default=VendorUserType.TEAM_MEMBER.value)
|
||||
|
||||
# Role for team members (NULL for owners - they have all permissions)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)
|
||||
"""Foreign key linking to the associated Role."""
|
||||
|
||||
invited_by = Column(Integer, ForeignKey("users.id"))
|
||||
"""Foreign key linking to the user who invited this VendorUser."""
|
||||
invitation_token = Column(String, nullable=True, index=True) # For email activation
|
||||
invitation_sent_at = Column(DateTime, nullable=True)
|
||||
invitation_accepted_at = Column(DateTime, nullable=True)
|
||||
|
||||
is_active = Column(
|
||||
Boolean, default=False, nullable=False
|
||||
) # False until invitation accepted
|
||||
"""Indicates whether the VendorUser role is active."""
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor", back_populates="vendor_users")
|
||||
"""Relationship to the Vendor model, representing the associated vendor."""
|
||||
|
||||
user = relationship(
|
||||
"User", foreign_keys=[user_id], back_populates="vendor_memberships"
|
||||
)
|
||||
"""Relationship to the User model, representing the user who holds this role within the vendor."""
|
||||
|
||||
inviter = relationship("User", foreign_keys=[invited_by])
|
||||
"""Optional relationship to the User model, representing the user who invited this VendorUser."""
|
||||
|
||||
role = relationship("Role", back_populates="vendor_users")
|
||||
"""Relationship to the Role model, representing the role held by the vendor user."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the VendorUser instance.
|
||||
|
||||
Returns:
|
||||
str: A string that includes the vendor_id, the user_id and the user_type of the VendorUser instance.
|
||||
"""
|
||||
return f"<VendorUser(vendor_id={self.vendor_id}, user_id={self.user_id}, type={self.user_type})>"
|
||||
|
||||
@property
|
||||
def is_owner(self) -> bool:
|
||||
"""Check if this is an owner membership."""
|
||||
return self.user_type == VendorUserType.OWNER.value
|
||||
|
||||
@property
|
||||
def is_team_member(self) -> bool:
|
||||
"""Check if this is a team member (not owner)."""
|
||||
return self.user_type == VendorUserType.TEAM_MEMBER.value
|
||||
|
||||
@property
|
||||
def is_invitation_pending(self) -> bool:
|
||||
"""Check if invitation is still pending."""
|
||||
return self.invitation_token is not None and self.invitation_accepted_at is None
|
||||
|
||||
def has_permission(self, permission: str) -> bool:
|
||||
"""
|
||||
Check if user has a specific permission.
|
||||
|
||||
Owners always have all permissions.
|
||||
Team members check their role's permissions.
|
||||
"""
|
||||
# Owners have all permissions
|
||||
if self.is_owner:
|
||||
return True
|
||||
|
||||
# Inactive users have no permissions
|
||||
if not self.is_active:
|
||||
return False
|
||||
|
||||
# Check role permissions
|
||||
if self.role and self.role.permissions:
|
||||
return permission in self.role.permissions
|
||||
|
||||
return False
|
||||
|
||||
def get_all_permissions(self) -> list:
|
||||
"""Get all permissions this user has."""
|
||||
if self.is_owner:
|
||||
# Return all possible permissions
|
||||
from app.core.permissions import VendorPermissions
|
||||
|
||||
return list(VendorPermissions.__members__.values())
|
||||
|
||||
if self.role and self.role.permissions:
|
||||
return self.role.permissions
|
||||
|
||||
return []
|
||||
|
||||
|
||||
class Role(Base, TimestampMixin):
|
||||
"""Represents a role within a vendor's system."""
|
||||
|
||||
__tablename__ = "roles" # Name of the table in the database
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
"""Unique identifier for each Role entry."""
|
||||
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
||||
"""Foreign key linking to the associated Vendor."""
|
||||
|
||||
name = Column(String(100), nullable=False)
|
||||
"""Name of the role, with a maximum length of 100 characters."""
|
||||
|
||||
permissions = Column(JSON, default=list)
|
||||
"""Permissions assigned to this role, stored as a JSON array."""
|
||||
|
||||
vendor = relationship("Vendor")
|
||||
"""Relationship to the Vendor model, representing the associated vendor."""
|
||||
|
||||
vendor_users = relationship("VendorUser", back_populates="role")
|
||||
"""Back-relationship to the VendorUser model, representing users with this role."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the Role instance.
|
||||
|
||||
Returns:
|
||||
str: A string that includes the id and name of the Role instance.
|
||||
"""
|
||||
return f"<Role(id={self.id}, name='{self.name}', vendor_id={self.vendor_id})>"
|
||||
@@ -1,96 +0,0 @@
|
||||
# models/database/vendor_domain.py
|
||||
"""
|
||||
Vendor Domain Model - Maps custom domains to vendors
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class VendorDomain(Base, TimestampMixin):
|
||||
"""
|
||||
Maps custom domains to vendors for multi-domain routing.
|
||||
|
||||
Examples:
|
||||
- customdomain1.com → Vendor 1
|
||||
- shop.mybusiness.com → Vendor 2
|
||||
- www.customdomain1.com → Vendor 1 (www is stripped)
|
||||
"""
|
||||
|
||||
__tablename__ = "vendor_domains"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
vendor_id = Column(
|
||||
Integer, ForeignKey("vendors.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
# Domain configuration
|
||||
domain = Column(String(255), nullable=False, unique=True, index=True)
|
||||
is_primary = Column(Boolean, default=False, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# SSL/TLS status (for monitoring)
|
||||
ssl_status = Column(
|
||||
String(50), default="pending"
|
||||
) # pending, active, expired, error
|
||||
ssl_verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# DNS verification (to confirm domain ownership)
|
||||
verification_token = Column(String(100), unique=True, nullable=True)
|
||||
is_verified = Column(Boolean, default=False, nullable=False)
|
||||
verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor", back_populates="domains")
|
||||
|
||||
# Constraints
|
||||
__table_args__ = (
|
||||
UniqueConstraint("vendor_id", "domain", name="uq_vendor_domain"),
|
||||
Index("idx_domain_active", "domain", "is_active"),
|
||||
Index("idx_vendor_primary", "vendor_id", "is_primary"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<VendorDomain(domain='{self.domain}', vendor_id={self.vendor_id})>"
|
||||
|
||||
@property
|
||||
def full_url(self):
|
||||
"""Return full URL with https"""
|
||||
return f"https://{self.domain}"
|
||||
|
||||
@classmethod
|
||||
def normalize_domain(cls, domain: str) -> str:
|
||||
"""
|
||||
Normalize domain for consistent storage.
|
||||
|
||||
Examples:
|
||||
- https://example.com → example.com
|
||||
- www.example.com → example.com
|
||||
- EXAMPLE.COM → example.com
|
||||
"""
|
||||
# Remove protocol
|
||||
domain = domain.replace("https://", "").replace("http://", "") # noqa: SEC-034
|
||||
|
||||
# Remove trailing slash
|
||||
domain = domain.rstrip("/")
|
||||
|
||||
# Remove www prefix (optional - depends on your preference)
|
||||
# if domain.startswith("www."):
|
||||
# domain = domain[4:]
|
||||
|
||||
# Convert to lowercase
|
||||
domain = domain.lower()
|
||||
|
||||
return domain
|
||||
@@ -1,255 +0,0 @@
|
||||
# models/database/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,
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
# models/database/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,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Session, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
from .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
|
||||
@@ -1,189 +0,0 @@
|
||||
# models/database/vendor_platform.py
|
||||
"""
|
||||
VendorPlatform junction table for many-to-many relationship between Vendor and Platform.
|
||||
|
||||
A vendor CAN belong to multiple platforms (e.g., both OMS and Loyalty Program).
|
||||
Each membership can have:
|
||||
- Platform-specific subscription tier
|
||||
- Custom subdomain for that platform
|
||||
- Platform-specific settings
|
||||
- Active/inactive status
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class VendorPlatform(Base, TimestampMixin):
|
||||
"""
|
||||
Junction table linking vendors to platforms.
|
||||
|
||||
Allows a vendor to:
|
||||
- Subscribe to multiple platforms (OMS + Loyalty)
|
||||
- Have different tiers per platform
|
||||
- Have platform-specific subdomains
|
||||
- Store platform-specific settings
|
||||
|
||||
Example:
|
||||
- Vendor "WizaMart" is on OMS platform (Professional tier)
|
||||
- Vendor "WizaMart" is also on Loyalty platform (Basic tier)
|
||||
"""
|
||||
|
||||
__tablename__ = "vendor_platforms"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# ========================================================================
|
||||
# Foreign Keys
|
||||
# ========================================================================
|
||||
|
||||
vendor_id = Column(
|
||||
Integer,
|
||||
ForeignKey("vendors.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Reference to the vendor",
|
||||
)
|
||||
|
||||
platform_id = Column(
|
||||
Integer,
|
||||
ForeignKey("platforms.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Reference to the platform",
|
||||
)
|
||||
|
||||
tier_id = Column(
|
||||
Integer,
|
||||
ForeignKey("subscription_tiers.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Platform-specific subscription tier",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Membership Status
|
||||
# ========================================================================
|
||||
|
||||
is_active = Column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
comment="Whether the vendor is active on this platform",
|
||||
)
|
||||
|
||||
is_primary = Column(
|
||||
Boolean,
|
||||
default=False,
|
||||
nullable=False,
|
||||
comment="Whether this is the vendor's primary platform",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Platform-Specific Configuration
|
||||
# ========================================================================
|
||||
|
||||
custom_subdomain = Column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
comment="Platform-specific subdomain (if different from main subdomain)",
|
||||
)
|
||||
|
||||
settings = Column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
default=dict,
|
||||
comment="Platform-specific vendor settings",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Timestamps
|
||||
# ========================================================================
|
||||
|
||||
joined_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
nullable=False,
|
||||
comment="When the vendor joined this platform",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Relationships
|
||||
# ========================================================================
|
||||
|
||||
vendor = relationship(
|
||||
"Vendor",
|
||||
back_populates="vendor_platforms",
|
||||
)
|
||||
|
||||
platform = relationship(
|
||||
"Platform",
|
||||
back_populates="vendor_platforms",
|
||||
)
|
||||
|
||||
tier = relationship(
|
||||
"SubscriptionTier",
|
||||
foreign_keys=[tier_id],
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Constraints & Indexes
|
||||
# ========================================================================
|
||||
|
||||
__table_args__ = (
|
||||
# Each vendor can only be on a platform once
|
||||
UniqueConstraint(
|
||||
"vendor_id",
|
||||
"platform_id",
|
||||
name="uq_vendor_platform",
|
||||
),
|
||||
# Performance indexes
|
||||
Index(
|
||||
"idx_vendor_platform_active",
|
||||
"vendor_id",
|
||||
"platform_id",
|
||||
"is_active",
|
||||
),
|
||||
Index(
|
||||
"idx_vendor_platform_primary",
|
||||
"vendor_id",
|
||||
"is_primary",
|
||||
),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Properties
|
||||
# ========================================================================
|
||||
|
||||
@property
|
||||
def tier_code(self) -> str | None:
|
||||
"""Get the tier code for this platform membership."""
|
||||
return self.tier.code if self.tier else None
|
||||
|
||||
@property
|
||||
def tier_name(self) -> str | None:
|
||||
"""Get the tier name for this platform membership."""
|
||||
return self.tier.name if self.tier else None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<VendorPlatform("
|
||||
f"vendor_id={self.vendor_id}, "
|
||||
f"platform_id={self.platform_id}, "
|
||||
f"is_active={self.is_active})>"
|
||||
)
|
||||
@@ -1,136 +0,0 @@
|
||||
# models/database/vendor_theme.py
|
||||
"""
|
||||
Vendor Theme Configuration Model
|
||||
Allows each vendor to customize their shop's appearance
|
||||
"""
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class VendorTheme(Base, TimestampMixin):
|
||||
"""
|
||||
Stores theme configuration for each vendor's shop.
|
||||
|
||||
Each vendor can have ONE active theme:
|
||||
- Custom colors (primary, secondary, accent)
|
||||
- Custom fonts
|
||||
- Custom logo and favicon
|
||||
- Custom CSS overrides
|
||||
- Layout preferences
|
||||
|
||||
Theme presets available: default, modern, classic, minimal, vibrant
|
||||
"""
|
||||
|
||||
__tablename__ = "vendor_themes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
vendor_id = Column(
|
||||
Integer,
|
||||
ForeignKey("vendors.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True, # ONE vendor = ONE theme
|
||||
)
|
||||
|
||||
# Basic Theme Settings
|
||||
theme_name = Column(
|
||||
String(100), default="default"
|
||||
) # default, modern, classic, minimal, vibrant
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Color Scheme (JSON for flexibility)
|
||||
colors = Column(
|
||||
JSON,
|
||||
default={
|
||||
"primary": "#6366f1", # Indigo
|
||||
"secondary": "#8b5cf6", # Purple
|
||||
"accent": "#ec4899", # Pink
|
||||
"background": "#ffffff", # White
|
||||
"text": "#1f2937", # Gray-800
|
||||
"border": "#e5e7eb", # Gray-200
|
||||
},
|
||||
)
|
||||
|
||||
# Typography
|
||||
font_family_heading = Column(String(100), default="Inter, sans-serif")
|
||||
font_family_body = Column(String(100), default="Inter, sans-serif")
|
||||
|
||||
# Branding Assets
|
||||
logo_url = Column(String(500), nullable=True) # Path to vendor logo
|
||||
logo_dark_url = Column(String(500), nullable=True) # Dark mode logo
|
||||
favicon_url = Column(String(500), nullable=True) # Favicon
|
||||
banner_url = Column(String(500), nullable=True) # Homepage banner
|
||||
|
||||
# Layout Preferences
|
||||
layout_style = Column(String(50), default="grid") # grid, list, masonry
|
||||
header_style = Column(String(50), default="fixed") # fixed, static, transparent
|
||||
product_card_style = Column(
|
||||
String(50), default="modern"
|
||||
) # modern, classic, minimal
|
||||
|
||||
# Custom CSS (for advanced customization)
|
||||
custom_css = Column(Text, nullable=True)
|
||||
|
||||
# Social Media Links
|
||||
social_links = Column(JSON, default={}) # {facebook: "url", instagram: "url", etc.}
|
||||
|
||||
# SEO & Meta
|
||||
meta_title_template = Column(
|
||||
String(200), nullable=True
|
||||
) # e.g., "{product_name} - {shop_name}"
|
||||
meta_description = Column(Text, nullable=True)
|
||||
|
||||
# Relationships - FIXED: back_populates must match the relationship name in Vendor model
|
||||
vendor = relationship("Vendor", back_populates="vendor_theme")
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<VendorTheme(vendor_id={self.vendor_id}, theme_name='{self.theme_name}')>"
|
||||
)
|
||||
|
||||
@property
|
||||
def primary_color(self):
|
||||
"""Get primary color from JSON"""
|
||||
return self.colors.get("primary", "#6366f1")
|
||||
|
||||
@property
|
||||
def css_variables(self):
|
||||
"""Generate CSS custom properties from theme config"""
|
||||
return {
|
||||
"--color-primary": self.colors.get("primary", "#6366f1"),
|
||||
"--color-secondary": self.colors.get("secondary", "#8b5cf6"),
|
||||
"--color-accent": self.colors.get("accent", "#ec4899"),
|
||||
"--color-background": self.colors.get("background", "#ffffff"),
|
||||
"--color-text": self.colors.get("text", "#1f2937"),
|
||||
"--color-border": self.colors.get("border", "#e5e7eb"),
|
||||
"--font-heading": self.font_family_heading,
|
||||
"--font-body": self.font_family_body,
|
||||
}
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert theme to dictionary for template rendering"""
|
||||
return {
|
||||
"theme_name": self.theme_name,
|
||||
"colors": self.colors,
|
||||
"fonts": {
|
||||
"heading": self.font_family_heading,
|
||||
"body": self.font_family_body,
|
||||
},
|
||||
"branding": {
|
||||
"logo": self.logo_url,
|
||||
"logo_dark": self.logo_dark_url,
|
||||
"favicon": self.favicon_url,
|
||||
"banner": self.banner_url,
|
||||
},
|
||||
"layout": {
|
||||
"style": self.layout_style,
|
||||
"header": self.header_style,
|
||||
"product_card": self.product_card_style,
|
||||
},
|
||||
"social_links": self.social_links,
|
||||
"custom_css": self.custom_css,
|
||||
"css_variables": self.css_variables,
|
||||
}
|
||||
Reference in New Issue
Block a user