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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user