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:
2
app/api/v1/vendor/analytics.py
vendored
2
app/api/v1/vendor/analytics.py
vendored
@@ -19,7 +19,7 @@ from app.api.deps import get_current_vendor_api
|
|||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.feature_gate import RequireFeature
|
from app.core.feature_gate import RequireFeature
|
||||||
from app.services.stats_service import stats_service
|
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 models.database.user import User
|
||||||
from app.modules.analytics.schemas import (
|
from app.modules.analytics.schemas import (
|
||||||
VendorAnalyticsCatalog,
|
VendorAnalyticsCatalog,
|
||||||
|
|||||||
2
app/api/v1/vendor/invoices.py
vendored
2
app/api/v1/vendor/invoices.py
vendored
@@ -41,7 +41,7 @@ from app.exceptions.invoice import (
|
|||||||
InvoiceSettingsNotFoundException,
|
InvoiceSettingsNotFoundException,
|
||||||
)
|
)
|
||||||
from app.services.invoice_service import invoice_service
|
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 models.database.user import User
|
||||||
from app.modules.orders.schemas import (
|
from app.modules.orders.schemas import (
|
||||||
InvoiceCreate,
|
InvoiceCreate,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.api.deps import get_current_vendor_api
|
from app.api.deps import get_current_vendor_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.services.feature_service import feature_service
|
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
|
from models.database.user import User
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from app.modules.analytics.schemas import (
|
|||||||
VendorAnalyticsInventory,
|
VendorAnalyticsInventory,
|
||||||
VendorAnalyticsResponse,
|
VendorAnalyticsResponse,
|
||||||
)
|
)
|
||||||
from models.database.feature import FeatureCode
|
from app.modules.billing.models import FeatureCode
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ Usage:
|
|||||||
SubscriptionTier,
|
SubscriptionTier,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
TierCode,
|
TierCode,
|
||||||
|
Feature,
|
||||||
|
FeatureCode,
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -31,14 +33,23 @@ from app.modules.billing.models.subscription import (
|
|||||||
# Legacy constants
|
# Legacy constants
|
||||||
TIER_LIMITS,
|
TIER_LIMITS,
|
||||||
)
|
)
|
||||||
|
from app.modules.billing.models.feature import (
|
||||||
|
# Enums
|
||||||
|
FeatureCategory,
|
||||||
|
FeatureUILocation,
|
||||||
|
# Model
|
||||||
|
Feature,
|
||||||
|
# Constants
|
||||||
|
FeatureCode,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Enums
|
# Subscription Enums
|
||||||
"TierCode",
|
"TierCode",
|
||||||
"SubscriptionStatus",
|
"SubscriptionStatus",
|
||||||
"AddOnCategory",
|
"AddOnCategory",
|
||||||
"BillingPeriod",
|
"BillingPeriod",
|
||||||
# Models
|
# Subscription Models
|
||||||
"SubscriptionTier",
|
"SubscriptionTier",
|
||||||
"AddOnProduct",
|
"AddOnProduct",
|
||||||
"VendorAddOn",
|
"VendorAddOn",
|
||||||
@@ -48,4 +59,11 @@ __all__ = [
|
|||||||
"CapacitySnapshot",
|
"CapacitySnapshot",
|
||||||
# Legacy constants
|
# Legacy constants
|
||||||
"TIER_LIMITS",
|
"TIER_LIMITS",
|
||||||
|
# Feature Enums
|
||||||
|
"FeatureCategory",
|
||||||
|
"FeatureUILocation",
|
||||||
|
# Feature Model
|
||||||
|
"Feature",
|
||||||
|
# Feature Constants
|
||||||
|
"FeatureCode",
|
||||||
]
|
]
|
||||||
|
|||||||
200
app/modules/billing/models/feature.py
Normal file
200
app/modules/billing/models/feature.py
Normal 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
|
||||||
@@ -5,13 +5,15 @@ Catalog module models.
|
|||||||
This is the canonical location for product models.
|
This is the canonical location for product models.
|
||||||
|
|
||||||
Usage:
|
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 import Product
|
||||||
from app.modules.catalog.models.product_translation import ProductTranslation
|
from app.modules.catalog.models.product_translation import ProductTranslation
|
||||||
|
from app.modules.catalog.models.product_media import ProductMedia
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Product",
|
"Product",
|
||||||
"ProductTranslation",
|
"ProductTranslation",
|
||||||
|
"ProductMedia",
|
||||||
]
|
]
|
||||||
|
|||||||
71
app/modules/catalog/models/product_media.py
Normal file
71
app/modules/catalog/models/product_media.py
Normal 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}')>"
|
||||||
|
)
|
||||||
@@ -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.
|
discovered and registered with SQLAlchemy's Base.metadata at startup.
|
||||||
|
|
||||||
Usage:
|
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
|
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__ = [
|
__all__ = [
|
||||||
"ContentPage",
|
"ContentPage",
|
||||||
"MediaFile",
|
|
||||||
"ProductMedia",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ from app.exceptions.feature import (
|
|||||||
InvalidFeatureCodesError,
|
InvalidFeatureCodesError,
|
||||||
TierNotFoundError,
|
TierNotFoundError,
|
||||||
)
|
)
|
||||||
from models.database.feature import Feature, FeatureCode
|
from app.modules.billing.models import Feature, FeatureCode
|
||||||
from app.modules.billing.models import SubscriptionTier, VendorSubscription
|
from app.modules.billing.models import SubscriptionTier, VendorSubscription
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ from app.exceptions.media import (
|
|||||||
UnsupportedMediaTypeException,
|
UnsupportedMediaTypeException,
|
||||||
MediaFileTooLargeException,
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ from app.modules.customers.models import PasswordResetToken
|
|||||||
from .email import EmailCategory, EmailLog, EmailStatus, EmailTemplate
|
from .email import EmailCategory, EmailLog, EmailStatus, EmailTemplate
|
||||||
from .vendor_email_template import VendorEmailTemplate
|
from .vendor_email_template import VendorEmailTemplate
|
||||||
from .vendor_email_settings import EmailProvider, VendorEmailSettings, PREMIUM_EMAIL_PROVIDERS
|
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 Inventory
|
||||||
from app.modules.inventory.models import InventoryTransaction, TransactionType
|
from app.modules.inventory.models import InventoryTransaction, TransactionType
|
||||||
from app.modules.orders.models import (
|
from app.modules.orders.models import (
|
||||||
@@ -77,7 +77,8 @@ from app.modules.messaging.models import (
|
|||||||
MessageAttachment,
|
MessageAttachment,
|
||||||
ParticipantType,
|
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.marketplace.models import OnboardingStatus, OnboardingStep, VendorOnboarding
|
||||||
from app.modules.orders.models import Order, OrderItem
|
from app.modules.orders.models import Order, OrderItem
|
||||||
from app.modules.orders.models import OrderItemException
|
from app.modules.orders.models import OrderItemException
|
||||||
|
|||||||
@@ -1,200 +1,22 @@
|
|||||||
# models/database/feature.py
|
# 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:
|
Feature is now part of the billing module.
|
||||||
- Dynamic feature-to-tier assignment (no code changes needed)
|
Please update imports to use:
|
||||||
- UI metadata for frontend rendering
|
from app.modules.billing.models import Feature, FeatureCode, FeatureCategory, FeatureUILocation
|
||||||
- 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 app.modules.billing.models import (
|
||||||
from datetime import UTC, datetime
|
Feature,
|
||||||
|
FeatureCategory,
|
||||||
|
FeatureCode,
|
||||||
|
FeatureUILocation,
|
||||||
|
)
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, Integer, String, Text
|
__all__ = [
|
||||||
from sqlalchemy.orm import relationship
|
"Feature",
|
||||||
|
"FeatureCategory",
|
||||||
from app.core.database import Base
|
"FeatureCode",
|
||||||
from models.database.base import TimestampMixin
|
"FeatureUILocation",
|
||||||
|
]
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
# models/database/media.py
|
# models/database/media.py
|
||||||
"""
|
"""
|
||||||
Media file models for vendor media library.
|
CORE media file model for vendor media library.
|
||||||
|
|
||||||
This module provides:
|
This is a CORE framework model used across multiple modules.
|
||||||
- MediaFile: Vendor-uploaded media files (images, documents, videos)
|
MediaFile provides vendor-uploaded media files (images, documents, videos).
|
||||||
- ProductMedia: Many-to-many relationship between products and media
|
|
||||||
|
For product-media associations, use:
|
||||||
|
from app.modules.catalog.models import ProductMedia
|
||||||
|
|
||||||
Files are stored in vendor-specific directories:
|
Files are stored in vendor-specific directories:
|
||||||
uploads/vendors/{vendor_id}/{folder}/{filename}
|
uploads/vendors/{vendor_id}/{folder}/{filename}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Boolean,
|
Boolean,
|
||||||
Column,
|
Column,
|
||||||
DateTime,
|
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
UniqueConstraint,
|
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.sqlite import JSON
|
from sqlalchemy.dialects.sqlite import JSON
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
@@ -75,6 +73,7 @@ class MediaFile(Base, TimestampMixin):
|
|||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
vendor = relationship("Vendor", back_populates="media_files")
|
vendor = relationship("Vendor", back_populates="media_files")
|
||||||
|
# ProductMedia relationship uses string reference to avoid circular import
|
||||||
product_associations = relationship(
|
product_associations = relationship(
|
||||||
"ProductMedia",
|
"ProductMedia",
|
||||||
back_populates="media",
|
back_populates="media",
|
||||||
@@ -122,52 +121,7 @@ class MediaFile(Base, TimestampMixin):
|
|||||||
return self.media_type == "document"
|
return self.media_type == "document"
|
||||||
|
|
||||||
|
|
||||||
class ProductMedia(Base, TimestampMixin):
|
# Re-export ProductMedia from its canonical location for backwards compatibility
|
||||||
"""Association between products and media files.
|
from app.modules.catalog.models import ProductMedia # noqa: E402, F401
|
||||||
|
|
||||||
Tracks which media files are used by which products,
|
__all__ = ["MediaFile", "ProductMedia"]
|
||||||
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}')>"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import pytest
|
|||||||
|
|
||||||
from app.exceptions import FeatureNotFoundError, InvalidFeatureCodesError, TierNotFoundError
|
from app.exceptions import FeatureNotFoundError, InvalidFeatureCodesError, TierNotFoundError
|
||||||
from app.services.feature_service import FeatureService, feature_service
|
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
|
from app.modules.billing.models import SubscriptionTier, VendorSubscription
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user