refactor: product independence - remove inheritance pattern

Change Product/ProductTranslation from "override/inheritance" pattern
(NULL = inherit from marketplace) to "independent copy" pattern
(all fields populated at creation).

Key changes:
- Remove OVERRIDABLE_FIELDS, effective_* properties, reset_* methods
- Rename get_override_info() → get_source_comparison_info()
- Update copy_to_vendor_catalog() to copy ALL fields + translations
- Replace effective_* with direct field access in services
- Remove *_overridden fields from schema, keep *_source for comparison
- Add migration to populate NULL fields from marketplace products

The marketplace_product_id FK is kept for "view original source" feature.
Rollback tag: v1.0.0-pre-product-independence

🤖 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-24 23:41:20 +01:00
parent 4ba911e263
commit 508e121a0e
10 changed files with 444 additions and 418 deletions

View File

@@ -0,0 +1,262 @@
"""Populate product fields from marketplace for independence refactor
Revision ID: j8e9f0a1b2c3
Revises: i7d8e9f0a1b2
Create Date: 2025-12-24
This migration populates NULL fields on products and product_translations
with values from their linked marketplace products. This is part of the
"product independence" refactor where products become standalone entities
instead of inheriting from marketplace products via NULL fallback.
After this migration:
- All Product fields will have actual values (no NULL inheritance)
- All ProductTranslation records will exist with actual values
- The marketplace_product_id FK is kept for "view original source" feature
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision: str = "j8e9f0a1b2c3"
down_revision: Union[str, None] = "i7d8e9f0a1b2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Populate NULL product fields with marketplace product values."""
# Get database connection for raw SQL
connection = op.get_bind()
# =========================================================================
# STEP 1: Populate Product fields from MarketplaceProduct
# =========================================================================
# Price cents
connection.execute(text("""
UPDATE products
SET price_cents = (
SELECT mp.price_cents
FROM marketplace_products mp
WHERE mp.id = products.marketplace_product_id
)
WHERE price_cents IS NULL
AND marketplace_product_id IS NOT NULL
"""))
# Sale price cents
connection.execute(text("""
UPDATE products
SET sale_price_cents = (
SELECT mp.sale_price_cents
FROM marketplace_products mp
WHERE mp.id = products.marketplace_product_id
)
WHERE sale_price_cents IS NULL
AND marketplace_product_id IS NOT NULL
"""))
# Currency (default to EUR if marketplace has NULL)
connection.execute(text("""
UPDATE products
SET currency = COALESCE(
(SELECT mp.currency FROM marketplace_products mp WHERE mp.id = products.marketplace_product_id),
'EUR'
)
WHERE currency IS NULL
AND marketplace_product_id IS NOT NULL
"""))
# Brand
connection.execute(text("""
UPDATE products
SET brand = (
SELECT mp.brand
FROM marketplace_products mp
WHERE mp.id = products.marketplace_product_id
)
WHERE brand IS NULL
AND marketplace_product_id IS NOT NULL
"""))
# Condition
connection.execute(text("""
UPDATE products
SET condition = (
SELECT mp.condition
FROM marketplace_products mp
WHERE mp.id = products.marketplace_product_id
)
WHERE condition IS NULL
AND marketplace_product_id IS NOT NULL
"""))
# Availability
connection.execute(text("""
UPDATE products
SET availability = (
SELECT mp.availability
FROM marketplace_products mp
WHERE mp.id = products.marketplace_product_id
)
WHERE availability IS NULL
AND marketplace_product_id IS NOT NULL
"""))
# Primary image URL (marketplace uses 'image_link')
connection.execute(text("""
UPDATE products
SET primary_image_url = (
SELECT mp.image_link
FROM marketplace_products mp
WHERE mp.id = products.marketplace_product_id
)
WHERE primary_image_url IS NULL
AND marketplace_product_id IS NOT NULL
"""))
# Additional images
connection.execute(text("""
UPDATE products
SET additional_images = (
SELECT mp.additional_images
FROM marketplace_products mp
WHERE mp.id = products.marketplace_product_id
)
WHERE additional_images IS NULL
AND marketplace_product_id IS NOT NULL
"""))
# =========================================================================
# STEP 2: Create missing ProductTranslation records from MarketplaceProductTranslation
# =========================================================================
# Insert missing translations (where product doesn't have translation for a language
# that the marketplace product has)
connection.execute(text("""
INSERT INTO product_translations (product_id, language, title, description, short_description,
meta_title, meta_description, url_slug, created_at, updated_at)
SELECT
p.id,
mpt.language,
mpt.title,
mpt.description,
mpt.short_description,
mpt.meta_title,
mpt.meta_description,
mpt.url_slug,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM products p
JOIN marketplace_products mp ON mp.id = p.marketplace_product_id
JOIN marketplace_product_translations mpt ON mpt.marketplace_product_id = mp.id
WHERE NOT EXISTS (
SELECT 1 FROM product_translations pt
WHERE pt.product_id = p.id AND pt.language = mpt.language
)
"""))
# =========================================================================
# STEP 3: Update existing ProductTranslation NULL fields with marketplace values
# =========================================================================
# Update title where NULL
connection.execute(text("""
UPDATE product_translations
SET title = (
SELECT mpt.title
FROM products p
JOIN marketplace_products mp ON mp.id = p.marketplace_product_id
JOIN marketplace_product_translations mpt ON mpt.marketplace_product_id = mp.id
AND mpt.language = product_translations.language
WHERE p.id = product_translations.product_id
)
WHERE title IS NULL
"""))
# Update description where NULL
connection.execute(text("""
UPDATE product_translations
SET description = (
SELECT mpt.description
FROM products p
JOIN marketplace_products mp ON mp.id = p.marketplace_product_id
JOIN marketplace_product_translations mpt ON mpt.marketplace_product_id = mp.id
AND mpt.language = product_translations.language
WHERE p.id = product_translations.product_id
)
WHERE description IS NULL
"""))
# Update short_description where NULL
connection.execute(text("""
UPDATE product_translations
SET short_description = (
SELECT mpt.short_description
FROM products p
JOIN marketplace_products mp ON mp.id = p.marketplace_product_id
JOIN marketplace_product_translations mpt ON mpt.marketplace_product_id = mp.id
AND mpt.language = product_translations.language
WHERE p.id = product_translations.product_id
)
WHERE short_description IS NULL
"""))
# Update meta_title where NULL
connection.execute(text("""
UPDATE product_translations
SET meta_title = (
SELECT mpt.meta_title
FROM products p
JOIN marketplace_products mp ON mp.id = p.marketplace_product_id
JOIN marketplace_product_translations mpt ON mpt.marketplace_product_id = mp.id
AND mpt.language = product_translations.language
WHERE p.id = product_translations.product_id
)
WHERE meta_title IS NULL
"""))
# Update meta_description where NULL
connection.execute(text("""
UPDATE product_translations
SET meta_description = (
SELECT mpt.meta_description
FROM products p
JOIN marketplace_products mp ON mp.id = p.marketplace_product_id
JOIN marketplace_product_translations mpt ON mpt.marketplace_product_id = mp.id
AND mpt.language = product_translations.language
WHERE p.id = product_translations.product_id
)
WHERE meta_description IS NULL
"""))
# Update url_slug where NULL
connection.execute(text("""
UPDATE product_translations
SET url_slug = (
SELECT mpt.url_slug
FROM products p
JOIN marketplace_products mp ON mp.id = p.marketplace_product_id
JOIN marketplace_product_translations mpt ON mpt.marketplace_product_id = mp.id
AND mpt.language = product_translations.language
WHERE p.id = product_translations.product_id
)
WHERE url_slug IS NULL
"""))
def downgrade() -> None:
"""
Downgrade is a no-op for data population.
The data was copied, not moved. The original marketplace product data
is still intact. We don't reset fields to NULL because:
1. It would lose any vendor customizations made after migration
2. The model code may still work with populated fields
"""
pass

