refactor: migrate Feature to billing module and split ProductMedia to catalog

- Move Feature model from models/database/ to app/modules/billing/models/
  (tightly coupled to SubscriptionTier for tier-based access control)
- Move ProductMedia from models/database/media.py to app/modules/catalog/models/
  (product-specific media associations belong with catalog)
- Keep MediaFile as CORE in models/database/media.py (cross-cutting file storage)
- Convert legacy feature.py to re-export for backwards compatibility
- Update all imports to use canonical module locations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 19:32:38 +01:00
parent b9f08b853f
commit 0f9b80c634
15 changed files with 336 additions and 268 deletions

View File

@@ -19,7 +19,7 @@ from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.core.feature_gate import RequireFeature
from app.services.stats_service import stats_service
from models.database.feature import FeatureCode
from app.modules.billing.models import FeatureCode
from models.database.user import User
from app.modules.analytics.schemas import (
VendorAnalyticsCatalog,

View File

@@ -41,7 +41,7 @@ from app.exceptions.invoice import (
InvoiceSettingsNotFoundException,
)
from app.services.invoice_service import invoice_service
from models.database.feature import FeatureCode
from app.modules.billing.models import FeatureCode
from models.database.user import User
from app.modules.orders.schemas import (
InvoiceCreate,

View File

@@ -38,7 +38,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.feature_service import feature_service
from models.database.feature import FeatureCode
from app.modules.billing.models import FeatureCode
from models.database.user import User
logger = logging.getLogger(__name__)

View File

@@ -24,7 +24,7 @@ from app.modules.analytics.schemas import (
VendorAnalyticsInventory,
VendorAnalyticsResponse,
)
from models.database.feature import FeatureCode
from app.modules.billing.models import FeatureCode
from models.database.user import User
router = APIRouter(

View File

@@ -11,6 +11,8 @@ Usage:
SubscriptionTier,
SubscriptionStatus,
TierCode,
Feature,
FeatureCode,
)
"""
@@ -31,14 +33,23 @@ from app.modules.billing.models.subscription import (
# Legacy constants
TIER_LIMITS,
)
from app.modules.billing.models.feature import (
# Enums
FeatureCategory,
FeatureUILocation,
# Model
Feature,
# Constants
FeatureCode,
)
__all__ = [
# Enums
# Subscription Enums
"TierCode",
"SubscriptionStatus",
"AddOnCategory",
"BillingPeriod",
# Models
# Subscription Models
"SubscriptionTier",
"AddOnProduct",
"VendorAddOn",
@@ -48,4 +59,11 @@ __all__ = [
"CapacitySnapshot",
# Legacy constants
"TIER_LIMITS",
# Feature Enums
"FeatureCategory",
"FeatureUILocation",
# Feature Model
"Feature",
# Feature Constants
"FeatureCode",
]

View File

@@ -0,0 +1,200 @@
# app/modules/billing/models/feature.py
"""
Feature registry for tier-based access control.
Provides a database-driven feature registry that allows:
- Dynamic feature-to-tier assignment (no code changes needed)
- UI metadata for frontend rendering
- Feature categorization for organization
- Upgrade prompts with tier info
Features are assigned to tiers via the SubscriptionTier.features JSON array.
This model provides the metadata and acts as a registry of all available features.
"""
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 FeatureCategory(str, enum.Enum):
"""Feature categories for organization."""
ORDERS = "orders"
INVENTORY = "inventory"
ANALYTICS = "analytics"
INVOICING = "invoicing"
INTEGRATIONS = "integrations"
TEAM = "team"
BRANDING = "branding"
CUSTOMERS = "customers"
CMS = "cms"
class FeatureUILocation(str, enum.Enum):
"""Where the feature appears in the UI."""
SIDEBAR = "sidebar" # Main navigation item
DASHBOARD = "dashboard" # Dashboard widget/section
SETTINGS = "settings" # Settings page option
API = "api" # API-only feature (no UI)
INLINE = "inline" # Inline feature within a page
class Feature(Base, TimestampMixin):
"""
Feature registry for tier-based access control.
Each feature represents a capability that can be enabled/disabled per tier.
The actual tier assignment is stored in SubscriptionTier.features as a JSON
array of feature codes. This table provides metadata for:
- UI rendering (icons, labels, locations)
- Upgrade prompts (which tier unlocks this?)
- Admin management (description, categorization)
Example features:
- analytics_dashboard: Full analytics with charts
- api_access: REST API access for integrations
- team_roles: Role-based permissions for team members
- automation_rules: Automatic order processing rules
"""
__tablename__ = "features"
id = Column(Integer, primary_key=True, index=True)
# Unique identifier used in code and tier.features JSON
code = Column(String(50), unique=True, nullable=False, index=True)
# Display info
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
# Categorization
category = Column(String(50), nullable=False, index=True)
# UI metadata - tells frontend how to render
ui_location = Column(String(50), nullable=True) # sidebar, dashboard, settings, api
ui_icon = Column(String(50), nullable=True) # Icon name (e.g., "chart-bar")
ui_route = Column(String(100), nullable=True) # Route pattern (e.g., "/vendor/{code}/analytics")
ui_badge_text = Column(String(20), nullable=True) # Badge to show (e.g., "Pro", "New")
# Minimum tier that includes this feature (for upgrade prompts)
# This is denormalized for performance - the actual assignment is in SubscriptionTier.features
minimum_tier_id = Column(
Integer, ForeignKey("subscription_tiers.id"), nullable=True, index=True
)
minimum_tier = relationship("SubscriptionTier", foreign_keys=[minimum_tier_id])
# Status
is_active = Column(Boolean, default=True, nullable=False) # Feature available at all
is_visible = Column(Boolean, default=True, nullable=False) # Show in UI even if locked
display_order = Column(Integer, default=0, nullable=False) # Sort order within category
# Indexes
__table_args__ = (
Index("idx_feature_category_order", "category", "display_order"),
Index("idx_feature_active_visible", "is_active", "is_visible"),
)
def __repr__(self) -> str:
return f"<Feature(code='{self.code}', name='{self.name}', category='{self.category}')>"
def to_dict(self) -> dict:
"""Convert to dictionary for API responses."""
return {
"id": self.id,
"code": self.code,
"name": self.name,
"description": self.description,
"category": self.category,
"ui_location": self.ui_location,
"ui_icon": self.ui_icon,
"ui_route": self.ui_route,
"ui_badge_text": self.ui_badge_text,
"minimum_tier_code": self.minimum_tier.code if self.minimum_tier else None,
"minimum_tier_name": self.minimum_tier.name if self.minimum_tier else None,
"is_active": self.is_active,
"is_visible": self.is_visible,
"display_order": self.display_order,
}
# ============================================================================
# Feature Code Constants
# ============================================================================
# These constants are used throughout the codebase for type safety.
# The actual feature definitions and tier assignments are in the database.
class FeatureCode:
"""
Feature code constants for use in @require_feature decorator and checks.
Usage:
@require_feature(FeatureCode.ANALYTICS_DASHBOARD)
def get_analytics(...):
...
if feature_service.has_feature(db, vendor_id, FeatureCode.API_ACCESS):
...
"""
# Orders
ORDER_MANAGEMENT = "order_management"
ORDER_BULK_ACTIONS = "order_bulk_actions"
ORDER_EXPORT = "order_export"
AUTOMATION_RULES = "automation_rules"
# Inventory
INVENTORY_BASIC = "inventory_basic"
INVENTORY_LOCATIONS = "inventory_locations"
INVENTORY_PURCHASE_ORDERS = "inventory_purchase_orders"
LOW_STOCK_ALERTS = "low_stock_alerts"
# Analytics
BASIC_REPORTS = "basic_reports"
ANALYTICS_DASHBOARD = "analytics_dashboard"
CUSTOM_REPORTS = "custom_reports"
EXPORT_REPORTS = "export_reports"
# Invoicing
INVOICE_LU = "invoice_lu"
INVOICE_EU_VAT = "invoice_eu_vat"
INVOICE_BULK = "invoice_bulk"
ACCOUNTING_EXPORT = "accounting_export"
# Integrations
LETZSHOP_SYNC = "letzshop_sync"
API_ACCESS = "api_access"
WEBHOOKS = "webhooks"
CUSTOM_INTEGRATIONS = "custom_integrations"
# Team
SINGLE_USER = "single_user"
TEAM_BASIC = "team_basic"
TEAM_ROLES = "team_roles"
AUDIT_LOG = "audit_log"
# Branding
BASIC_SHOP = "basic_shop"
CUSTOM_DOMAIN = "custom_domain"
WHITE_LABEL = "white_label"
# Customers
CUSTOMER_VIEW = "customer_view"
CUSTOMER_EXPORT = "customer_export"
CUSTOMER_MESSAGING = "customer_messaging"
# CMS
CMS_BASIC = "cms_basic" # Basic CMS functionality (override defaults)
CMS_CUSTOM_PAGES = "cms_custom_pages" # Create custom pages beyond defaults
CMS_UNLIMITED_PAGES = "cms_unlimited_pages" # No page limit
CMS_TEMPLATES = "cms_templates" # Access to page templates
CMS_SEO = "cms_seo" # Advanced SEO features
CMS_SCHEDULING = "cms_scheduling" # Schedule page publish/unpublish

View File

@@ -5,13 +5,15 @@ Catalog module models.
This is the canonical location for product models.
Usage:
from app.modules.catalog.models import Product, ProductTranslation
from app.modules.catalog.models import Product, ProductTranslation, ProductMedia
"""
from app.modules.catalog.models.product import Product
from app.modules.catalog.models.product_translation import ProductTranslation
from app.modules.catalog.models.product_media import ProductMedia
__all__ = [
"Product",
"ProductTranslation",
"ProductMedia",
]

View File

@@ -0,0 +1,71 @@
# app/modules/catalog/models/product_media.py
"""
Product-Media association model.
Links media files to products with usage type tracking
(main image, gallery, variant images, etc.)
"""
from sqlalchemy import (
Column,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class ProductMedia(Base, TimestampMixin):
"""Association between products and media files.
Tracks which media files are used by which products,
including the usage type (main image, gallery, variant, etc.)
"""
__tablename__ = "product_media"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(
Integer,
ForeignKey("products.id", ondelete="CASCADE"),
nullable=False,
)
media_id = Column(
Integer,
ForeignKey("media_files.id", ondelete="CASCADE"),
nullable=False,
)
# Usage type
usage_type = Column(String(50), nullable=False, default="gallery")
# Types: main_image, gallery, variant, thumbnail, swatch
# Display order for galleries
display_order = Column(Integer, default=0)
# Variant-specific (if usage_type is variant)
variant_id = Column(Integer) # Reference to variant if applicable
# Relationships
product = relationship("Product")
media = relationship("MediaFile", back_populates="product_associations")
__table_args__ = (
UniqueConstraint(
"product_id", "media_id", "usage_type",
name="uq_product_media_usage"
),
Index("idx_product_media_product", "product_id"),
Index("idx_product_media_media", "media_id"),
)
def __repr__(self):
return (
f"<ProductMedia(product_id={self.product_id}, "
f"media_id={self.media_id}, usage='{self.usage_type}')>"
)

View File

@@ -6,16 +6,15 @@ This is the canonical location for CMS models. Module models are automatically
discovered and registered with SQLAlchemy's Base.metadata at startup.
Usage:
from app.modules.cms.models import ContentPage, MediaFile, ProductMedia
from app.modules.cms.models import ContentPage
For media models:
from models.database.media import MediaFile # Core media file storage
from app.modules.catalog.models import ProductMedia # Product-media associations
"""
from app.modules.cms.models.content_page import ContentPage
# Media models remain in core for now (used by multiple modules)
from models.database.media import MediaFile, ProductMedia
__all__ = [
"ContentPage",
"MediaFile",
"ProductMedia",
]

View File

@@ -34,7 +34,7 @@ from app.exceptions.feature import (
InvalidFeatureCodesError,
TierNotFoundError,
)
from models.database.feature import Feature, FeatureCode
from app.modules.billing.models import Feature, FeatureCode
from app.modules.billing.models import SubscriptionTier, VendorSubscription
logger = logging.getLogger(__name__)

View File

@@ -27,7 +27,8 @@ from app.exceptions.media import (
UnsupportedMediaTypeException,
MediaFileTooLargeException,
)
from models.database.media import MediaFile, ProductMedia
from models.database.media import MediaFile
from app.modules.catalog.models import ProductMedia
logger = logging.getLogger(__name__)

View File

@@ -48,7 +48,7 @@ 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 .feature import Feature, FeatureCategory, FeatureCode, FeatureUILocation
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 (
@@ -77,7 +77,8 @@ from app.modules.messaging.models import (
MessageAttachment,
ParticipantType,
)
from .media import MediaFile, ProductMedia
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

View File

@@ -1,200 +1,22 @@
# models/database/feature.py
"""
Feature registry for tier-based access control.
DEPRECATED: This module re-exports from the canonical location.
Provides a database-driven feature registry that allows:
- Dynamic feature-to-tier assignment (no code changes needed)
- UI metadata for frontend rendering
- Feature categorization for organization
- Upgrade prompts with tier info
Features are assigned to tiers via the SubscriptionTier.features JSON array.
This model provides the metadata and acts as a registry of all available features.
Feature is now part of the billing module.
Please update imports to use:
from app.modules.billing.models import Feature, FeatureCode, FeatureCategory, FeatureUILocation
"""
import enum
from datetime import UTC, datetime
from app.modules.billing.models import (
Feature,
FeatureCategory,
FeatureCode,
FeatureUILocation,
)
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 FeatureCategory(str, enum.Enum):
"""Feature categories for organization."""
ORDERS = "orders"
INVENTORY = "inventory"
ANALYTICS = "analytics"
INVOICING = "invoicing"
INTEGRATIONS = "integrations"
TEAM = "team"
BRANDING = "branding"
CUSTOMERS = "customers"
CMS = "cms"
class FeatureUILocation(str, enum.Enum):
"""Where the feature appears in the UI."""
SIDEBAR = "sidebar" # Main navigation item
DASHBOARD = "dashboard" # Dashboard widget/section
SETTINGS = "settings" # Settings page option
API = "api" # API-only feature (no UI)
INLINE = "inline" # Inline feature within a page
class Feature(Base, TimestampMixin):
"""
Feature registry for tier-based access control.
Each feature represents a capability that can be enabled/disabled per tier.
The actual tier assignment is stored in SubscriptionTier.features as a JSON
array of feature codes. This table provides metadata for:
- UI rendering (icons, labels, locations)
- Upgrade prompts (which tier unlocks this?)
- Admin management (description, categorization)
Example features:
- analytics_dashboard: Full analytics with charts
- api_access: REST API access for integrations
- team_roles: Role-based permissions for team members
- automation_rules: Automatic order processing rules
"""
__tablename__ = "features"
id = Column(Integer, primary_key=True, index=True)
# Unique identifier used in code and tier.features JSON
code = Column(String(50), unique=True, nullable=False, index=True)
# Display info
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
# Categorization
category = Column(String(50), nullable=False, index=True)
# UI metadata - tells frontend how to render
ui_location = Column(String(50), nullable=True) # sidebar, dashboard, settings, api
ui_icon = Column(String(50), nullable=True) # Icon name (e.g., "chart-bar")
ui_route = Column(String(100), nullable=True) # Route pattern (e.g., "/vendor/{code}/analytics")
ui_badge_text = Column(String(20), nullable=True) # Badge to show (e.g., "Pro", "New")
# Minimum tier that includes this feature (for upgrade prompts)
# This is denormalized for performance - the actual assignment is in SubscriptionTier.features
minimum_tier_id = Column(
Integer, ForeignKey("subscription_tiers.id"), nullable=True, index=True
)
minimum_tier = relationship("SubscriptionTier", foreign_keys=[minimum_tier_id])
# Status
is_active = Column(Boolean, default=True, nullable=False) # Feature available at all
is_visible = Column(Boolean, default=True, nullable=False) # Show in UI even if locked
display_order = Column(Integer, default=0, nullable=False) # Sort order within category
# Indexes
__table_args__ = (
Index("idx_feature_category_order", "category", "display_order"),
Index("idx_feature_active_visible", "is_active", "is_visible"),
)
def __repr__(self) -> str:
return f"<Feature(code='{self.code}', name='{self.name}', category='{self.category}')>"
def to_dict(self) -> dict:
"""Convert to dictionary for API responses."""
return {
"id": self.id,
"code": self.code,
"name": self.name,
"description": self.description,
"category": self.category,
"ui_location": self.ui_location,
"ui_icon": self.ui_icon,
"ui_route": self.ui_route,
"ui_badge_text": self.ui_badge_text,
"minimum_tier_code": self.minimum_tier.code if self.minimum_tier else None,
"minimum_tier_name": self.minimum_tier.name if self.minimum_tier else None,
"is_active": self.is_active,
"is_visible": self.is_visible,
"display_order": self.display_order,
}
# ============================================================================
# Feature Code Constants
# ============================================================================
# These constants are used throughout the codebase for type safety.
# The actual feature definitions and tier assignments are in the database.
class FeatureCode:
"""
Feature code constants for use in @require_feature decorator and checks.
Usage:
@require_feature(FeatureCode.ANALYTICS_DASHBOARD)
def get_analytics(...):
...
if feature_service.has_feature(db, vendor_id, FeatureCode.API_ACCESS):
...
"""
# Orders
ORDER_MANAGEMENT = "order_management"
ORDER_BULK_ACTIONS = "order_bulk_actions"
ORDER_EXPORT = "order_export"
AUTOMATION_RULES = "automation_rules"
# Inventory
INVENTORY_BASIC = "inventory_basic"
INVENTORY_LOCATIONS = "inventory_locations"
INVENTORY_PURCHASE_ORDERS = "inventory_purchase_orders"
LOW_STOCK_ALERTS = "low_stock_alerts"
# Analytics
BASIC_REPORTS = "basic_reports"
ANALYTICS_DASHBOARD = "analytics_dashboard"
CUSTOM_REPORTS = "custom_reports"
EXPORT_REPORTS = "export_reports"
# Invoicing
INVOICE_LU = "invoice_lu"
INVOICE_EU_VAT = "invoice_eu_vat"
INVOICE_BULK = "invoice_bulk"
ACCOUNTING_EXPORT = "accounting_export"
# Integrations
LETZSHOP_SYNC = "letzshop_sync"
API_ACCESS = "api_access"
WEBHOOKS = "webhooks"
CUSTOM_INTEGRATIONS = "custom_integrations"
# Team
SINGLE_USER = "single_user"
TEAM_BASIC = "team_basic"
TEAM_ROLES = "team_roles"
AUDIT_LOG = "audit_log"
# Branding
BASIC_SHOP = "basic_shop"
CUSTOM_DOMAIN = "custom_domain"
WHITE_LABEL = "white_label"
# Customers
CUSTOMER_VIEW = "customer_view"
CUSTOMER_EXPORT = "customer_export"
CUSTOMER_MESSAGING = "customer_messaging"
# CMS
CMS_BASIC = "cms_basic" # Basic CMS functionality (override defaults)
CMS_CUSTOM_PAGES = "cms_custom_pages" # Create custom pages beyond defaults
CMS_UNLIMITED_PAGES = "cms_unlimited_pages" # No page limit
CMS_TEMPLATES = "cms_templates" # Access to page templates
CMS_SEO = "cms_seo" # Advanced SEO features
CMS_SCHEDULING = "cms_scheduling" # Schedule page publish/unpublish
__all__ = [
"Feature",
"FeatureCategory",
"FeatureCode",
"FeatureUILocation",
]

View File

@@ -1,27 +1,25 @@
# models/database/media.py
"""
Media file models for vendor media library.
CORE media file model for vendor media library.
This module provides:
- MediaFile: Vendor-uploaded media files (images, documents, videos)
- ProductMedia: Many-to-many relationship between products and media
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 datetime import datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
@@ -75,6 +73,7 @@ class MediaFile(Base, TimestampMixin):
# Relationships
vendor = relationship("Vendor", back_populates="media_files")
# ProductMedia relationship uses string reference to avoid circular import
product_associations = relationship(
"ProductMedia",
back_populates="media",
@@ -122,52 +121,7 @@ class MediaFile(Base, TimestampMixin):
return self.media_type == "document"
class ProductMedia(Base, TimestampMixin):
"""Association between products and media files.
# Re-export ProductMedia from its canonical location for backwards compatibility
from app.modules.catalog.models import ProductMedia # noqa: E402, F401
Tracks which media files are used by which products,
including the usage type (main image, gallery, variant, etc.)
"""
__tablename__ = "product_media"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(
Integer,
ForeignKey("products.id", ondelete="CASCADE"),
nullable=False,
)
media_id = Column(
Integer,
ForeignKey("media_files.id", ondelete="CASCADE"),
nullable=False,
)
# Usage type
usage_type = Column(String(50), nullable=False, default="gallery")
# Types: main_image, gallery, variant, thumbnail, swatch
# Display order for galleries
display_order = Column(Integer, default=0)
# Variant-specific (if usage_type is variant)
variant_id = Column(Integer) # Reference to variant if applicable
# Relationships
product = relationship("Product")
media = relationship("MediaFile", back_populates="product_associations")
__table_args__ = (
UniqueConstraint(
"product_id", "media_id", "usage_type",
name="uq_product_media_usage"
),
Index("idx_product_media_product", "product_id"),
Index("idx_product_media_media", "media_id"),
)
def __repr__(self):
return (
f"<ProductMedia(product_id={self.product_id}, "
f"media_id={self.media_id}, usage='{self.usage_type}')>"
)
__all__ = ["MediaFile", "ProductMedia"]

View File

@@ -5,7 +5,7 @@ import pytest
from app.exceptions import FeatureNotFoundError, InvalidFeatureCodesError, TierNotFoundError
from app.services.feature_service import FeatureService, feature_service
from models.database.feature import Feature
from app.modules.billing.models import Feature
from app.modules.billing.models import SubscriptionTier, VendorSubscription