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")