diff --git a/app/api/v1/vendor/analytics.py b/app/api/v1/vendor/analytics.py index a4c79db3..e44d3a22 100644 --- a/app/api/v1/vendor/analytics.py +++ b/app/api/v1/vendor/analytics.py @@ -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, diff --git a/app/api/v1/vendor/invoices.py b/app/api/v1/vendor/invoices.py index 4a432254..f512f14c 100644 --- a/app/api/v1/vendor/invoices.py +++ b/app/api/v1/vendor/invoices.py @@ -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, diff --git a/app/core/feature_gate.py b/app/core/feature_gate.py index 98b034fd..c678e3e8 100644 --- a/app/core/feature_gate.py +++ b/app/core/feature_gate.py @@ -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__) diff --git a/app/modules/analytics/routes/api/vendor.py b/app/modules/analytics/routes/api/vendor.py index 9e936f5b..0d8e6c4a 100644 --- a/app/modules/analytics/routes/api/vendor.py +++ b/app/modules/analytics/routes/api/vendor.py @@ -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( diff --git a/app/modules/billing/models/__init__.py b/app/modules/billing/models/__init__.py index 4851b35d..0676f682 100644 --- a/app/modules/billing/models/__init__.py +++ b/app/modules/billing/models/__init__.py @@ -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", ] diff --git a/app/modules/billing/models/feature.py b/app/modules/billing/models/feature.py new file mode 100644 index 00000000..07162bfc --- /dev/null +++ b/app/modules/billing/models/feature.py @@ -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"" + + 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 diff --git a/app/modules/catalog/models/__init__.py b/app/modules/catalog/models/__init__.py index f13327a5..9a5cec68 100644 --- a/app/modules/catalog/models/__init__.py +++ b/app/modules/catalog/models/__init__.py @@ -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", ] diff --git a/app/modules/catalog/models/product_media.py b/app/modules/catalog/models/product_media.py new file mode 100644 index 00000000..118164cc --- /dev/null +++ b/app/modules/catalog/models/product_media.py @@ -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"" + ) diff --git a/app/modules/cms/models/__init__.py b/app/modules/cms/models/__init__.py index 5d10eb19..79255a88 100644 --- a/app/modules/cms/models/__init__.py +++ b/app/modules/cms/models/__init__.py @@ -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", ] diff --git a/app/services/feature_service.py b/app/services/feature_service.py index 153bfc11..f6434f7a 100644 --- a/app/services/feature_service.py +++ b/app/services/feature_service.py @@ -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__) diff --git a/app/services/media_service.py b/app/services/media_service.py index 6b766ac7..87644aff 100644 --- a/app/services/media_service.py +++ b/app/services/media_service.py @@ -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__) diff --git a/models/database/__init__.py b/models/database/__init__.py index 0237963e..88058b0a 100644 --- a/models/database/__init__.py +++ b/models/database/__init__.py @@ -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 diff --git a/models/database/feature.py b/models/database/feature.py index 8fb6676e..cce38526 100644 --- a/models/database/feature.py +++ b/models/database/feature.py @@ -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"" - - 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", +] diff --git a/models/database/media.py b/models/database/media.py index e69b8cc2..b0ce8eaa 100644 --- a/models/database/media.py +++ b/models/database/media.py @@ -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"" - ) +__all__ = ["MediaFile", "ProductMedia"] diff --git a/tests/unit/services/test_feature_service.py b/tests/unit/services/test_feature_service.py index d4e87cb1..008ca77b 100644 --- a/tests/unit/services/test_feature_service.py +++ b/tests/unit/services/test_feature_service.py @@ -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