feat: complete marketplace module self-containment

Migrate marketplace module to self-contained structure:

- routes/api/admin.py - Admin API endpoints
- routes/api/vendor.py - Vendor API endpoints
- routes/pages/ - Page routes (placeholder)
- models/letzshop.py - Letzshop model
- models/marketplace_import_job.py - Import job model
- models/marketplace_product.py - Product model
- models/marketplace_product_translation.py - Translation model
- schemas/marketplace_import_job.py - Import job schemas
- schemas/marketplace_product.py - Product schemas
- locales/ - Translations (en, de, fr, lu)

Removed legacy route files replaced by api/ structure.
Updated __init__.py files to use new structure.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 22:21:40 +01:00
parent f79e67d199
commit b74d1346aa
19 changed files with 1894 additions and 42 deletions

View File

@@ -1,9 +1,9 @@
# app/modules/marketplace/models/__init__.py
"""
Marketplace module models.
Marketplace module database models.
Re-exports marketplace and Letzshop models from the central models location.
Models remain in models/database/ for now to avoid breaking existing imports.
This is the canonical location for marketplace models. Module models are automatically
discovered and registered with SQLAlchemy's Base.metadata at startup.
Usage:
from app.modules.marketplace.models import (
@@ -14,10 +14,19 @@ Usage:
)
"""
from models.database.marketplace_product import MarketplaceProduct
from models.database.marketplace_product_translation import MarketplaceProductTranslation
from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.letzshop import (
from app.modules.marketplace.models.marketplace_product import (
MarketplaceProduct,
ProductType,
DigitalDeliveryMethod,
)
from app.modules.marketplace.models.marketplace_product_translation import (
MarketplaceProductTranslation,
)
from app.modules.marketplace.models.marketplace_import_job import (
MarketplaceImportJob,
MarketplaceImportError,
)
from app.modules.marketplace.models.letzshop import (
# Letzshop credentials and sync
VendorLetzshopCredentials,
LetzshopFulfillmentQueue,
@@ -31,8 +40,11 @@ __all__ = [
# Marketplace products
"MarketplaceProduct",
"MarketplaceProductTranslation",
"ProductType",
"DigitalDeliveryMethod",
# Import jobs
"MarketplaceImportJob",
"MarketplaceImportError",
"LetzshopHistoricalImportJob",
# Letzshop models
"VendorLetzshopCredentials",

View File

@@ -0,0 +1,379 @@
# app/modules/marketplace/models/letzshop.py
"""
Database models for Letzshop marketplace integration.
Provides models for:
- VendorLetzshopCredentials: Per-vendor API key storage (encrypted)
- LetzshopFulfillmentQueue: Outbound operation queue with retry
- LetzshopSyncLog: Audit trail for sync operations
- LetzshopHistoricalImportJob: Progress tracking for historical imports
Note: Orders are now stored in the unified `orders` table with channel='letzshop'.
The LetzshopOrder model has been removed in favor of the unified Order model.
"""
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class VendorLetzshopCredentials(Base, TimestampMixin):
"""
Per-vendor Letzshop API credentials.
Stores encrypted API keys and sync settings for each vendor's
Letzshop integration.
"""
__tablename__ = "vendor_letzshop_credentials"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(
Integer, ForeignKey("vendors.id"), unique=True, nullable=False, index=True
)
# Encrypted API credentials
api_key_encrypted = Column(Text, nullable=False)
api_endpoint = Column(String(255), default="https://letzshop.lu/graphql")
# Sync settings
auto_sync_enabled = Column(Boolean, default=False)
sync_interval_minutes = Column(Integer, default=15)
# Test mode (disables API mutations when enabled)
test_mode_enabled = Column(Boolean, default=False)
# Default carrier settings
default_carrier = Column(String(50), nullable=True) # greco, colissimo, xpresslogistics
# Carrier label URL prefixes
carrier_greco_label_url = Column(
String(500), default="https://dispatchweb.fr/Tracky/Home/"
)
carrier_colissimo_label_url = Column(String(500), nullable=True)
carrier_xpresslogistics_label_url = Column(String(500), nullable=True)
# Last sync status
last_sync_at = Column(DateTime(timezone=True), nullable=True)
last_sync_status = Column(String(50), nullable=True) # success, failed, partial
last_sync_error = Column(Text, nullable=True)
# Relationships
vendor = relationship("Vendor", back_populates="letzshop_credentials")
def __repr__(self):
return f"<VendorLetzshopCredentials(vendor_id={self.vendor_id}, auto_sync={self.auto_sync_enabled})>"
class LetzshopFulfillmentQueue(Base, TimestampMixin):
"""
Queue for outbound fulfillment operations to Letzshop.
Supports retry logic for failed operations.
References the unified orders table.
"""
__tablename__ = "letzshop_fulfillment_queue"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False, index=True)
# Operation type
operation = Column(
String(50), nullable=False
) # confirm_item, decline_item, set_tracking
# Operation payload
payload = Column(JSON, nullable=False)
# Status and retry
status = Column(
String(50), default="pending"
) # pending, processing, completed, failed
attempts = Column(Integer, default=0)
max_attempts = Column(Integer, default=3)
last_attempt_at = Column(DateTime(timezone=True), nullable=True)
next_retry_at = Column(DateTime(timezone=True), nullable=True)
error_message = Column(Text, nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
# Response from Letzshop
response_data = Column(JSON, nullable=True)
# Relationships
vendor = relationship("Vendor")
order = relationship("Order")
__table_args__ = (
Index("idx_fulfillment_queue_status", "status", "vendor_id"),
Index("idx_fulfillment_queue_retry", "status", "next_retry_at"),
Index("idx_fulfillment_queue_order", "order_id"),
)
def __repr__(self):
return f"<LetzshopFulfillmentQueue(id={self.id}, order_id={self.order_id}, operation='{self.operation}', status='{self.status}')>"
class LetzshopSyncLog(Base, TimestampMixin):
"""
Audit log for all Letzshop sync operations.
"""
__tablename__ = "letzshop_sync_logs"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
# Operation details
operation_type = Column(
String(50), nullable=False
) # order_import, confirm_inventory, set_tracking, etc.
direction = Column(String(10), nullable=False) # inbound, outbound
# Status
status = Column(String(50), nullable=False) # success, failed, partial
# Details
records_processed = Column(Integer, default=0)
records_succeeded = Column(Integer, default=0)
records_failed = Column(Integer, default=0)
error_details = Column(JSON, nullable=True)
# Timestamps
started_at = Column(DateTime(timezone=True), nullable=False)
completed_at = Column(DateTime(timezone=True), nullable=True)
duration_seconds = Column(Integer, nullable=True)
# Triggered by
triggered_by = Column(String(100), nullable=True) # user_id, scheduler, webhook
# Relationships
vendor = relationship("Vendor")
__table_args__ = (
Index("idx_sync_log_vendor_type", "vendor_id", "operation_type"),
Index("idx_sync_log_vendor_date", "vendor_id", "started_at"),
)
def __repr__(self):
return f"<LetzshopSyncLog(id={self.id}, type='{self.operation_type}', status='{self.status}')>"
class LetzshopVendorCache(Base, TimestampMixin):
"""
Cache of Letzshop marketplace vendor directory.
This table stores vendor data fetched from Letzshop's public GraphQL API,
allowing users to browse and claim existing Letzshop shops during signup.
Data is periodically synced from Letzshop (e.g., daily via Celery task).
"""
__tablename__ = "letzshop_vendor_cache"
id = Column(Integer, primary_key=True, index=True)
# Letzshop identifiers
letzshop_id = Column(String(50), unique=True, nullable=False, index=True)
"""Unique ID from Letzshop (e.g., 'lpkedYMRup')."""
slug = Column(String(200), unique=True, nullable=False, index=True)
"""URL slug (e.g., 'nicks-diecast-corner')."""
# Basic info
name = Column(String(255), nullable=False)
"""Vendor display name."""
company_name = Column(String(255), nullable=True)
"""Legal company name."""
is_active = Column(Boolean, default=True)
"""Whether vendor is active on Letzshop."""
# Descriptions (multilingual)
description_en = Column(Text, nullable=True)
description_fr = Column(Text, nullable=True)
description_de = Column(Text, nullable=True)
# Contact information
email = Column(String(255), nullable=True)
phone = Column(String(50), nullable=True)
fax = Column(String(50), nullable=True)
website = Column(String(500), nullable=True)
# Location
street = Column(String(255), nullable=True)
street_number = Column(String(50), nullable=True)
city = Column(String(100), nullable=True)
zipcode = Column(String(20), nullable=True)
country_iso = Column(String(5), default="LU")
latitude = Column(String(20), nullable=True)
longitude = Column(String(20), nullable=True)
# Categories (stored as JSON array of names)
categories = Column(JSON, default=list)
"""List of category names, e.g., ['Fashion', 'Shoes']."""
# Images
background_image_url = Column(String(500), nullable=True)
# Social media (stored as JSON array of URLs)
social_media_links = Column(JSON, default=list)
"""List of social media URLs."""
# Opening hours (multilingual text)
opening_hours_en = Column(Text, nullable=True)
opening_hours_fr = Column(Text, nullable=True)
opening_hours_de = Column(Text, nullable=True)
# Representative
representative_name = Column(String(255), nullable=True)
representative_title = Column(String(100), nullable=True)
# Claiming status (linked to our platform)
claimed_by_vendor_id = Column(
Integer, ForeignKey("vendors.id"), nullable=True, index=True
)
"""If claimed, links to our Vendor record."""
claimed_at = Column(DateTime(timezone=True), nullable=True)
"""When the vendor was claimed on our platform."""
# Sync metadata
last_synced_at = Column(DateTime(timezone=True), nullable=False)
"""When this record was last updated from Letzshop."""
raw_data = Column(JSON, nullable=True)
"""Full raw response from Letzshop API for reference."""
# Relationship to claimed vendor
claimed_vendor = relationship("Vendor", foreign_keys=[claimed_by_vendor_id])
__table_args__ = (
Index("idx_vendor_cache_city", "city"),
Index("idx_vendor_cache_claimed", "claimed_by_vendor_id"),
Index("idx_vendor_cache_active", "is_active"),
)
def __repr__(self):
return f"<LetzshopVendorCache(id={self.id}, slug='{self.slug}', name='{self.name}')>"
@property
def is_claimed(self) -> bool:
"""Check if this vendor has been claimed on our platform."""
return self.claimed_by_vendor_id is not None
@property
def letzshop_url(self) -> str:
"""Get the Letzshop profile URL."""
return f"https://letzshop.lu/vendors/{self.slug}"
def get_description(self, lang: str = "en") -> str | None:
"""Get description in specified language with fallback."""
descriptions = {
"en": self.description_en,
"fr": self.description_fr,
"de": self.description_de,
}
# Try requested language, then fallback order
for try_lang in [lang, "en", "fr", "de"]:
if descriptions.get(try_lang):
return descriptions[try_lang]
return None
def get_opening_hours(self, lang: str = "en") -> str | None:
"""Get opening hours in specified language with fallback."""
hours = {
"en": self.opening_hours_en,
"fr": self.opening_hours_fr,
"de": self.opening_hours_de,
}
for try_lang in [lang, "en", "fr", "de"]:
if hours.get(try_lang):
return hours[try_lang]
return None
def get_full_address(self) -> str | None:
"""Get formatted full address."""
parts = []
if self.street:
addr = self.street
if self.street_number:
addr += f" {self.street_number}"
parts.append(addr)
if self.zipcode or self.city:
parts.append(f"{self.zipcode or ''} {self.city or ''}".strip())
return ", ".join(parts) if parts else None
class LetzshopHistoricalImportJob(Base, TimestampMixin):
"""
Track progress of historical order imports from Letzshop.
Enables real-time progress tracking via polling for long-running imports.
"""
__tablename__ = "letzshop_historical_import_jobs"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Status: pending | fetching | processing | completed | failed
status = Column(String(50), default="pending", nullable=False)
# Current phase: "confirmed" | "declined"
current_phase = Column(String(20), nullable=True)
# Fetch progress
current_page = Column(Integer, default=0)
total_pages = Column(Integer, nullable=True) # null = unknown yet
shipments_fetched = Column(Integer, default=0)
# Processing progress
orders_processed = Column(Integer, default=0)
orders_imported = Column(Integer, default=0)
orders_updated = Column(Integer, default=0)
orders_skipped = Column(Integer, default=0)
# EAN matching stats
products_matched = Column(Integer, default=0)
products_not_found = Column(Integer, default=0)
# Phase-specific stats (stored as JSON for combining confirmed + declined)
confirmed_stats = Column(JSON, nullable=True)
declined_stats = Column(JSON, nullable=True)
# Error handling
error_message = Column(Text, nullable=True)
# Celery task tracking (optional - for USE_CELERY=true)
celery_task_id = Column(String(255), nullable=True, index=True)
# Timing
started_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
vendor = relationship("Vendor")
user = relationship("User")
__table_args__ = (
Index("idx_historical_import_vendor", "vendor_id", "status"),
)
def __repr__(self):
return f"<LetzshopHistoricalImportJob(id={self.id}, status='{self.status}', phase='{self.current_phase}')>"

View File

@@ -0,0 +1,119 @@
# app/modules/marketplace/models/marketplace_import_job.py
"""Marketplace Import Job models for tracking CSV and historical imports."""
from sqlalchemy import JSON, Column, DateTime, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class MarketplaceImportError(Base, TimestampMixin):
"""
Stores detailed information about individual import errors.
Each row that fails during import creates an error record with:
- Row number from the source file
- Identifier (marketplace_product_id if available)
- Error type and message
- Raw row data for review
"""
__tablename__ = "marketplace_import_errors"
id = Column(Integer, primary_key=True, index=True)
import_job_id = Column(
Integer,
ForeignKey("marketplace_import_jobs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Error location
row_number = Column(Integer, nullable=False)
# Identifier from the row (if available)
identifier = Column(String) # marketplace_product_id, gtin, mpn, etc.
# Error details
error_type = Column(
String(50), nullable=False
) # missing_title, missing_id, parse_error, etc.
error_message = Column(Text, nullable=False)
# Raw row data for review (JSON)
row_data = Column(JSON)
# Relationship
import_job = relationship("MarketplaceImportJob", back_populates="errors")
__table_args__ = (
Index("idx_import_error_job_id", "import_job_id"),
Index("idx_import_error_type", "error_type"),
)
def __repr__(self):
return (
f"<MarketplaceImportError(id={self.id}, job_id={self.import_job_id}, "
f"row={self.row_number}, type='{self.error_type}')>"
)
class MarketplaceImportJob(Base, TimestampMixin):
__tablename__ = "marketplace_import_jobs"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Import configuration
marketplace = Column(String, nullable=False, index=True, default="Letzshop")
source_url = Column(String, nullable=False)
language = Column(
String(5), nullable=False, default="en"
) # Language for translations
# Status tracking
status = Column(
String, nullable=False, default="pending"
) # pending, processing, completed, failed, completed_with_errors
# Results
imported_count = Column(Integer, default=0)
updated_count = Column(Integer, default=0)
error_count = Column(Integer, default=0)
total_processed = Column(Integer, default=0)
# Error handling
error_message = Column(Text)
# Celery task tracking (optional - for USE_CELERY=true)
celery_task_id = Column(String(255), nullable=True, index=True)
# Timestamps
started_at = Column(DateTime(timezone=True))
completed_at = Column(DateTime(timezone=True))
# Relationships
vendor = relationship("Vendor", back_populates="marketplace_import_jobs")
user = relationship("User", foreign_keys=[user_id])
errors = relationship(
"MarketplaceImportError",
back_populates="import_job",
cascade="all, delete-orphan",
order_by="MarketplaceImportError.row_number",
)
# Indexes for performance
__table_args__ = (
Index("idx_import_vendor_status", "vendor_id", "status"),
Index("idx_import_vendor_created", "vendor_id", "created_at"),
Index("idx_import_user_marketplace", "user_id", "marketplace"),
)
def __repr__(self):
return (
f"<MarketplaceImportJob(id={self.id}, vendor_id={self.vendor_id}, "
f"marketplace='{self.marketplace}', status='{self.status}', "
f"imported={self.imported_count})>"
)

View File

@@ -0,0 +1,307 @@
# app/modules/marketplace/models/marketplace_product.py
"""Marketplace Product model for multi-marketplace product integration.
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
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.
"""
from enum import Enum
from sqlalchemy import (
Boolean,
Column,
Index,
Integer,
String,
)
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
# Import MarketplaceProductTranslation for type hints
from app.modules.marketplace.models.marketplace_product_translation import (
MarketplaceProductTranslation,
)

View File

@@ -0,0 +1,77 @@
# app/modules/marketplace/models/marketplace_product_translation.py
"""Marketplace Product Translation model for multi-language support.
This model stores localized content (title, description, SEO fields) for
marketplace products. Each marketplace product can have multiple translations
for different languages.
"""
from sqlalchemy import (
Column,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class MarketplaceProductTranslation(Base, TimestampMixin):
"""Localized content for marketplace products.
Stores translations for product titles, descriptions, and SEO fields.
Each marketplace_product can have one translation per language.
"""
__tablename__ = "marketplace_product_translations"
id = Column(Integer, primary_key=True, index=True)
marketplace_product_id = Column(
Integer,
ForeignKey("marketplace_products.id", ondelete="CASCADE"),
nullable=False,
)
language = Column(String(5), nullable=False) # 'en', 'fr', 'de', 'lb'
# === LOCALIZED CONTENT ===
title = Column(String, nullable=False)
description = Column(Text)
short_description = Column(String(500))
# === SEO FIELDS ===
meta_title = Column(String(70))
meta_description = Column(String(160))
url_slug = Column(String(255))
# === SOURCE TRACKING ===
source_import_id = Column(Integer) # Which import job provided this
source_file = Column(String) # e.g., "letzshop_fr.csv"
# === RELATIONSHIPS ===
marketplace_product = relationship(
"MarketplaceProduct",
back_populates="translations",
)
__table_args__ = (
UniqueConstraint(
"marketplace_product_id",
"language",
name="uq_marketplace_product_translation",
),
Index("idx_mpt_marketplace_product_id", "marketplace_product_id"),
Index("idx_mpt_language", "language"),
)
def __repr__(self):
return (
f"<MarketplaceProductTranslation(id={self.id}, "
f"marketplace_product_id={self.marketplace_product_id}, "
f"language='{self.language}', "
f"title='{self.title[:30] if self.title else None}...')>"
)