feat: implement multi-language translation support for products
- Add database migrations for translation tables and digital product fields - Create MarketplaceProductTranslation model for localized content - Create ProductTranslation model for vendor translation overrides - Add product type enum (physical, digital, service, subscription) - Add digital delivery method and platform fields - Add numeric price fields (price_numeric, sale_price_numeric) - Implement language fallback pattern (requested -> 'en' -> None) - Add helper methods: get_title(), get_description(), get_translation() - Add vendor override pattern with effective_* properties - Move title/description from products to translations table 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,31 +1,115 @@
|
||||
"""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,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
# Import Base from the central database module instead of creating a new one
|
||||
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)
|
||||
title = Column(String, nullable=False)
|
||||
description = Column(String)
|
||||
link = Column(String)
|
||||
image_link = Column(String)
|
||||
availability = Column(String, index=True) # Index for filtering
|
||||
price = Column(String)
|
||||
brand = Column(String, index=True) # Index for filtering
|
||||
gtin = Column(String, index=True) # Index for inventory lookups
|
||||
mpn = Column(String)
|
||||
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)
|
||||
@@ -38,43 +122,130 @@ class MarketplaceProduct(Base, TimestampMixin):
|
||||
size_type = Column(String)
|
||||
size_system = Column(String)
|
||||
item_group_id = Column(String)
|
||||
google_product_category = Column(String, index=True) # Index for filtering
|
||||
product_type = 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)
|
||||
additional_image_link = Column(String)
|
||||
sale_price = Column(String)
|
||||
unit_pricing_measure = Column(String)
|
||||
unit_pricing_base_measure = Column(String)
|
||||
identifier_exists = Column(String)
|
||||
shipping = Column(String)
|
||||
currency = Column(String)
|
||||
|
||||
# New marketplace fields
|
||||
marketplace = Column(
|
||||
String, index=True, nullable=True, default="Letzshop"
|
||||
) # Index for marketplace filtering
|
||||
vendor_name = Column(
|
||||
String, index=True, nullable=True
|
||||
) # Index for vendor filtering
|
||||
# === STATUS ===
|
||||
is_active = Column(Boolean, default=True, index=True)
|
||||
|
||||
product = relationship("Product", back_populates="marketplace_product")
|
||||
# === RELATIONSHIPS ===
|
||||
translations = relationship(
|
||||
"MarketplaceProductTranslation",
|
||||
back_populates="marketplace_product",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
vendor_products = relationship("Product", back_populates="marketplace_product")
|
||||
|
||||
# Additional indexes for marketplace queries
|
||||
# === INDEXES ===
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"idx_marketplace_vendor", "marketplace", "vendor_name"
|
||||
), # Composite index for marketplace+vendor queries
|
||||
Index(
|
||||
"idx_marketplace_brand", "marketplace", "brand"
|
||||
), # Composite index for marketplace+brand queries
|
||||
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"<MarketplaceProduct(marketplace_product_id='{self.marketplace_product_id}', title='{self.title}', marketplace='{self.marketplace}', "
|
||||
f"<MarketplaceProduct(id={self.id}, "
|
||||
f"marketplace_product_id='{self.marketplace_product_id}', "
|
||||
f"marketplace='{self.marketplace}', "
|
||||
f"vendor='{self.vendor_name}')>"
|
||||
)
|
||||
|
||||
# === 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
|
||||
|
||||
Reference in New Issue
Block a user