View File

@@ -176,8 +176,8 @@ class CartService:
# Get current price in cents (use sale_price if available, otherwise regular price)
current_price_cents = (
product.effective_sale_price_cents
or product.effective_price_cents
product.sale_price_cents
or product.price_cents
or 0
)

View File

@@ -835,10 +835,18 @@ class MarketplaceProductService:
"""
Copy marketplace products to a vendor's catalog.
Creates independent vendor products with ALL fields copied from the
marketplace product. Each vendor product is a standalone entity - no
field inheritance or fallback logic. The marketplace_product_id FK is
kept for "view original source" feature.
Also copies ALL translations from the marketplace product.
Returns:
Dict with copied, skipped, failed counts and details
"""
from models.database.product import Product
from models.database.product_translation import ProductTranslation
from models.database.vendor import Vendor
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
@@ -849,6 +857,7 @@ class MarketplaceProductService:
marketplace_products = (
db.query(MarketplaceProduct)
.options(joinedload(MarketplaceProduct.translations))
.filter(MarketplaceProduct.id.in_(marketplace_product_ids))
.all()
)
@@ -883,19 +892,58 @@ class MarketplaceProductService:
)
continue
# Create vendor product with ALL fields copied from marketplace
product = Product(
vendor_id=vendor_id,
marketplace_product_id=mp.id,
# === Vendor settings (defaults) ===
is_active=True,
is_featured=False,
# Copy GTIN for order matching
# === Product identifiers ===
gtin=mp.gtin,
gtin_type=mp.gtin_type if hasattr(mp, "gtin_type") else None,
# === Pricing (copy from marketplace) ===
price_cents=mp.price_cents,
sale_price_cents=mp.sale_price_cents,
currency=mp.currency or "EUR",
# === Product info ===
brand=mp.brand,
condition=mp.condition,
availability=mp.availability,
# === Media ===
primary_image_url=mp.image_link,
additional_images=mp.additional_images,
# === Digital product fields ===
download_url=mp.download_url if hasattr(mp, "download_url") else None,
license_type=mp.license_type if hasattr(mp, "license_type") else None,
)
db.add(product)
db.flush() # Get product.id for translations
# Copy ALL translations from marketplace product
translations_copied = 0
for mpt in mp.translations:
product_translation = ProductTranslation(
product_id=product.id,
language=mpt.language,
title=mpt.title,
description=mpt.description,
short_description=mpt.short_description,
meta_title=mpt.meta_title,
meta_description=mpt.meta_description,
url_slug=mpt.url_slug,
)
db.add(product_translation)
translations_copied += 1
copied += 1
details.append({"id": mp.id, "status": "copied", "gtin": mp.gtin})
details.append({
"id": mp.id,
"status": "copied",
"gtin": mp.gtin,
"translations_copied": translations_copied,
})
except Exception as e:
logger.error(f"Failed to copy product {mp.id}: {str(e)}")

