From 508e121a0e83a86afc7be431345cb6e03a06636b Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Wed, 24 Dec 2025 23:41:20 +0100 Subject: [PATCH] refactor: product independence - remove inheritance pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...c3_product_independence_populate_fields.py | 262 ++++++++++++++++++ app/services/cart_service.py | 4 +- app/services/marketplace_product_service.py | 52 +++- app/services/order_service.py | 4 +- app/services/vendor_product_service.py | 18 +- models/database/product.py | 238 +++------------- models/database/product_translation.py | 154 ++-------- models/schema/vendor_product.py | 23 +- scripts/create_dummy_letzshop_order.py | 6 +- tests/unit/models/database/test_product.py | 101 +++---- 10 files changed, 444 insertions(+), 418 deletions(-) create mode 100644 alembic/versions/j8e9f0a1b2c3_product_independence_populate_fields.py diff --git a/alembic/versions/j8e9f0a1b2c3_product_independence_populate_fields.py b/alembic/versions/j8e9f0a1b2c3_product_independence_populate_fields.py new file mode 100644 index 00000000..e03e6244 --- /dev/null +++ b/alembic/versions/j8e9f0a1b2c3_product_independence_populate_fields.py @@ -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 diff --git a/app/services/cart_service.py b/app/services/cart_service.py index 3209d34f..04876909 100644 --- a/app/services/cart_service.py +++ b/app/services/cart_service.py @@ -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 ) diff --git a/app/services/marketplace_product_service.py b/app/services/marketplace_product_service.py index 7cc85fad..9a3f3dbd 100644 --- a/app/services/marketplace_product_service.py +++ b/app/services/marketplace_product_service.py @@ -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)}") diff --git a/app/services/order_service.py b/app/services/order_service.py index be75570b..2536cd4b 100644 --- a/app/services/order_service.py +++ b/app/services/order_service.py @@ -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") diff --git a/app/services/vendor_product_service.py b/app/services/vendor_product_service.py index cfd0be06..e7d10370 100644 --- a/app/services/vendor_product_service.py +++ b/app/services/vendor_product_service.py @@ -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() diff --git a/models/database/product.py b/models/database/product.py index 499d05c7..35c69b0a 100644 --- a/models/database/product.py +++ b/models/database/product.py @@ -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" 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 diff --git a/models/database/product_translation.py b/models/database/product_translation.py index 3897b8eb..227bdf50 100644 --- a/models/database/product_translation.py +++ b/models/database/product_translation.py @@ -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"" ) - # === 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 diff --git a/models/schema/vendor_product.py b/models/schema/vendor_product.py index 4d120a6e..5d21533f 100644 --- a/models/schema/vendor_product.py +++ b/models/schema/vendor_product.py @@ -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 diff --git a/scripts/create_dummy_letzshop_order.py b/scripts/create_dummy_letzshop_order.py index 8a584e34..8b620445 100755 --- a/scripts/create_dummy_letzshop_order.py +++ b/scripts/create_dummy_letzshop_order.py @@ -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, diff --git a/tests/unit/models/database/test_product.py b/tests/unit/models/database/test_product.py index 213daa87..01faf420 100644 --- a/tests/unit/models/database/test_product.py +++ b/tests/unit/models/database/test_product.py @@ -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"