refactor: convert legacy models/schemas to re-exports
Legacy model and schema files now re-export from module locations for backwards compatibility: models/database/: - letzshop.py -> app.modules.marketplace.models - marketplace_import_job.py -> app.modules.marketplace.models - marketplace_product.py -> app.modules.marketplace.models - marketplace_product_translation.py -> app.modules.marketplace.models - subscription.py -> app.modules.billing.models - architecture_scan.py -> app.modules.dev_tools.models - test_run.py -> app.modules.dev_tools.models models/schema/: - marketplace_import_job.py -> app.modules.marketplace.schemas - marketplace_product.py -> app.modules.marketplace.schemas - subscription.py -> app.modules.billing.schemas - stats.py -> app.modules.analytics.schemas This maintains import compatibility while moving actual code to self-contained modules. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,300 +1,28 @@
|
||||
"""Marketplace Product model for multi-marketplace product integration.
|
||||
# models/database/marketplace_product.py
|
||||
"""
|
||||
Legacy location for marketplace product model.
|
||||
|
||||
This model stores canonical product data from various marketplaces (Letzshop,
|
||||
Amazon, eBay, CodesWholesale, etc.) in a universal format. It supports:
|
||||
- Physical and digital products
|
||||
- Multi-language translations (via MarketplaceProductTranslation)
|
||||
- Flexible attributes for marketplace-specific data
|
||||
- Google Shopping fields for Letzshop compatibility
|
||||
MIGRATED: All models have been moved to app.modules.marketplace.models.marketplace_product.
|
||||
|
||||
Money values are stored as integer cents (e.g., €105.91 = 10591).
|
||||
Weight is stored as integer grams (e.g., 1.5kg = 1500g).
|
||||
See docs/architecture/money-handling.md for details.
|
||||
New location:
|
||||
from app.modules.marketplace.models import (
|
||||
MarketplaceProduct,
|
||||
ProductType,
|
||||
DigitalDeliveryMethod,
|
||||
)
|
||||
|
||||
This file re-exports from the new location for backward compatibility.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
# Re-export everything from the new canonical location
|
||||
from app.modules.marketplace.models.marketplace_product import (
|
||||
MarketplaceProduct,
|
||||
ProductType,
|
||||
DigitalDeliveryMethod,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.utils.money import cents_to_euros, euros_to_cents
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class ProductType(str, Enum):
|
||||
"""Product type classification."""
|
||||
|
||||
PHYSICAL = "physical"
|
||||
DIGITAL = "digital"
|
||||
SERVICE = "service"
|
||||
SUBSCRIPTION = "subscription"
|
||||
|
||||
|
||||
class DigitalDeliveryMethod(str, Enum):
|
||||
"""Digital product delivery methods."""
|
||||
|
||||
DOWNLOAD = "download"
|
||||
EMAIL = "email"
|
||||
IN_APP = "in_app"
|
||||
STREAMING = "streaming"
|
||||
LICENSE_KEY = "license_key"
|
||||
|
||||
|
||||
class MarketplaceProduct(Base, TimestampMixin):
|
||||
"""Canonical product data from marketplace sources.
|
||||
|
||||
This table stores normalized product information from all marketplace sources.
|
||||
Localized content (title, description) is stored in MarketplaceProductTranslation.
|
||||
|
||||
Price fields use integer cents for precision (€19.99 = 1999 cents).
|
||||
Weight uses integer grams (1.5kg = 1500 grams).
|
||||
"""
|
||||
|
||||
__tablename__ = "marketplace_products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# === UNIVERSAL IDENTIFIERS ===
|
||||
marketplace_product_id = Column(String, unique=True, index=True, nullable=False)
|
||||
gtin = Column(String, index=True) # EAN/UPC - primary cross-marketplace matching
|
||||
mpn = Column(String, index=True) # Manufacturer Part Number
|
||||
sku = Column(String, index=True) # Internal SKU if assigned
|
||||
|
||||
# === SOURCE TRACKING ===
|
||||
marketplace = Column(
|
||||
String, index=True, nullable=True, default="letzshop"
|
||||
) # 'letzshop', 'amazon', 'ebay', 'codeswholesale'
|
||||
source_url = Column(String) # Original product URL
|
||||
vendor_name = Column(String, index=True) # Seller/vendor in marketplace
|
||||
|
||||
# === PRODUCT TYPE ===
|
||||
product_type_enum = Column(
|
||||
String(20), nullable=False, default=ProductType.PHYSICAL.value
|
||||
)
|
||||
is_digital = Column(Boolean, default=False, index=True)
|
||||
|
||||
# === DIGITAL PRODUCT FIELDS ===
|
||||
digital_delivery_method = Column(String(20)) # DigitalDeliveryMethod values
|
||||
platform = Column(String(50), index=True) # 'steam', 'playstation', 'xbox', etc.
|
||||
region_restrictions = Column(JSON) # ["EU", "US"] or null for global
|
||||
license_type = Column(String(50)) # 'single_use', 'subscription', 'lifetime'
|
||||
|
||||
# === NON-LOCALIZED FIELDS ===
|
||||
brand = Column(String, index=True)
|
||||
google_product_category = Column(String, index=True)
|
||||
category_path = Column(String) # Normalized category hierarchy
|
||||
condition = Column(String)
|
||||
|
||||
# === PRICING (stored as integer cents) ===
|
||||
price = Column(String) # Raw price string "19.99 EUR" (kept for reference)
|
||||
price_cents = Column(Integer) # Parsed numeric price in cents
|
||||
sale_price = Column(String) # Raw sale price string
|
||||
sale_price_cents = Column(Integer) # Parsed numeric sale price in cents
|
||||
currency = Column(String(3), default="EUR")
|
||||
|
||||
# === TAX / VAT ===
|
||||
# Luxembourg VAT rates: 0 (exempt), 3 (super-reduced), 8 (reduced), 14 (intermediate), 17 (standard)
|
||||
# Prices are stored as gross (VAT-inclusive). Default to standard rate.
|
||||
tax_rate_percent = Column(Integer, default=17, nullable=False)
|
||||
|
||||
# === MEDIA ===
|
||||
image_link = Column(String)
|
||||
additional_image_link = Column(String) # Legacy single string
|
||||
additional_images = Column(JSON) # Array of image URLs
|
||||
|
||||
# === PRODUCT ATTRIBUTES (Flexible) ===
|
||||
attributes = Column(JSON) # {color, size, material, etc.}
|
||||
|
||||
# === PHYSICAL PRODUCT FIELDS ===
|
||||
weight_grams = Column(Integer) # Weight in grams (1.5kg = 1500)
|
||||
weight_unit = Column(String(10), default="kg") # Display unit
|
||||
dimensions = Column(JSON) # {length, width, height, unit}
|
||||
|
||||
# === GOOGLE SHOPPING FIELDS (Preserved for Letzshop) ===
|
||||
link = Column(String)
|
||||
availability = Column(String, index=True)
|
||||
adult = Column(String)
|
||||
multipack = Column(Integer)
|
||||
is_bundle = Column(String)
|
||||
age_group = Column(String)
|
||||
color = Column(String)
|
||||
gender = Column(String)
|
||||
material = Column(String)
|
||||
pattern = Column(String)
|
||||
size = Column(String)
|
||||
size_type = Column(String)
|
||||
size_system = Column(String)
|
||||
item_group_id = Column(String)
|
||||
product_type_raw = Column(String) # Original feed value (renamed from product_type)
|
||||
custom_label_0 = Column(String)
|
||||
custom_label_1 = Column(String)
|
||||
custom_label_2 = Column(String)
|
||||
custom_label_3 = Column(String)
|
||||
custom_label_4 = Column(String)
|
||||
unit_pricing_measure = Column(String)
|
||||
unit_pricing_base_measure = Column(String)
|
||||
identifier_exists = Column(String)
|
||||
shipping = Column(String)
|
||||
|
||||
# === STATUS ===
|
||||
is_active = Column(Boolean, default=True, index=True)
|
||||
|
||||
# === RELATIONSHIPS ===
|
||||
translations = relationship(
|
||||
"MarketplaceProductTranslation",
|
||||
back_populates="marketplace_product",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
vendor_products = relationship("Product", back_populates="marketplace_product")
|
||||
|
||||
# === INDEXES ===
|
||||
__table_args__ = (
|
||||
Index("idx_marketplace_vendor", "marketplace", "vendor_name"),
|
||||
Index("idx_marketplace_brand", "marketplace", "brand"),
|
||||
Index("idx_mp_gtin_marketplace", "gtin", "marketplace"),
|
||||
Index("idx_mp_product_type", "product_type_enum", "is_digital"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<MarketplaceProduct(id={self.id}, "
|
||||
f"marketplace_product_id='{self.marketplace_product_id}', "
|
||||
f"marketplace='{self.marketplace}', "
|
||||
f"vendor='{self.vendor_name}')>"
|
||||
)
|
||||
|
||||
# === PRICE PROPERTIES (Euro convenience accessors) ===
|
||||
|
||||
@property
|
||||
def price_numeric(self) -> float | None:
|
||||
"""Get price in euros (for API/display). Legacy name for compatibility."""
|
||||
if self.price_cents is not None:
|
||||
return cents_to_euros(self.price_cents)
|
||||
return None
|
||||
|
||||
@price_numeric.setter
|
||||
def price_numeric(self, value: float | None):
|
||||
"""Set price from euros. Legacy name for compatibility."""
|
||||
self.price_cents = euros_to_cents(value) if value is not None else None
|
||||
|
||||
@property
|
||||
def sale_price_numeric(self) -> float | None:
|
||||
"""Get sale price in euros (for API/display). Legacy name for compatibility."""
|
||||
if self.sale_price_cents is not None:
|
||||
return cents_to_euros(self.sale_price_cents)
|
||||
return None
|
||||
|
||||
@sale_price_numeric.setter
|
||||
def sale_price_numeric(self, value: float | None):
|
||||
"""Set sale price from euros. Legacy name for compatibility."""
|
||||
self.sale_price_cents = euros_to_cents(value) if value is not None else None
|
||||
|
||||
@property
|
||||
def weight(self) -> float | None:
|
||||
"""Get weight in kg (for API/display)."""
|
||||
if self.weight_grams is not None:
|
||||
return self.weight_grams / 1000.0
|
||||
return None
|
||||
|
||||
@weight.setter
|
||||
def weight(self, value: float | None):
|
||||
"""Set weight from kg."""
|
||||
self.weight_grams = int(value * 1000) if value is not None else None
|
||||
|
||||
# === HELPER PROPERTIES ===
|
||||
|
||||
@property
|
||||
def product_type(self) -> ProductType:
|
||||
"""Get product type as enum."""
|
||||
return ProductType(self.product_type_enum)
|
||||
|
||||
@product_type.setter
|
||||
def product_type(self, value: ProductType | str):
|
||||
"""Set product type from enum or string."""
|
||||
if isinstance(value, ProductType):
|
||||
self.product_type_enum = value.value
|
||||
else:
|
||||
self.product_type_enum = value
|
||||
|
||||
@property
|
||||
def delivery_method(self) -> DigitalDeliveryMethod | None:
|
||||
"""Get digital delivery method as enum."""
|
||||
if self.digital_delivery_method:
|
||||
return DigitalDeliveryMethod(self.digital_delivery_method)
|
||||
return None
|
||||
|
||||
@delivery_method.setter
|
||||
def delivery_method(self, value: DigitalDeliveryMethod | str | None):
|
||||
"""Set delivery method from enum or string."""
|
||||
if value is None:
|
||||
self.digital_delivery_method = None
|
||||
elif isinstance(value, DigitalDeliveryMethod):
|
||||
self.digital_delivery_method = value.value
|
||||
else:
|
||||
self.digital_delivery_method = value
|
||||
|
||||
def get_translation(self, language: str) -> "MarketplaceProductTranslation | None":
|
||||
"""Get translation for a specific language."""
|
||||
for t in self.translations:
|
||||
if t.language == language:
|
||||
return t
|
||||
return None
|
||||
|
||||
def get_title(self, language: str = "en") -> str | None:
|
||||
"""Get title for a specific language with fallback to 'en'."""
|
||||
translation = self.get_translation(language)
|
||||
if translation:
|
||||
return translation.title
|
||||
|
||||
# Fallback to English
|
||||
if language != "en":
|
||||
en_translation = self.get_translation("en")
|
||||
if en_translation:
|
||||
return en_translation.title
|
||||
|
||||
return None
|
||||
|
||||
def get_description(self, language: str = "en") -> str | None:
|
||||
"""Get description for a specific language with fallback to 'en'."""
|
||||
translation = self.get_translation(language)
|
||||
if translation:
|
||||
return translation.description
|
||||
|
||||
# Fallback to English
|
||||
if language != "en":
|
||||
en_translation = self.get_translation("en")
|
||||
if en_translation:
|
||||
return en_translation.description
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def effective_price(self) -> float | None:
|
||||
"""Get the effective numeric price in euros."""
|
||||
return self.price_numeric
|
||||
|
||||
@property
|
||||
def effective_sale_price(self) -> float | None:
|
||||
"""Get the effective numeric sale price in euros."""
|
||||
return self.sale_price_numeric
|
||||
|
||||
@property
|
||||
def all_images(self) -> list[str]:
|
||||
"""Get all product images as a list."""
|
||||
images = []
|
||||
if self.image_link:
|
||||
images.append(self.image_link)
|
||||
if self.additional_images:
|
||||
images.extend(self.additional_images)
|
||||
elif self.additional_image_link:
|
||||
# Legacy single string format
|
||||
images.append(self.additional_image_link)
|
||||
return images
|
||||
__all__ = [
|
||||
"MarketplaceProduct",
|
||||
"ProductType",
|
||||
"DigitalDeliveryMethod",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user