feat: make Product fully independent from MarketplaceProduct
- Add is_digital and product_type columns to Product model - Remove is_digital/product_type properties that derived from MarketplaceProduct - Update Create form with translation tabs, GTIN type, sale price, VAT rate, image - Update Edit form to allow editing is_digital (remove disabled state) - Add Availability field to Edit form - Fix Detail page for directly created products (no marketplace source) - Update vendor_product_service to handle new fields in create/update - Add VendorProductCreate/Update schema fields for translations and is_digital - Add unit tests for is_digital column and direct product creation - Add integration tests for create/update API with new fields - Create product-architecture.md documenting the independent copy pattern - Add migration y3d4e5f6g7h8 for is_digital and product_type columns 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
"""Vendor Product model - independent copy pattern.
|
||||
|
||||
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 model represents a vendor's product. Products can be:
|
||||
1. Created from a marketplace import (has marketplace_product_id)
|
||||
2. Created directly by the vendor (no marketplace_product_id)
|
||||
|
||||
The marketplace_product_id FK is kept for "view original source" feature,
|
||||
allowing comparison with the original marketplace data.
|
||||
When created from marketplace, the marketplace_product_id FK provides
|
||||
"view original source" comparison feature.
|
||||
|
||||
Money values are stored as integer cents (e.g., €105.91 = 10591).
|
||||
See docs/architecture/money-handling.md for details.
|
||||
@@ -29,11 +29,10 @@ from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class Product(Base, TimestampMixin):
|
||||
"""Vendor-specific product - independent copy.
|
||||
"""Vendor-specific 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.
|
||||
Products can be created from marketplace imports or directly by vendors.
|
||||
When from marketplace, marketplace_product_id provides source comparison.
|
||||
|
||||
Price fields use integer cents for precision (€19.99 = 1999 cents).
|
||||
"""
|
||||
@@ -43,7 +42,7 @@ class Product(Base, TimestampMixin):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
||||
marketplace_product_id = Column(
|
||||
Integer, ForeignKey("marketplace_products.id"), nullable=False
|
||||
Integer, ForeignKey("marketplace_products.id"), nullable=True
|
||||
)
|
||||
|
||||
# === VENDOR REFERENCE ===
|
||||
@@ -85,7 +84,11 @@ class Product(Base, TimestampMixin):
|
||||
cost_cents = Column(Integer) # What vendor pays to acquire (in cents) - for profit calculation
|
||||
margin_percent_x100 = Column(Integer) # Markup percentage * 100 (e.g., 25.5% = 2550)
|
||||
|
||||
# === VENDOR-SPECIFIC (No inheritance) ===
|
||||
# === PRODUCT TYPE ===
|
||||
is_digital = Column(Boolean, default=False, index=True)
|
||||
product_type = Column(String(20), default="physical") # physical, digital, service, subscription
|
||||
|
||||
# === VENDOR-SPECIFIC ===
|
||||
is_featured = Column(Boolean, default=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
display_order = Column(Integer, default=0)
|
||||
@@ -249,20 +252,6 @@ class Product(Base, TimestampMixin):
|
||||
return None
|
||||
return round((profit / net) * 100, 2)
|
||||
|
||||
# === MARKETPLACE PRODUCT PROPERTIES ===
|
||||
|
||||
@property
|
||||
def is_digital(self) -> bool:
|
||||
"""Check if this is a digital product."""
|
||||
mp = self.marketplace_product
|
||||
return mp.is_digital if mp else False
|
||||
|
||||
@property
|
||||
def product_type(self) -> str:
|
||||
"""Get product type from marketplace product."""
|
||||
mp = self.marketplace_product
|
||||
return mp.product_type_enum if mp else "physical"
|
||||
|
||||
# === INVENTORY PROPERTIES ===
|
||||
|
||||
# Constant for unlimited inventory (digital products)
|
||||
@@ -305,6 +294,7 @@ class Product(Base, TimestampMixin):
|
||||
|
||||
Returns a dict with current field values and original source values
|
||||
from the marketplace product. Used for "view original source" feature.
|
||||
Only populated when product was created from a marketplace source.
|
||||
"""
|
||||
mp = self.marketplace_product
|
||||
return {
|
||||
@@ -331,7 +321,7 @@ class Product(Base, TimestampMixin):
|
||||
# Images
|
||||
"primary_image_url": self.primary_image_url,
|
||||
"primary_image_url_source": mp.image_link if mp else None,
|
||||
# Product type info
|
||||
# Product type (independent fields, no source comparison)
|
||||
"is_digital": self.is_digital,
|
||||
"product_type": self.product_type,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user