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:
2025-12-11 17:28:54 +01:00
parent 2d6410164e
commit 92a1c0249f
9 changed files with 1416 additions and 58 deletions

View File

@@ -0,0 +1,126 @@
"""Add override fields to products table
Revision ID: a3b4c5d6e7f8
Revises: f2b3c4d5e6f7
Create Date: 2025-12-11
This migration:
- Renames 'product_id' to 'vendor_sku' for clarity
- Adds new override fields (brand, images, digital delivery)
- Adds vendor-specific digital fulfillment fields
- Changes relationship from one-to-one to one-to-many (same marketplace product
can be in multiple vendor catalogs)
The override pattern: NULL value means "inherit from marketplace_product".
Setting a value creates a vendor-specific override.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a3b4c5d6e7f8"
down_revision: Union[str, None] = "f2b3c4d5e6f7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Rename product_id to vendor_sku for clarity
op.alter_column(
"products",
"product_id",
new_column_name="vendor_sku",
)
# Add new override fields
op.add_column(
"products",
sa.Column("brand", sa.String(), nullable=True),
)
op.add_column(
"products",
sa.Column("primary_image_url", sa.String(), nullable=True),
)
op.add_column(
"products",
sa.Column("additional_images", sa.JSON(), nullable=True),
)
# Add digital product override fields
op.add_column(
"products",
sa.Column("download_url", sa.String(), nullable=True),
)
op.add_column(
"products",
sa.Column("license_type", sa.String(50), nullable=True),
)
# Add vendor-specific digital fulfillment settings
op.add_column(
"products",
sa.Column("fulfillment_email_template", sa.String(), nullable=True),
)
# Add supplier tracking (for products sourced from CodesWholesale, etc.)
op.add_column(
"products",
sa.Column("supplier", sa.String(50), nullable=True),
)
op.add_column(
"products",
sa.Column("supplier_product_id", sa.String(), nullable=True),
)
op.add_column(
"products",
sa.Column("supplier_cost", sa.Float(), nullable=True),
)
# Add margin/markup tracking
op.add_column(
"products",
sa.Column("margin_percent", sa.Float(), nullable=True),
)
# Create index for vendor_sku
op.create_index(
"idx_product_vendor_sku",
"products",
["vendor_id", "vendor_sku"],
)
# Create index for supplier queries
op.create_index(
"idx_product_supplier",
"products",
["supplier", "supplier_product_id"],
)
def downgrade() -> None:
# Drop indexes
op.drop_index("idx_product_supplier", table_name="products")
op.drop_index("idx_product_vendor_sku", table_name="products")
# Drop new columns
op.drop_column("products", "margin_percent")
op.drop_column("products", "supplier_cost")
op.drop_column("products", "supplier_product_id")
op.drop_column("products", "supplier")
op.drop_column("products", "fulfillment_email_template")
op.drop_column("products", "license_type")
op.drop_column("products", "download_url")
op.drop_column("products", "additional_images")
op.drop_column("products", "primary_image_url")
op.drop_column("products", "brand")
# Rename vendor_sku back to product_id
op.alter_column(
"products",
"vendor_sku",
new_column_name="product_id",
)

View File

@@ -0,0 +1,132 @@
"""Migrate existing product data to translation tables
Revision ID: b4c5d6e7f8a9
Revises: a3b4c5d6e7f8
Create Date: 2025-12-11
This migration:
1. Copies existing title/description from marketplace_products to
marketplace_product_translations (default language: 'en')
2. Parses existing price strings to numeric values
3. Removes the old title/description columns from marketplace_products
Since we're not live yet, we can safely remove the old columns
after migrating the data to the new structure.
"""
import re
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import text
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b4c5d6e7f8a9"
down_revision: Union[str, None] = "a3b4c5d6e7f8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def parse_price(price_str: str) -> float | None:
"""Parse price string like '19.99 EUR' to float."""
if not price_str:
return None
# Extract numeric value
numbers = re.findall(r"[\d.,]+", str(price_str))
if numbers:
num_str = numbers[0].replace(",", ".")
try:
return float(num_str)
except ValueError:
pass
return None
def upgrade() -> None:
conn = op.get_bind()
# Step 1: Migrate existing title/description to translations table
# Default language is 'en' for existing data
conn.execute(
text("""
INSERT INTO marketplace_product_translations
(marketplace_product_id, language, title, description, created_at, updated_at)
SELECT
id,
'en',
title,
description,
created_at,
updated_at
FROM marketplace_products
WHERE title IS NOT NULL
""")
)
# Step 2: Parse prices to numeric values
# Get all marketplace products with prices
result = conn.execute(
text("SELECT id, price, sale_price FROM marketplace_products")
)
for row in result:
price_numeric = parse_price(row.price) if row.price else None
sale_price_numeric = parse_price(row.sale_price) if row.sale_price else None
if price_numeric is not None or sale_price_numeric is not None:
conn.execute(
text("""
UPDATE marketplace_products
SET price_numeric = :price_numeric,
sale_price_numeric = :sale_price_numeric
WHERE id = :id
"""),
{
"id": row.id,
"price_numeric": price_numeric,
"sale_price_numeric": sale_price_numeric,
},
)
# Step 3: Since we're not live, remove the old title/description columns
# from marketplace_products (data is now in translations table)
op.drop_column("marketplace_products", "title")
op.drop_column("marketplace_products", "description")
def downgrade() -> None:
# Re-add title and description columns
op.add_column(
"marketplace_products",
sa.Column("title", sa.String(), nullable=True),
)
op.add_column(
"marketplace_products",
sa.Column("description", sa.String(), nullable=True),
)
# Copy data back from translations (only 'en' translations)
conn = op.get_bind()
conn.execute(
text("""
UPDATE marketplace_products
SET title = (
SELECT title FROM marketplace_product_translations
WHERE marketplace_product_translations.marketplace_product_id = marketplace_products.id
AND marketplace_product_translations.language = 'en'
),
description = (
SELECT description FROM marketplace_product_translations
WHERE marketplace_product_translations.marketplace_product_id = marketplace_products.id
AND marketplace_product_translations.language = 'en'
)
""")
)
# Delete the migrated translations
conn.execute(
text("DELETE FROM marketplace_product_translations WHERE language = 'en'")
)

View File

@@ -0,0 +1,206 @@
"""Add product type and digital fields to marketplace_products
Revision ID: e1a2b3c4d5e6
Revises: 28d44d503cac
Create Date: 2025-12-11
This migration adds support for:
- Product type classification (physical, digital, service, subscription)
- Digital product fields (delivery method, platform, region restrictions)
- Numeric price fields for filtering/sorting
- Additional images as JSON array
- Source URL tracking
- Flexible attributes JSON column
- Active status flag
It also renames 'product_type' to 'product_type_raw' to preserve the original
Google Shopping feed value while using 'product_type' for the new enum.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import sqlite
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "e1a2b3c4d5e6"
down_revision: Union[str, None] = "28d44d503cac"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Rename existing product_type column to product_type_raw
# to preserve the original Google Shopping feed value
op.alter_column(
"marketplace_products",
"product_type",
new_column_name="product_type_raw",
)
# Add new product classification columns
op.add_column(
"marketplace_products",
sa.Column(
"product_type_enum",
sa.String(20),
nullable=False,
server_default="physical",
),
)
op.add_column(
"marketplace_products",
sa.Column(
"is_digital",
sa.Boolean(),
nullable=False,
server_default=sa.text("0"),
),
)
# Add digital product specific fields
op.add_column(
"marketplace_products",
sa.Column("digital_delivery_method", sa.String(20), nullable=True),
)
op.add_column(
"marketplace_products",
sa.Column("platform", sa.String(50), nullable=True),
)
op.add_column(
"marketplace_products",
sa.Column("region_restrictions", sa.JSON(), nullable=True),
)
op.add_column(
"marketplace_products",
sa.Column("license_type", sa.String(50), nullable=True),
)
# Add source tracking
op.add_column(
"marketplace_products",
sa.Column("source_url", sa.String(), nullable=True),
)
# Add numeric price fields for filtering/sorting
op.add_column(
"marketplace_products",
sa.Column("price_numeric", sa.Float(), nullable=True),
)
op.add_column(
"marketplace_products",
sa.Column("sale_price_numeric", sa.Float(), nullable=True),
)
# Add flexible attributes JSON column
op.add_column(
"marketplace_products",
sa.Column("attributes", sa.JSON(), nullable=True),
)
# Add additional images as JSON array (complements existing additional_image_link)
op.add_column(
"marketplace_products",
sa.Column("additional_images", sa.JSON(), nullable=True),
)
# Add active status flag
op.add_column(
"marketplace_products",
sa.Column(
"is_active",
sa.Boolean(),
nullable=False,
server_default=sa.text("1"),
),
)
# Add SKU field for internal reference
op.add_column(
"marketplace_products",
sa.Column("sku", sa.String(), nullable=True),
)
# Add weight fields for physical products
op.add_column(
"marketplace_products",
sa.Column("weight", sa.Float(), nullable=True),
)
op.add_column(
"marketplace_products",
sa.Column("weight_unit", sa.String(10), nullable=True, server_default="kg"),
)
op.add_column(
"marketplace_products",
sa.Column("dimensions", sa.JSON(), nullable=True),
)
# Add category_path for normalized hierarchy
op.add_column(
"marketplace_products",
sa.Column("category_path", sa.String(), nullable=True),
)
# Create indexes for new columns
op.create_index(
"idx_mp_product_type",
"marketplace_products",
["product_type_enum", "is_digital"],
)
op.create_index(
"idx_mp_is_active",
"marketplace_products",
["is_active"],
)
op.create_index(
"idx_mp_platform",
"marketplace_products",
["platform"],
)
op.create_index(
"idx_mp_sku",
"marketplace_products",
["sku"],
)
op.create_index(
"idx_mp_gtin_marketplace",
"marketplace_products",
["gtin", "marketplace"],
)
def downgrade() -> None:
# Drop indexes
op.drop_index("idx_mp_gtin_marketplace", table_name="marketplace_products")
op.drop_index("idx_mp_sku", table_name="marketplace_products")
op.drop_index("idx_mp_platform", table_name="marketplace_products")
op.drop_index("idx_mp_is_active", table_name="marketplace_products")
op.drop_index("idx_mp_product_type", table_name="marketplace_products")
# Drop new columns
op.drop_column("marketplace_products", "category_path")
op.drop_column("marketplace_products", "dimensions")
op.drop_column("marketplace_products", "weight_unit")
op.drop_column("marketplace_products", "weight")
op.drop_column("marketplace_products", "sku")
op.drop_column("marketplace_products", "is_active")
op.drop_column("marketplace_products", "additional_images")
op.drop_column("marketplace_products", "attributes")
op.drop_column("marketplace_products", "sale_price_numeric")
op.drop_column("marketplace_products", "price_numeric")
op.drop_column("marketplace_products", "source_url")
op.drop_column("marketplace_products", "license_type")
op.drop_column("marketplace_products", "region_restrictions")
op.drop_column("marketplace_products", "platform")
op.drop_column("marketplace_products", "digital_delivery_method")
op.drop_column("marketplace_products", "is_digital")
op.drop_column("marketplace_products", "product_type_enum")
# Rename product_type_raw back to product_type
op.alter_column(
"marketplace_products",
"product_type_raw",
new_column_name="product_type",
)

