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:
126
alembic/versions/a3b4c5d6e7f8_add_product_override_fields.py
Normal file
126
alembic/versions/a3b4c5d6e7f8_add_product_override_fields.py
Normal 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",
|
||||
)
|
||||
@@ -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'")
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
147
alembic/versions/f2b3c4d5e6f7_create_translation_tables.py
Normal file
147
alembic/versions/f2b3c4d5e6f7_create_translation_tables.py
Normal 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")
|
||||
Reference in New Issue
Block a user