refactor: complete module-driven architecture migration

This commit completes the migration to a fully module-driven architecture:

## Models Migration
- Moved all domain models from models/database/ to their respective modules:
  - tenancy: User, Admin, Vendor, Company, Platform, VendorDomain, etc.
  - cms: MediaFile, VendorTheme
  - messaging: Email, VendorEmailSettings, VendorEmailTemplate
  - core: AdminMenuConfig
- models/database/ now only contains Base and TimestampMixin (infrastructure)

## Schemas Migration
- Moved all domain schemas from models/schema/ to their respective modules:
  - tenancy: company, vendor, admin, team, vendor_domain
  - cms: media, image, vendor_theme
  - messaging: email
- models/schema/ now only contains base.py and auth.py (infrastructure)

## Routes Migration
- Moved admin routes from app/api/v1/admin/ to modules:
  - menu_config.py -> core module
  - modules.py -> tenancy module
  - module_config.py -> tenancy module
- app/api/v1/admin/ now only aggregates auto-discovered module routes

## Menu System
- Implemented module-driven menu system with MenuDiscoveryService
- Extended FrontendType enum: PLATFORM, ADMIN, VENDOR, STOREFRONT
- Added MenuItemDefinition and MenuSectionDefinition dataclasses
- Each module now defines its own menu items in definition.py
- MenuService integrates with MenuDiscoveryService for template rendering

## Documentation
- Updated docs/architecture/models-structure.md
- Updated docs/architecture/menu-management.md
- Updated architecture validation rules for new exceptions

## Architecture Validation
- Updated MOD-019 rule to allow base.py in models/schema/
- Created core module exceptions.py and schemas/ directory
- All validation errors resolved (only warnings remain)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 21:02:56 +01:00
parent 09d7d282c6
commit d7a0ff8818
307 changed files with 5536 additions and 3826 deletions

View File

@@ -2,19 +2,24 @@
"""
CMS module database models.
This is the canonical location for CMS models. Module models are automatically
discovered and registered with SQLAlchemy's Base.metadata at startup.
This is the canonical location for CMS models including:
- ContentPage: CMS pages (marketing, vendor default pages)
- MediaFile: Vendor media library
- VendorTheme: Vendor storefront theme configuration
Usage:
from app.modules.cms.models import ContentPage
from app.modules.cms.models import ContentPage, MediaFile, VendorTheme
For media models:
from models.database.media import MediaFile # Core media file storage
from app.modules.catalog.models import ProductMedia # Product-media associations
For product-media associations:
from app.modules.catalog.models import ProductMedia
"""
from app.modules.cms.models.content_page import ContentPage
from app.modules.cms.models.media import MediaFile
from app.modules.cms.models.vendor_theme import VendorTheme
__all__ = [
"ContentPage",
"MediaFile",
"VendorTheme",
]

View File

@@ -0,0 +1,124 @@
# app/modules/cms/models/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"
__all__ = ["MediaFile"]

View File

@@ -0,0 +1,139 @@
# app/modules/cms/models/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,
}
__all__ = ["VendorTheme"]