View File

@@ -0,0 +1,147 @@
"""Create translation tables for multi-language support
Revision ID: f2b3c4d5e6f7
Revises: e1a2b3c4d5e6
Create Date: 2025-12-11
This migration creates:
- marketplace_product_translations: Localized content from marketplace sources
- product_translations: Vendor-specific localized overrides
The translation tables support multi-language product information with
language fallback capabilities. Fields in product_translations can be
NULL to inherit from marketplace_product_translations.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "f2b3c4d5e6f7"
down_revision: Union[str, None] = "e1a2b3c4d5e6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create marketplace_product_translations table
# Note: Unique constraint is included in create_table for SQLite compatibility
op.create_table(
"marketplace_product_translations",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"marketplace_product_id",
sa.Integer(),
sa.ForeignKey("marketplace_products.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("language", sa.String(5), nullable=False),
# Localized content
sa.Column("title", sa.String(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("short_description", sa.String(500), nullable=True),
# SEO fields
sa.Column("meta_title", sa.String(70), nullable=True),
sa.Column("meta_description", sa.String(160), nullable=True),
sa.Column("url_slug", sa.String(255), nullable=True),
# Source tracking
sa.Column("source_import_id", sa.Integer(), nullable=True),
sa.Column("source_file", sa.String(), nullable=True),
# Timestamps
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
# Unique constraint included in table creation for SQLite
sa.UniqueConstraint(
"marketplace_product_id",
"language",
name="uq_marketplace_product_translation",
),
)
# Create indexes for marketplace_product_translations
op.create_index(
"idx_mpt_marketplace_product_id",
"marketplace_product_translations",
["marketplace_product_id"],
)
op.create_index(
"idx_mpt_language",
"marketplace_product_translations",
["language"],
)
# Create product_translations table
# Note: Unique constraint is included in create_table for SQLite compatibility
op.create_table(
"product_translations",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"product_id",
sa.Integer(),
sa.ForeignKey("products.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("language", sa.String(5), nullable=False),
# Overridable localized content (NULL = inherit from marketplace)
sa.Column("title", sa.String(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("short_description", sa.String(500), nullable=True),
# SEO overrides
sa.Column("meta_title", sa.String(70), nullable=True),
sa.Column("meta_description", sa.String(160), nullable=True),
sa.Column("url_slug", sa.String(255), nullable=True),
# Timestamps
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
# Unique constraint included in table creation for SQLite
sa.UniqueConstraint("product_id", "language", name="uq_product_translation"),
)
# Create indexes for product_translations
op.create_index(
"idx_pt_product_id",
"product_translations",
["product_id"],
)
op.create_index(
"idx_pt_product_language",
"product_translations",
["product_id", "language"],
)
def downgrade() -> None:
# Drop product_translations table and its indexes
op.drop_index("idx_pt_product_language", table_name="product_translations")
op.drop_index("idx_pt_product_id", table_name="product_translations")
op.drop_table("product_translations")
# Drop marketplace_product_translations table and its indexes
op.drop_index("idx_mpt_language", table_name="marketplace_product_translations")
op.drop_index(
"idx_mpt_marketplace_product_id", table_name="marketplace_product_translations"
)
op.drop_table("marketplace_product_translations")

View File

@@ -20,9 +20,15 @@ from .content_page import ContentPage
from .customer import Customer, CustomerAddress
from .inventory import Inventory
from .marketplace_import_job import MarketplaceImportJob
from .marketplace_product import MarketplaceProduct
from .marketplace_product import (
DigitalDeliveryMethod,
MarketplaceProduct,
ProductType,
)
from .marketplace_product_translation import MarketplaceProductTranslation
from .order import Order, OrderItem
from .product import Product
from .product_translation import ProductTranslation
from .user import User
from .vendor import Role, Vendor, VendorUser
from .vendor_domain import VendorDomain
@@ -40,21 +46,35 @@ __all__ = [
"ArchitectureViolation",
"ViolationAssignment",
"ViolationComment",
# Base
"Base",
# User & Auth
"User",
# Company & Vendor
"Company",
"ContentPage",
"Inventory",
"Customer",
"CustomerAddress",
"Order",
"OrderItem",
"Vendor",
"VendorUser",
"Role",
"Product",
"MarketplaceImportJob",
"MarketplaceProduct",
"VendorDomain",
"VendorTheme",
# Content
"ContentPage",
# Customer
"Customer",
"CustomerAddress",
# Product - Enums
"ProductType",
"DigitalDeliveryMethod",
# Product - Models
"MarketplaceProduct",
"MarketplaceProductTranslation",
"Product",
"ProductTranslation",
# Import
"MarketplaceImportJob",
# Inventory
"Inventory",
# Orders
"Order",
"OrderItem",
]

View File

@@ -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

View File

@@ -0,0 +1,76 @@
"""Marketplace Product Translation model for multi-language support.
This model stores localized content (title, description, SEO fields) for
marketplace products. Each marketplace product can have multiple translations
for different languages.
"""
from sqlalchemy import (
Column,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class MarketplaceProductTranslation(Base, TimestampMixin):
"""Localized content for marketplace products.
Stores translations for product titles, descriptions, and SEO fields.
Each marketplace_product can have one translation per language.
"""
__tablename__ = "marketplace_product_translations"
id = Column(Integer, primary_key=True, index=True)
marketplace_product_id = Column(
Integer,
ForeignKey("marketplace_products.id", ondelete="CASCADE"),
nullable=False,
)
language = Column(String(5), nullable=False) # 'en', 'fr', 'de', 'lb'
# === LOCALIZED CONTENT ===
title = Column(String, nullable=False)
description = Column(Text)
short_description = Column(String(500))
# === SEO FIELDS ===
meta_title = Column(String(70))
meta_description = Column(String(160))
url_slug = Column(String(255))
# === SOURCE TRACKING ===
source_import_id = Column(Integer) # Which import job provided this
source_file = Column(String) # e.g., "letzshop_fr.csv"
# === RELATIONSHIPS ===
marketplace_product = relationship(
"MarketplaceProduct",
back_populates="translations",
)
__table_args__ = (
UniqueConstraint(
"marketplace_product_id",
"language",
name="uq_marketplace_product_translation",
),
Index("idx_mpt_marketplace_product_id", "marketplace_product_id"),
Index("idx_mpt_language", "language"),
)
def __repr__(self):
return (
f"<MarketplaceProductTranslation(id={self.id}, "
f"marketplace_product_id={self.marketplace_product_id}, "
f"language='{self.language}', "
f"title='{self.title[:30] if self.title else None}...')>"
)

View File

@@ -1,4 +1,13 @@
# models/database/product.py
"""Vendor Product model with override pattern.
This model represents a vendor's copy of a marketplace product with the ability
to override any field. The override pattern works as follows:
- NULL value = inherit from marketplace_product
- Non-NULL value = vendor-specific override
This allows vendors to customize pricing, images, descriptions etc. while
still being able to "reset to source" by setting values back to NULL.
"""
from sqlalchemy import (
Boolean,
@@ -10,6 +19,7 @@ from sqlalchemy import (
String,
UniqueConstraint,
)
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -17,6 +27,13 @@ from models.database.base import TimestampMixin
class Product(Base, TimestampMixin):
"""Vendor-specific product with override capability.
Each vendor can have their own version of a marketplace product with
custom pricing, images, and other overrides. Fields set to NULL
inherit their value from the linked marketplace_product.
"""
__tablename__ = "products"
id = Column(Integer, primary_key=True, index=True)
@@ -25,46 +42,295 @@ class Product(Base, TimestampMixin):
Integer, ForeignKey("marketplace_products.id"), nullable=False
)
# Vendor-specific overrides
product_id = Column(String) # Vendor's internal SKU
# === VENDOR REFERENCE ===
vendor_sku = Column(String, index=True) # Vendor's internal SKU
# === OVERRIDABLE FIELDS (NULL = inherit from marketplace_product) ===
# Pricing
price = Column(Float)
sale_price = Column(Float)
currency = Column(String)
availability = Column(String)
condition = Column(String)
currency = Column(String(3))
# Vendor-specific metadata
# Product Info
brand = Column(String)
condition = Column(String)
availability = Column(String)
# Media
primary_image_url = Column(String)
additional_images = Column(JSON)
# Digital Product Overrides
download_url = Column(String)
license_type = Column(String(50))
# === SUPPLIER TRACKING ===
supplier = Column(String(50)) # 'codeswholesale', 'internal', etc.
supplier_product_id = Column(String) # Supplier's product reference
supplier_cost = Column(Float) # What we pay the supplier
margin_percent = Column(Float) # Markup percentage
# === VENDOR-SPECIFIC (No inheritance) ===
is_featured = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
display_order = Column(Integer, default=0)
# Inventory settings
# Inventory Settings
min_quantity = Column(Integer, default=1)
max_quantity = Column(Integer)
# Relationships
# Digital Fulfillment
fulfillment_email_template = Column(String) # Template name for digital delivery
# === RELATIONSHIPS ===
vendor = relationship("Vendor", back_populates="products")
marketplace_product = relationship("MarketplaceProduct", back_populates="product")
marketplace_product = relationship(
"MarketplaceProduct", back_populates="vendor_products"
)
translations = relationship(
"ProductTranslation",
back_populates="product",
cascade="all, delete-orphan",
)
inventory_entries = relationship(
"Inventory", back_populates="product", cascade="all, delete-orphan"
)
# Constraints
# === CONSTRAINTS & INDEXES ===
__table_args__ = (
UniqueConstraint("vendor_id", "marketplace_product_id", name="uq_product"),
Index("idx_product_active", "vendor_id", "is_active"),
Index("idx_product_featured", "vendor_id", "is_featured"),
UniqueConstraint(
"vendor_id", "marketplace_product_id", name="uq_vendor_marketplace_product"
),
Index("idx_product_vendor_active", "vendor_id", "is_active"),
Index("idx_product_vendor_featured", "vendor_id", "is_featured"),
Index("idx_product_vendor_sku", "vendor_id", "vendor_sku"),
Index("idx_product_supplier", "supplier", "supplier_product_id"),
)
# === OVERRIDABLE FIELDS LIST ===
OVERRIDABLE_FIELDS = [
"price",
"sale_price",
"currency",
"brand",
"condition",
"availability",
"primary_image_url",
"additional_images",
"download_url",
"license_type",
]
def __repr__(self):
return f"<Product(id={self.id}, vendor_id={self.vendor_id}, product_id='{self.product_id}')>"
return (
f"<Product(id={self.id}, vendor_id={self.vendor_id}, "
f"vendor_sku='{self.vendor_sku}')>"
)
# === EFFECTIVE PROPERTIES (Override Pattern) ===
@property
def total_inventory(self):
def effective_price(self) -> float | None:
"""Get price (vendor override or marketplace fallback)."""
if self.price is not None:
return self.price
mp = self.marketplace_product
return mp.price_numeric if mp else None
@property
def effective_sale_price(self) -> float | None:
"""Get sale price (vendor override or marketplace fallback)."""
if self.sale_price is not None:
return self.sale_price
mp = self.marketplace_product
return mp.sale_price_numeric if mp else None
@property
def effective_currency(self) -> str:
"""Get currency (vendor override or marketplace fallback)."""
if self.currency is not None:
return self.currency
mp = self.marketplace_product
return mp.currency if mp else "EUR"
@property
def effective_brand(self) -> str | None:
"""Get brand (vendor override or marketplace fallback)."""
if self.brand is not None:
return self.brand
mp = self.marketplace_product
return mp.brand if mp else None
@property
def effective_condition(self) -> str | None:
"""Get condition (vendor override or marketplace fallback)."""
if self.condition is not None:
return self.condition
mp = self.marketplace_product
return mp.condition if mp else None
@property
def effective_availability(self) -> str | None:
"""Get availability (vendor override or marketplace fallback)."""
if self.availability is not None:
return self.availability
mp = self.marketplace_product
return mp.availability if mp else None
@property
def effective_primary_image_url(self) -> str | None:
"""Get primary image (vendor override or marketplace fallback)."""
if self.primary_image_url is not None:
return self.primary_image_url
mp = self.marketplace_product
return mp.image_link if mp else None
@property
def effective_additional_images(self) -> list | None:
"""Get additional images (vendor override or marketplace fallback)."""
if self.additional_images is not None:
return self.additional_images
mp = self.marketplace_product
if mp and mp.additional_images:
return mp.additional_images
return None
@property
def is_digital(self) -> bool:
"""Check if this is a digital product."""
mp = self.marketplace_product
return mp.is_digital if mp else False
@property
def product_type(self) -> str:
"""Get product type from marketplace product."""
mp = self.marketplace_product
return mp.product_type_enum if mp else "physical"
# === INVENTORY PROPERTIES ===
@property
def total_inventory(self) -> int:
"""Calculate total inventory across all locations."""
return sum(inv.quantity for inv in self.inventory_entries)
@property
def available_inventory(self):
def available_inventory(self) -> int:
"""Calculate available inventory (total - reserved)."""
return sum(inv.available_quantity for inv in self.inventory_entries)
# === OVERRIDE INFO METHOD ===
def get_override_info(self) -> dict:
"""Get all fields with inheritance flags.
Returns a dict with effective values, override flags, and source values.
Similar to Vendor.get_contact_info_with_inheritance().
"""
mp = self.marketplace_product
return {
# Price
"price": self.effective_price,
"price_overridden": self.price is not None,
"price_source": mp.price_numeric if mp else None,
# Sale Price
"sale_price": self.effective_sale_price,
"sale_price_overridden": self.sale_price is not None,
"sale_price_source": mp.sale_price_numeric if mp else None,
# Currency
"currency": self.effective_currency,
"currency_overridden": self.currency is not None,
"currency_source": mp.currency if mp else None,
# Brand
"brand": self.effective_brand,
"brand_overridden": self.brand is not None,
"brand_source": mp.brand if mp else None,
# Condition
"condition": self.effective_condition,
"condition_overridden": self.condition is not None,
"condition_source": mp.condition if mp else None,
# Availability
"availability": self.effective_availability,
"availability_overridden": self.availability is not None,
"availability_source": mp.availability if mp else None,
# Images
"primary_image_url": self.effective_primary_image_url,
"primary_image_url_overridden": self.primary_image_url is not None,
"primary_image_url_source": mp.image_link if mp else None,
# Product type info
"is_digital": self.is_digital,
"product_type": self.product_type,
}
# === RESET METHODS ===
def reset_field_to_source(self, field_name: str) -> bool:
"""Reset a single field to inherit from marketplace product.
Args:
field_name: Name of the field to reset
Returns:
True if field was reset, False if field is not overridable
"""
if field_name in self.OVERRIDABLE_FIELDS:
setattr(self, field_name, None)
return True
return False
def reset_all_to_source(self) -> None:
"""Reset all overridable fields to inherit from marketplace product."""
for field in self.OVERRIDABLE_FIELDS:
setattr(self, field, None)
def reset_fields_to_source(self, field_names: list[str]) -> list[str]:
"""Reset multiple fields to inherit from marketplace product.
Args:
field_names: List of field names to reset
Returns:
List of fields that were successfully reset
"""
reset_fields = []
for field in field_names:
if self.reset_field_to_source(field):
reset_fields.append(field)
return reset_fields
# === TRANSLATION HELPERS ===
def get_translation(self, language: str) -> "ProductTranslation | None":
"""Get vendor translation for a specific language."""
for t in self.translations:
if t.language == language:
return t
return None
def get_effective_title(self, language: str = "en") -> str | None:
"""Get title with vendor override or marketplace fallback."""
# Check vendor translation first
translation = self.get_translation(language)
if translation and translation.title:
return translation.title
# Fall back to marketplace translation
mp = self.marketplace_product
if mp:
return mp.get_title(language)
return None
def get_effective_description(self, language: str = "en") -> str | None:
"""Get description with vendor override or marketplace fallback."""
# Check vendor translation first
translation = self.get_translation(language)
if translation and translation.description:
return translation.description
# Fall back to marketplace translation
mp = self.marketplace_product
if mp:
return mp.get_description(language)
return None

View File

@@ -0,0 +1,214 @@
"""Product Translation model for vendor-specific localized content.
This model stores vendor-specific translations that can override the
marketplace product translations. The override pattern works as follows:
- NULL value = inherit from marketplace_product_translations
- Non-NULL value = vendor-specific override
This allows vendors to customize titles, descriptions, and SEO content
per language while still being able to "reset to source" by setting
values back to NULL.
"""
from sqlalchemy import (
Column,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class ProductTranslation(Base, TimestampMixin):
"""Vendor-specific localized content with override capability.
Each vendor can have their own translations that override the
marketplace product translations. Fields set to NULL inherit
their value from the linked marketplace_product_translation.
"""
__tablename__ = "product_translations"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(
Integer,
ForeignKey("products.id", ondelete="CASCADE"),
nullable=False,
)
language = Column(String(5), nullable=False) # 'en', 'fr', 'de', 'lb'
# === OVERRIDABLE LOCALIZED FIELDS (NULL = inherit) ===
title = Column(String)
description = Column(Text)
short_description = Column(String(500))
# SEO Overrides
meta_title = Column(String(70))
meta_description = Column(String(160))
url_slug = Column(String(255))
# === RELATIONSHIPS ===
product = relationship("Product", back_populates="translations")
__table_args__ = (
UniqueConstraint("product_id", "language", name="uq_product_translation"),
Index("idx_pt_product_id", "product_id"),
Index("idx_pt_product_language", "product_id", "language"),
)
# === OVERRIDABLE FIELDS LIST ===
OVERRIDABLE_FIELDS = [
"title",
"description",
"short_description",
"meta_title",
"meta_description",
"url_slug",
]
def __repr__(self):
return (
f"<ProductTranslation(id={self.id}, "
f"product_id={self.product_id}, "
f"language='{self.language}', "
f"title='{self.title[:30] if self.title else None}...')>"
)
# === HELPER METHODS ===
def _get_marketplace_translation(self):
"""Get the corresponding marketplace translation for fallback."""
product = self.product
if product and product.marketplace_product:
mp = product.marketplace_product
for t in mp.translations:
if t.language == self.language:
return t
return None
def _get_marketplace_translation_field(self, field: str):
"""Helper to get a field from marketplace translation."""
mp_translation = self._get_marketplace_translation()
if mp_translation:
return getattr(mp_translation, field, None)
return None
# === EFFECTIVE PROPERTIES (Override Pattern) ===
def get_effective_title(self) -> str | None:
"""Get title with fallback to marketplace translation."""
if self.title is not None:
return self.title
return self._get_marketplace_translation_field("title")
def get_effective_description(self) -> str | None:
"""Get description with fallback to marketplace translation."""
if self.description is not None:
return self.description
return self._get_marketplace_translation_field("description")
def get_effective_short_description(self) -> str | None:
"""Get short description with fallback to marketplace translation."""
if self.short_description is not None:
return self.short_description
return self._get_marketplace_translation_field("short_description")
def get_effective_meta_title(self) -> str | None:
"""Get meta title with fallback to marketplace translation."""
if self.meta_title is not None:
return self.meta_title
return self._get_marketplace_translation_field("meta_title")
def get_effective_meta_description(self) -> str | None:
"""Get meta description with fallback to marketplace translation."""
if self.meta_description is not None:
return self.meta_description
return self._get_marketplace_translation_field("meta_description")
def get_effective_url_slug(self) -> str | None:
"""Get URL slug with fallback to marketplace translation."""
if self.url_slug is not None:
return self.url_slug
return self._get_marketplace_translation_field("url_slug")
# === OVERRIDE INFO METHOD ===
def get_override_info(self) -> dict:
"""Get all fields with inheritance flags.
Returns a dict with effective values and override flags.
"""
mp_translation = self._get_marketplace_translation()
return {
# Title
"title": self.get_effective_title(),
"title_overridden": self.title is not None,
"title_source": mp_translation.title if mp_translation else None,
# Description
"description": self.get_effective_description(),
"description_overridden": self.description is not None,
"description_source": mp_translation.description if mp_translation else None,
# Short Description
"short_description": self.get_effective_short_description(),
"short_description_overridden": self.short_description is not None,
"short_description_source": (
mp_translation.short_description if mp_translation else None
),
# Meta Title
"meta_title": self.get_effective_meta_title(),
"meta_title_overridden": self.meta_title is not None,
"meta_title_source": mp_translation.meta_title if mp_translation else None,
# Meta Description
"meta_description": self.get_effective_meta_description(),
"meta_description_overridden": self.meta_description is not None,
"meta_description_source": (
mp_translation.meta_description if mp_translation else None
),
# URL Slug
"url_slug": self.get_effective_url_slug(),
"url_slug_overridden": self.url_slug is not None,
"url_slug_source": mp_translation.url_slug if mp_translation else None,
}
# === RESET METHODS ===
def reset_field_to_source(self, field_name: str) -> bool:
"""Reset a single field to inherit from marketplace translation.
Args:
field_name: Name of the field to reset
Returns:
True if field was reset, False if field is not overridable
"""
if field_name in self.OVERRIDABLE_FIELDS:
setattr(self, field_name, None)
return True
return False
def reset_to_source(self) -> None:
"""Reset all fields to inherit from marketplace translation."""
for field in self.OVERRIDABLE_FIELDS:
setattr(self, field, None)
def reset_fields_to_source(self, field_names: list[str]) -> list[str]:
"""Reset multiple fields to inherit from marketplace translation.
Args:
field_names: List of field names to reset
Returns:
List of fields that were successfully reset
"""
reset_fields = []
for field in field_names:
if self.reset_field_to_source(field):
reset_fields.append(field)
return reset_fields