"""Marketplace Product model for multi-marketplace product integration. This model stores canonical product data from various marketplaces (Letzshop, Amazon, eBay, CodesWholesale, etc.) in a universal format. It supports: - Physical and digital products - Multi-language translations (via MarketplaceProductTranslation) - Flexible attributes for marketplace-specific data - Google Shopping fields for Letzshop compatibility """ from enum import Enum from sqlalchemy import ( Boolean, Column, Float, Index, Integer, String, ) from sqlalchemy.dialects.sqlite import JSON from sqlalchemy.orm import relationship from app.core.database import Base from models.database.base import TimestampMixin class ProductType(str, Enum): """Product type classification.""" PHYSICAL = "physical" DIGITAL = "digital" SERVICE = "service" SUBSCRIPTION = "subscription" class DigitalDeliveryMethod(str, Enum): """Digital product delivery methods.""" DOWNLOAD = "download" EMAIL = "email" IN_APP = "in_app" STREAMING = "streaming" LICENSE_KEY = "license_key" class MarketplaceProduct(Base, TimestampMixin): """Canonical product data from marketplace sources. This table stores normalized product information from all marketplace sources. Localized content (title, description) is stored in MarketplaceProductTranslation. """ __tablename__ = "marketplace_products" id = Column(Integer, primary_key=True, index=True) # === UNIVERSAL IDENTIFIERS === marketplace_product_id = Column(String, unique=True, index=True, nullable=False) gtin = Column(String, index=True) # EAN/UPC - primary cross-marketplace matching mpn = Column(String, index=True) # Manufacturer Part Number sku = Column(String, index=True) # Internal SKU if assigned # === SOURCE TRACKING === marketplace = Column( String, index=True, nullable=True, default="letzshop" ) # 'letzshop', 'amazon', 'ebay', 'codeswholesale' source_url = Column(String) # Original product URL vendor_name = Column(String, index=True) # Seller/vendor in marketplace # === PRODUCT TYPE === product_type_enum = Column( String(20), nullable=False, default=ProductType.PHYSICAL.value ) is_digital = Column(Boolean, default=False, index=True) # === DIGITAL PRODUCT FIELDS === digital_delivery_method = Column(String(20)) # DigitalDeliveryMethod values platform = Column(String(50), index=True) # 'steam', 'playstation', 'xbox', etc. region_restrictions = Column(JSON) # ["EU", "US"] or null for global license_type = Column(String(50)) # 'single_use', 'subscription', 'lifetime' # === NON-LOCALIZED FIELDS === brand = Column(String, index=True) google_product_category = Column(String, index=True) category_path = Column(String) # Normalized category hierarchy condition = Column(String) # === PRICING === price = Column(String) # Raw price string "19.99 EUR" (kept for reference) price_numeric = Column(Float) # Parsed numeric price sale_price = Column(String) # Raw sale price string sale_price_numeric = Column(Float) # Parsed numeric sale price currency = Column(String(3), default="EUR") # === MEDIA === image_link = Column(String) additional_image_link = Column(String) # Legacy single string additional_images = Column(JSON) # Array of image URLs # === PRODUCT ATTRIBUTES (Flexible) === attributes = Column(JSON) # {color, size, material, etc.} # === PHYSICAL PRODUCT FIELDS === weight = Column(Float) # In kg weight_unit = Column(String(10), default="kg") dimensions = Column(JSON) # {length, width, height, unit} # === GOOGLE SHOPPING FIELDS (Preserved for Letzshop) === link = Column(String) availability = Column(String, index=True) adult = Column(String) multipack = Column(Integer) is_bundle = Column(String) age_group = Column(String) color = Column(String) gender = Column(String) material = Column(String) pattern = Column(String) size = Column(String) size_type = Column(String) size_system = Column(String) item_group_id = Column(String) product_type_raw = Column(String) # Original feed value (renamed from product_type) custom_label_0 = Column(String) custom_label_1 = Column(String) custom_label_2 = Column(String) custom_label_3 = Column(String) custom_label_4 = Column(String) unit_pricing_measure = Column(String) unit_pricing_base_measure = Column(String) identifier_exists = Column(String) shipping = Column(String) # === STATUS === is_active = Column(Boolean, default=True, index=True) # === RELATIONSHIPS === translations = relationship( "MarketplaceProductTranslation", back_populates="marketplace_product", cascade="all, delete-orphan", ) vendor_products = relationship("Product", back_populates="marketplace_product") # === INDEXES === __table_args__ = ( Index("idx_marketplace_vendor", "marketplace", "vendor_name"), Index("idx_marketplace_brand", "marketplace", "brand"), Index("idx_mp_gtin_marketplace", "gtin", "marketplace"), Index("idx_mp_product_type", "product_type_enum", "is_digital"), ) def __repr__(self): return ( f"" ) # === HELPER PROPERTIES === @property def product_type(self) -> ProductType: """Get product type as enum.""" return ProductType(self.product_type_enum) @product_type.setter def product_type(self, value: ProductType | str): """Set product type from enum or string.""" if isinstance(value, ProductType): self.product_type_enum = value.value else: self.product_type_enum = value @property def delivery_method(self) -> DigitalDeliveryMethod | None: """Get digital delivery method as enum.""" if self.digital_delivery_method: return DigitalDeliveryMethod(self.digital_delivery_method) return None @delivery_method.setter def delivery_method(self, value: DigitalDeliveryMethod | str | None): """Set delivery method from enum or string.""" if value is None: self.digital_delivery_method = None elif isinstance(value, DigitalDeliveryMethod): self.digital_delivery_method = value.value else: self.digital_delivery_method = value def get_translation(self, language: str) -> "MarketplaceProductTranslation | None": """Get translation for a specific language.""" for t in self.translations: if t.language == language: return t return None def get_title(self, language: str = "en") -> str | None: """Get title for a specific language with fallback to 'en'.""" translation = self.get_translation(language) if translation: return translation.title # Fallback to English if language != "en": en_translation = self.get_translation("en") if en_translation: return en_translation.title return None def get_description(self, language: str = "en") -> str | None: """Get description for a specific language with fallback to 'en'.""" translation = self.get_translation(language) if translation: return translation.description # Fallback to English if language != "en": en_translation = self.get_translation("en") if en_translation: return en_translation.description return None @property def effective_price(self) -> float | None: """Get the effective numeric price.""" return self.price_numeric @property def effective_sale_price(self) -> float | None: """Get the effective numeric sale price.""" return self.sale_price_numeric @property def all_images(self) -> list[str]: """Get all product images as a list.""" images = [] if self.image_link: images.append(self.image_link) if self.additional_images: images.extend(self.additional_images) elif self.additional_image_link: # Legacy single string format images.append(self.additional_image_link) return images