View File

@@ -340,8 +340,8 @@ class OrderService:
# Get price in cents (prefer sale price, then regular price)
unit_price_cents = (
product.effective_sale_price_cents
or product.effective_price_cents
product.sale_price_cents
or product.price_cents
)
if not unit_price_cents:
raise ValidationException(f"Product {product.id} has no price")

View File

@@ -171,9 +171,9 @@ class VendorProductService:
raise ProductNotFoundException(product_id)
mp = product.marketplace_product
override_info = product.get_override_info()
source_comparison_info = product.get_source_comparison_info()
# Get marketplace product translations
# Get marketplace product translations (for "view original source")
mp_translations = {}
if mp:
for t in mp.translations:
@@ -183,7 +183,7 @@ class VendorProductService:
"short_description": t.short_description,
}
# Get vendor translations (overrides)
# Get vendor translations
vendor_translations = {}
for t in product.translations:
vendor_translations[t.language] = {
@@ -198,8 +198,8 @@ class VendorProductService:
"vendor_code": product.vendor.vendor_code if product.vendor else None,
"marketplace_product_id": product.marketplace_product_id,
"vendor_sku": product.vendor_sku,
# Override info
**override_info,
# Product fields with source comparison info
**source_comparison_info,
# Vendor-specific fields
"is_featured": product.is_featured,
"is_active": product.is_active,
@@ -270,13 +270,13 @@ class VendorProductService:
"marketplace_product_id": product.marketplace_product_id,
"vendor_sku": product.vendor_sku,
"title": title,
"brand": product.effective_brand,
"effective_price": product.effective_price,
"effective_currency": product.effective_currency,
"brand": product.brand,
"price": product.price,
"currency": product.currency,
"is_active": product.is_active,
"is_featured": product.is_featured,
"is_digital": product.is_digital,
"image_url": product.effective_primary_image_url,
"image_url": product.primary_image_url,
"source_marketplace": mp.marketplace if mp else None,
"source_vendor": mp.vendor_name if mp else None,
"created_at": product.created_at.isoformat()

View File

@@ -1,12 +1,11 @@
"""Vendor Product model with override pattern.
"""Vendor Product model - independent copy 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 model represents a vendor's copy of a marketplace product. Products are
independent entities with all fields populated at creation time from the source
marketplace product.
This allows vendors to customize pricing, images, descriptions etc. while
still being able to "reset to source" by setting values back to NULL.
The marketplace_product_id FK is kept for "view original source" feature,
allowing comparison with the original marketplace data.
Money values are stored as integer cents (e.g., €105.91 = 10591).
See docs/architecture/money-handling.md for details.
@@ -30,11 +29,11 @@ from models.database.base import TimestampMixin
class Product(Base, TimestampMixin):
"""Vendor-specific product with override capability.
"""Vendor-specific product - independent copy.
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.
Each vendor has their own copy of a marketplace product with all fields
populated at creation time. The marketplace_product_id FK is kept for
"view original source" comparison feature.
Price fields use integer cents for precision (€19.99 = 1999 cents).
"""
@@ -56,11 +55,11 @@ class Product(Base, TimestampMixin):
gtin = Column(String(50), index=True) # EAN/UPC barcode number
gtin_type = Column(String(20)) # Format: gtin13, gtin14, gtin12, gtin8, isbn13, isbn10
# === OVERRIDABLE FIELDS (NULL = inherit from marketplace_product) ===
# === PRODUCT FIELDS (copied from marketplace at creation) ===
# Pricing - stored as integer cents (€19.99 = 1999)
price_cents = Column(Integer) # Price in cents
sale_price_cents = Column(Integer) # Sale price in cents
currency = Column(String(3))
currency = Column(String(3), default="EUR")
# Product Info
brand = Column(String)
@@ -71,7 +70,7 @@ class Product(Base, TimestampMixin):
primary_image_url = Column(String)
additional_images = Column(JSON)
# Digital Product Overrides
# Digital Product Fields
download_url = Column(String)
license_type = Column(String(50))
@@ -123,20 +122,6 @@ class Product(Base, TimestampMixin):
Index("idx_product_supplier", "supplier", "supplier_product_id"),
)
# === OVERRIDABLE FIELDS LIST ===
OVERRIDABLE_FIELDS = [
"price_cents",
"sale_price_cents",
"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}, "
@@ -202,12 +187,11 @@ class Product(Base, TimestampMixin):
Formula: Net = Gross / (1 + rate/100)
Example: €119 gross at 17% VAT = €119 / 1.17 = €101.71 net
"""
gross = self.effective_price_cents
if gross is None:
if self.price_cents is None:
return None
# Use integer math to avoid floating point issues
# Net = Gross * 100 / (100 + rate)
return int(gross * 100 / (100 + self.tax_rate_percent))
return int(self.price_cents * 100 / (100 + self.tax_rate_percent))
@property
def net_price(self) -> float | None:
@@ -221,11 +205,12 @@ class Product(Base, TimestampMixin):
Formula: VAT = Gross - Net
"""
gross = self.effective_price_cents
net = self.net_price_cents
if gross is None or net is None:
if self.price_cents is None:
return None
return gross - net
net = self.net_price_cents
if net is None:
return None
return self.price_cents - net
@property
def vat_amount(self) -> float | None:
@@ -264,85 +249,7 @@ class Product(Base, TimestampMixin):
return None
return round((profit / net) * 100, 2)
# === EFFECTIVE PROPERTIES (Override Pattern) ===
@property
def effective_price_cents(self) -> int | None:
"""Get price in cents (vendor override or marketplace fallback)."""
if self.price_cents is not None:
return self.price_cents
mp = self.marketplace_product
return mp.price_cents if mp else None
@property
def effective_price(self) -> float | None:
"""Get price in euros (vendor override or marketplace fallback)."""
cents = self.effective_price_cents
return cents_to_euros(cents) if cents is not None else None
@property
def effective_sale_price_cents(self) -> int | None:
"""Get sale price in cents (vendor override or marketplace fallback)."""
if self.sale_price_cents is not None:
return self.sale_price_cents
mp = self.marketplace_product
return mp.sale_price_cents if mp else None
@property
def effective_sale_price(self) -> float | None:
"""Get sale price in euros (vendor override or marketplace fallback)."""
cents = self.effective_sale_price_cents
return cents_to_euros(cents) if cents is not None 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
# === MARKETPLACE PRODUCT PROPERTIES ===
@property
def is_digital(self) -> bool:
@@ -391,120 +298,59 @@ class Product(Base, TimestampMixin):
return self.UNLIMITED_INVENTORY
return sum(inv.available_quantity for inv in self.inventory_entries)
# === OVERRIDE INFO METHOD ===
# === SOURCE COMPARISON METHOD ===
def get_override_info(self) -> dict:
"""Get all fields with inheritance flags.
def get_source_comparison_info(self) -> dict:
"""Get current values with source values for comparison.
Returns a dict with effective values, override flags, and source values.
Similar to Vendor.get_contact_info_with_inheritance().
Returns a dict with current field values and original source values
from the marketplace product. Used for "view original source" feature.
"""
mp = self.marketplace_product
return {
# Price
"price": self.effective_price,
"price_cents": self.effective_price_cents,
"price_overridden": self.price_cents is not None,
"price": self.price,
"price_cents": self.price_cents,
"price_source": cents_to_euros(mp.price_cents) if mp and mp.price_cents else None,
# Sale Price
"sale_price": self.effective_sale_price,
"sale_price_cents": self.effective_sale_price_cents,
"sale_price_overridden": self.sale_price_cents is not None,
"sale_price": self.sale_price,
"sale_price_cents": self.sale_price_cents,
"sale_price_source": cents_to_euros(mp.sale_price_cents) if mp and mp.sale_price_cents else None,
# Currency
"currency": self.effective_currency,
"currency_overridden": self.currency is not None,
"currency": self.currency,
"currency_source": mp.currency if mp else None,
# Brand
"brand": self.effective_brand,
"brand_overridden": self.brand is not None,
"brand": self.brand,
"brand_source": mp.brand if mp else None,
# Condition
"condition": self.effective_condition,
"condition_overridden": self.condition is not None,
"condition": self.condition,
"condition_source": mp.condition if mp else None,
# Availability
"availability": self.effective_availability,
"availability_overridden": self.availability is not None,
"availability": self.availability,
"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": self.primary_image_url,
"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."""
"""Get 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
def get_title(self, language: str = "en") -> str | None:
"""Get title for a specific language."""
translation = self.get_translation(language)
if translation and translation.title:
return translation.title
return translation.title if translation else None
# 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
def get_description(self, language: str = "en") -> str | None:
"""Get description for a specific language."""
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
return translation.description if translation else None

View File

@@ -1,13 +1,11 @@
"""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 model stores vendor-specific translations. Translations are independent
entities with all fields populated at creation time from the source
marketplace product translation.
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.
The marketplace product translation can be accessed via the product's
marketplace_product relationship for "view original source" comparison.
"""
from sqlalchemy import (
@@ -26,11 +24,11 @@ from models.database.base import TimestampMixin
class ProductTranslation(Base, TimestampMixin):
"""Vendor-specific localized content with override capability.
"""Vendor-specific localized content - independent copy.
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.
Each vendor has their own translations with all fields populated
at creation time. The source marketplace translation can be accessed
for comparison via the product's marketplace_product relationship.
"""
__tablename__ = "product_translations"
@@ -43,12 +41,12 @@ class ProductTranslation(Base, TimestampMixin):
)
language = Column(String(5), nullable=False) # 'en', 'fr', 'de', 'lb'
# === OVERRIDABLE LOCALIZED FIELDS (NULL = inherit) ===
# === LOCALIZED FIELDS (copied from marketplace at creation) ===
title = Column(String)
description = Column(Text)
short_description = Column(String(500))
# SEO Overrides
# SEO Fields
meta_title = Column(String(70))
meta_description = Column(String(160))
url_slug = Column(String(255))
@@ -62,16 +60,6 @@ class ProductTranslation(Base, TimestampMixin):
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}, "
@@ -80,10 +68,10 @@ class ProductTranslation(Base, TimestampMixin):
f"title='{self.title[:30] if self.title else None}...')>"
)
# === HELPER METHODS ===
# === SOURCE COMPARISON METHOD ===
def _get_marketplace_translation(self):
"""Get the corresponding marketplace translation for fallback."""
def _find_marketplace_translation(self):
"""Get the corresponding marketplace translation for comparison."""
product = self.product
if product and product.marketplace_product:
mp = product.marketplace_product
@@ -92,125 +80,35 @@ class ProductTranslation(Base, TimestampMixin):
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
def get_source_comparison_info(self) -> dict:
"""Get current values with source values for comparison.
# === 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.
Returns a dict with current field values and original source values
from the marketplace product translation. Used for "view original source" feature.
"""
mp_translation = self._get_marketplace_translation()
mp_translation = self._find_marketplace_translation()
return {
# Title
"title": self.get_effective_title(),
"title_overridden": self.title is not None,
"title": self.title,
"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,
"description": self.description,
"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": self.short_description,
"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": self.meta_title,
"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": self.meta_description,
"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": self.url_slug,
"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

View File

@@ -25,8 +25,8 @@ class VendorProductListItem(BaseModel):
vendor_sku: str | None = None
title: str | None = None
brand: str | None = None
effective_price: float | None = None
effective_currency: str | None = None
price: float | None = None
currency: str | None = None
is_active: bool | None = None
is_featured: bool | None = None
is_digital: bool | None = None
@@ -73,7 +73,11 @@ class CatalogVendorsResponse(BaseModel):
class VendorProductDetail(BaseModel):
"""Detailed vendor product information."""
"""Detailed vendor product information.
Products are independent entities - all fields are populated at creation.
Source values are kept for "view original source" comparison only.
"""
id: int
vendor_id: int
@@ -81,27 +85,22 @@ class VendorProductDetail(BaseModel):
vendor_code: str | None = None
marketplace_product_id: int
vendor_sku: str | None = None
# Override info from get_override_info()
# Product fields with source comparison
price: float | None = None
price_overridden: bool | None = None
price_source: float | None = None
price_cents: int | None = None
price_source: float | None = None # For "view original source" feature
sale_price: float | None = None
sale_price_overridden: bool | None = None
sale_price_cents: int | None = None
sale_price_source: float | None = None
currency: str | None = None
currency_overridden: bool | None = None
currency_source: str | None = None
brand: str | None = None
brand_overridden: bool | None = None
brand_source: str | None = None
condition: str | None = None
condition_overridden: bool | None = None
condition_source: str | None = None
availability: str | None = None
availability_overridden: bool | None = None
availability_source: str | None = None
primary_image_url: str | None = None
primary_image_url_overridden: bool | None = None
primary_image_url_source: str | None = None
is_digital: bool | None = None
product_type: str | None = None

View File

@@ -107,7 +107,7 @@ def create_dummy_order(
customer_email = f"{customer_first.lower()}.{customer_last.lower()}@example.lu"
# Calculate totals in cents
subtotal_cents = sum((p.effective_price_cents or 0) * random.randint(1, 3) for p in products[:items_count])
subtotal_cents = sum((p.price_cents or 0) * random.randint(1, 3) for p in products[:items_count])
shipping_cents = 595 # €5.95
total_cents = subtotal_cents + shipping_cents
@@ -180,8 +180,8 @@ def create_dummy_order(
# Create order items with prices in cents
for i, product in enumerate(products[:items_count]):
quantity = random.randint(1, 3)
unit_price_cents = product.effective_price_cents or 0
product_name = product.get_effective_title("en") or f"Product {product.id}"
unit_price_cents = product.price_cents or 0
product_name = product.get_title("en") or f"Product {product.id}"
item = OrderItem(
order_id=order.id,
product_id=product.id,

View File

@@ -130,103 +130,76 @@ class TestProductModel:
assert product.marketplace_product is not None
assert product.inventory_entries == [] # No inventory yet
def test_product_effective_properties(
def test_product_get_source_comparison_info(
self, db, test_vendor, test_marketplace_product
):
"""Test Product effective properties with override pattern."""
# First, set some values on the marketplace product
test_marketplace_product.price_numeric = 100.00
test_marketplace_product.brand = "SourceBrand"
db.commit()
db.refresh(test_marketplace_product)
"""Test get_source_comparison_info method for 'view original source' feature.
# Create product without overrides
product = Product(
vendor_id=test_vendor.id,
marketplace_product_id=test_marketplace_product.id,
)
db.add(product)
db.commit()
db.refresh(product)
# Should inherit from marketplace product
assert product.effective_price == 100.00
assert product.effective_brand == "SourceBrand"
# Now override the price
product.price = 89.99
db.commit()
db.refresh(product)
# Should use the override
assert product.effective_price == 89.99
# Brand still inherited
assert product.effective_brand == "SourceBrand"
def test_product_reset_to_source(self, db, test_vendor, test_marketplace_product):
"""Test reset_to_source methods."""
# Set up marketplace product values (use cents internally)
Products are independent entities with all fields populated at creation.
Source values are kept for comparison only, not inheritance.
"""
# Set up marketplace product values
test_marketplace_product.price_cents = 10000 # €100.00
test_marketplace_product.brand = "SourceBrand"
db.commit()
# Create product with overrides (price property converts euros to cents)
# Create product with its own values (independent copy pattern)
product = Product(
vendor_id=test_vendor.id,
marketplace_product_id=test_marketplace_product.id,
price_cents=8999, # €89.99
brand="OverrideBrand",
price_cents=8999, # €89.99 - vendor's price
brand="VendorBrand", # Vendor's brand
)
db.add(product)
db.commit()
db.refresh(product)
assert product.effective_price == 89.99
assert product.effective_brand == "OverrideBrand"
info = product.get_source_comparison_info()
# Reset price_cents to source (OVERRIDABLE_FIELDS now uses _cents names)
product.reset_field_to_source("price_cents")
db.commit()
db.refresh(product)
# Product has its own price
assert info["price"] == 89.99
assert info["price_cents"] == 8999
assert info["price_source"] == 100.00 # Original marketplace price
assert product.price_cents is None
assert product.price is None # Property returns None when cents is None
assert product.effective_price == 100.00 # Now inherits from marketplace
# Product has its own brand
assert info["brand"] == "VendorBrand"
assert info["brand_source"] == "SourceBrand" # Original marketplace brand
# Reset all fields
product.reset_all_to_source()
db.commit()
db.refresh(product)
# No more *_overridden keys in the pattern
assert "price_overridden" not in info
assert "brand_overridden" not in info
assert product.brand is None
assert product.effective_brand == "SourceBrand" # Now inherits
def test_product_fields_are_independent(
self, db, test_vendor, test_marketplace_product
):
"""Test that product fields don't inherit from marketplace product.
def test_product_get_override_info(self, db, test_vendor, test_marketplace_product):
"""Test get_override_info method."""
test_marketplace_product.price_numeric = 100.00
Products are independent entities - NULL fields stay NULL,
no inheritance/fallback logic.
"""
# Set up marketplace product values
test_marketplace_product.price_cents = 10000
test_marketplace_product.brand = "SourceBrand"
db.commit()
# Create product without copying values
product = Product(
vendor_id=test_vendor.id,
marketplace_product_id=test_marketplace_product.id,
price=89.99, # Override
# brand not set - will inherit
# Not copying price_cents or brand
)
db.add(product)
db.commit()
db.refresh(product)
info = product.get_override_info()
# Fields should be NULL (not inherited)
assert product.price_cents is None
assert product.price is None
assert product.brand is None
# Price is overridden
assert info["price"] == 89.99
assert info["price_overridden"] is True
# But we can still see the source values for comparison
info = product.get_source_comparison_info()
assert info["price_source"] == 100.00
# Brand is inherited
assert info["brand"] == "SourceBrand"
assert info["brand_overridden"] is False
assert info["brand_source"] == "SourceBrand"