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

@@ -27,12 +27,21 @@ Usage:
from app.modules.marketplace.exceptions import LetzshopClientError
"""
from app.modules.marketplace.definition import (
marketplace_module,
get_marketplace_module_with_routers,
)
# Lazy imports to avoid circular dependencies
# Routers and module definition are imported on-demand
__all__ = [
"marketplace_module",
"get_marketplace_module_with_routers",
]
def __getattr__(name: str):
"""Lazy import to avoid circular dependencies."""
if name == "marketplace_module":
from app.modules.marketplace.definition import marketplace_module
return marketplace_module
elif name == "get_marketplace_module_with_routers":
from app.modules.marketplace.definition import get_marketplace_module_with_routers
return get_marketplace_module_with_routers
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -14,14 +14,14 @@ from models.database.admin_menu_config import FrontendType
def _get_admin_router():
"""Lazy import of admin router to avoid circular imports."""
from app.modules.marketplace.routes.admin import admin_router
from app.modules.marketplace.routes.api.admin import admin_router
return admin_router
def _get_vendor_router():
"""Lazy import of vendor router to avoid circular imports."""
from app.modules.marketplace.routes.vendor import vendor_router
from app.modules.marketplace.routes.api.vendor import vendor_router
return vendor_router

View File

@@ -0,0 +1,122 @@
{
"title": "Marktplatz-Integration",
"description": "Letzshop Produkt- und Bestellsynchronisation",
"products": {
"title": "Marktplatz-Produkte",
"subtitle": "Von Marktplätzen importierte Produkte",
"empty": "Keine Produkte gefunden",
"empty_search": "Keine Produkte entsprechen Ihrer Suche",
"import": "Produkte importieren"
},
"import": {
"title": "Produkte importieren",
"subtitle": "Produkte aus Marktplatz-Feeds importieren",
"source_url": "Feed-URL",
"source_url_help": "URL zum Marktplatz-CSV-Feed",
"marketplace": "Marktplatz",
"language": "Sprache",
"language_help": "Sprache für Produktübersetzungen",
"batch_size": "Batch-Größe",
"start_import": "Import starten",
"cancel": "Abbrechen"
},
"import_jobs": {
"title": "Import-Verlauf",
"subtitle": "Vergangene und aktuelle Import-Jobs",
"empty": "Keine Import-Jobs",
"job_id": "Job-ID",
"marketplace": "Marktplatz",
"vendor": "Verkäufer",
"status": "Status",
"imported": "Importiert",
"updated": "Aktualisiert",
"errors": "Fehler",
"created": "Erstellt",
"completed": "Abgeschlossen",
"statuses": {
"pending": "Ausstehend",
"processing": "In Bearbeitung",
"completed": "Abgeschlossen",
"completed_with_errors": "Mit Fehlern abgeschlossen",
"failed": "Fehlgeschlagen"
}
},
"letzshop": {
"title": "Letzshop-Integration",
"subtitle": "Letzshop-Verbindung und Synchronisation verwalten",
"credentials": {
"title": "API-Anmeldedaten",
"api_key": "API-Schlüssel",
"api_key_help": "Ihr Letzshop API-Schlüssel",
"endpoint": "API-Endpunkt",
"test_mode": "Testmodus",
"test_mode_help": "Wenn aktiviert, werden keine Änderungen bei Letzshop vorgenommen"
},
"sync": {
"title": "Synchronisation",
"auto_sync": "Auto-Sync",
"auto_sync_help": "Bestellungen automatisch von Letzshop synchronisieren",
"interval": "Sync-Intervall",
"interval_help": "Minuten zwischen Synchronisationen",
"last_sync": "Letzte Sync",
"last_status": "Letzter Status",
"sync_now": "Jetzt synchronisieren"
},
"carrier": {
"title": "Versanddiensteinstellungen",
"default_carrier": "Standard-Versanddienstleister",
"greco": "Greco",
"colissimo": "Colissimo",
"xpresslogistics": "XpressLogistics",
"label_url": "Label-URL-Präfix"
},
"historical": {
"title": "Historischer Import",
"subtitle": "Vergangene Bestellungen von Letzshop importieren",
"start_import": "Historischen Import starten",
"phase": "Phase",
"confirmed": "Bestätigte Bestellungen",
"unconfirmed": "Unbestätigte Bestellungen",
"fetching": "Abrufen...",
"processing": "Verarbeiten...",
"page": "Seite",
"fetched": "Abgerufen",
"processed": "Verarbeitet",
"imported": "Importiert",
"updated": "Aktualisiert",
"skipped": "Übersprungen"
},
"vendors": {
"title": "Verkäuferverzeichnis",
"subtitle": "Letzshop-Verkäufer durchsuchen",
"claim": "Beanspruchen",
"claimed": "Beansprucht",
"unclaimed": "Nicht beansprucht",
"last_synced": "Zuletzt synchronisiert"
}
},
"export": {
"title": "Produkte exportieren",
"subtitle": "Produkte im Marktplatz-Format exportieren",
"format": "Format",
"format_csv": "CSV",
"format_xml": "XML",
"download": "Export herunterladen"
},
"messages": {
"import_started": "Import erfolgreich gestartet",
"import_completed": "Import abgeschlossen",
"import_failed": "Import fehlgeschlagen",
"credentials_saved": "Anmeldedaten erfolgreich gespeichert",
"sync_started": "Synchronisation gestartet",
"sync_completed": "Synchronisation abgeschlossen",
"sync_failed": "Synchronisation fehlgeschlagen",
"export_ready": "Export zum Download bereit",
"error_loading": "Fehler beim Laden der Daten"
},
"filters": {
"all_marketplaces": "Alle Marktplätze",
"all_vendors": "Alle Verkäufer",
"search_placeholder": "Produkte suchen..."
}
}

View File

@@ -0,0 +1,122 @@
{
"title": "Marketplace Integration",
"description": "Letzshop product and order synchronization",
"products": {
"title": "Marketplace Products",
"subtitle": "Products imported from marketplaces",
"empty": "No products found",
"empty_search": "No products match your search",
"import": "Import Products"
},
"import": {
"title": "Import Products",
"subtitle": "Import products from marketplace feeds",
"source_url": "Feed URL",
"source_url_help": "URL to the marketplace CSV feed",
"marketplace": "Marketplace",
"language": "Language",
"language_help": "Language for product translations",
"batch_size": "Batch Size",
"start_import": "Start Import",
"cancel": "Cancel"
},
"import_jobs": {
"title": "Import History",
"subtitle": "Past and current import jobs",
"empty": "No import jobs",
"job_id": "Job ID",
"marketplace": "Marketplace",
"vendor": "Vendor",
"status": "Status",
"imported": "Imported",
"updated": "Updated",
"errors": "Errors",
"created": "Created",
"completed": "Completed",
"statuses": {
"pending": "Pending",
"processing": "Processing",
"completed": "Completed",
"completed_with_errors": "Completed with Errors",
"failed": "Failed"
}
},
"letzshop": {
"title": "Letzshop Integration",
"subtitle": "Manage Letzshop connection and sync",
"credentials": {
"title": "API Credentials",
"api_key": "API Key",
"api_key_help": "Your Letzshop API key",
"endpoint": "API Endpoint",
"test_mode": "Test Mode",
"test_mode_help": "When enabled, no changes are made to Letzshop"
},
"sync": {
"title": "Synchronization",
"auto_sync": "Auto Sync",
"auto_sync_help": "Automatically sync orders from Letzshop",
"interval": "Sync Interval",
"interval_help": "Minutes between syncs",
"last_sync": "Last Sync",
"last_status": "Last Status",
"sync_now": "Sync Now"
},
"carrier": {
"title": "Carrier Settings",
"default_carrier": "Default Carrier",
"greco": "Greco",
"colissimo": "Colissimo",
"xpresslogistics": "XpressLogistics",
"label_url": "Label URL Prefix"
},
"historical": {
"title": "Historical Import",
"subtitle": "Import past orders from Letzshop",
"start_import": "Start Historical Import",
"phase": "Phase",
"confirmed": "Confirmed Orders",
"unconfirmed": "Unconfirmed Orders",
"fetching": "Fetching...",
"processing": "Processing...",
"page": "Page",
"fetched": "Fetched",
"processed": "Processed",
"imported": "Imported",
"updated": "Updated",
"skipped": "Skipped"
},
"vendors": {
"title": "Vendor Directory",
"subtitle": "Browse Letzshop vendors",
"claim": "Claim",
"claimed": "Claimed",
"unclaimed": "Unclaimed",
"last_synced": "Last synced"
}
},
"export": {
"title": "Export Products",
"subtitle": "Export products to marketplace format",
"format": "Format",
"format_csv": "CSV",
"format_xml": "XML",
"download": "Download Export"
},
"messages": {
"import_started": "Import started successfully",
"import_completed": "Import completed",
"import_failed": "Import failed",
"credentials_saved": "Credentials saved successfully",
"sync_started": "Sync started",
"sync_completed": "Sync completed",
"sync_failed": "Sync failed",
"export_ready": "Export ready for download",
"error_loading": "Error loading data"
},
"filters": {
"all_marketplaces": "All Marketplaces",
"all_vendors": "All Vendors",
"search_placeholder": "Search products..."
}
}

View File

@@ -0,0 +1,122 @@
{
"title": "Intégration Marketplace",
"description": "Synchronisation des produits et commandes Letzshop",
"products": {
"title": "Produits Marketplace",
"subtitle": "Produits importés des marketplaces",
"empty": "Aucun produit trouvé",
"empty_search": "Aucun produit ne correspond à votre recherche",
"import": "Importer des produits"
},
"import": {
"title": "Importer des produits",
"subtitle": "Importer des produits depuis les flux marketplace",
"source_url": "URL du flux",
"source_url_help": "URL du flux CSV marketplace",
"marketplace": "Marketplace",
"language": "Langue",
"language_help": "Langue pour les traductions de produits",
"batch_size": "Taille du lot",
"start_import": "Démarrer l'import",
"cancel": "Annuler"
},
"import_jobs": {
"title": "Historique des imports",
"subtitle": "Imports passés et en cours",
"empty": "Aucun import",
"job_id": "ID du job",
"marketplace": "Marketplace",
"vendor": "Vendeur",
"status": "Statut",
"imported": "Importés",
"updated": "Mis à jour",
"errors": "Erreurs",
"created": "Créé",
"completed": "Terminé",
"statuses": {
"pending": "En attente",
"processing": "En cours",
"completed": "Terminé",
"completed_with_errors": "Terminé avec erreurs",
"failed": "Échoué"
}
},
"letzshop": {
"title": "Intégration Letzshop",
"subtitle": "Gérer la connexion et la synchronisation Letzshop",
"credentials": {
"title": "Identifiants API",
"api_key": "Clé API",
"api_key_help": "Votre clé API Letzshop",
"endpoint": "Point d'accès API",
"test_mode": "Mode test",
"test_mode_help": "Lorsqu'activé, aucune modification n'est effectuée sur Letzshop"
},
"sync": {
"title": "Synchronisation",
"auto_sync": "Sync automatique",
"auto_sync_help": "Synchroniser automatiquement les commandes depuis Letzshop",
"interval": "Intervalle de sync",
"interval_help": "Minutes entre les synchronisations",
"last_sync": "Dernière sync",
"last_status": "Dernier statut",
"sync_now": "Synchroniser maintenant"
},
"carrier": {
"title": "Paramètres transporteur",
"default_carrier": "Transporteur par défaut",
"greco": "Greco",
"colissimo": "Colissimo",
"xpresslogistics": "XpressLogistics",
"label_url": "Préfixe URL étiquette"
},
"historical": {
"title": "Import historique",
"subtitle": "Importer les commandes passées depuis Letzshop",
"start_import": "Démarrer l'import historique",
"phase": "Phase",
"confirmed": "Commandes confirmées",
"unconfirmed": "Commandes non confirmées",
"fetching": "Récupération...",
"processing": "Traitement...",
"page": "Page",
"fetched": "Récupérées",
"processed": "Traitées",
"imported": "Importées",
"updated": "Mises à jour",
"skipped": "Ignorées"
},
"vendors": {
"title": "Annuaire des vendeurs",
"subtitle": "Parcourir les vendeurs Letzshop",
"claim": "Revendiquer",
"claimed": "Revendiqué",
"unclaimed": "Non revendiqué",
"last_synced": "Dernière sync"
}
},
"export": {
"title": "Exporter les produits",
"subtitle": "Exporter les produits au format marketplace",
"format": "Format",
"format_csv": "CSV",
"format_xml": "XML",
"download": "Télécharger l'export"
},
"messages": {
"import_started": "Import démarré avec succès",
"import_completed": "Import terminé",
"import_failed": "Import échoué",
"credentials_saved": "Identifiants enregistrés avec succès",
"sync_started": "Synchronisation démarrée",
"sync_completed": "Synchronisation terminée",
"sync_failed": "Synchronisation échouée",
"export_ready": "Export prêt au téléchargement",
"error_loading": "Erreur lors du chargement des données"
},
"filters": {
"all_marketplaces": "Tous les marketplaces",
"all_vendors": "Tous les vendeurs",
"search_placeholder": "Rechercher des produits..."
}
}

View File

@@ -0,0 +1,122 @@
{
"title": "Marketplace-Integratioun",
"description": "Letzshop Produkt- a Bestellsynchronisatioun",
"products": {
"title": "Marketplace-Produkter",
"subtitle": "Vun Marketplacen importéiert Produkter",
"empty": "Keng Produkter fonnt",
"empty_search": "Keng Produkter passen zu Ärer Sich",
"import": "Produkter importéieren"
},
"import": {
"title": "Produkter importéieren",
"subtitle": "Produkter aus Marketplace-Feeds importéieren",
"source_url": "Feed-URL",
"source_url_help": "URL zum Marketplace-CSV-Feed",
"marketplace": "Marketplace",
"language": "Sprooch",
"language_help": "Sprooch fir Produktiwwersetzungen",
"batch_size": "Batch-Gréisst",
"start_import": "Import starten",
"cancel": "Ofbriechen"
},
"import_jobs": {
"title": "Import-Verlaf",
"subtitle": "Vergaangen an aktuell Import-Jobs",
"empty": "Keng Import-Jobs",
"job_id": "Job-ID",
"marketplace": "Marketplace",
"vendor": "Verkeefer",
"status": "Status",
"imported": "Importéiert",
"updated": "Aktualiséiert",
"errors": "Feeler",
"created": "Erstallt",
"completed": "Ofgeschloss",
"statuses": {
"pending": "Waarden",
"processing": "Am Gaang",
"completed": "Ofgeschloss",
"completed_with_errors": "Mat Feeler ofgeschloss",
"failed": "Feelgeschloen"
}
},
"letzshop": {
"title": "Letzshop-Integratioun",
"subtitle": "Letzshop-Verbindung a Synchronisatioun verwalten",
"credentials": {
"title": "API-Umeldedaten",
"api_key": "API-Schlëssel",
"api_key_help": "Ären Letzshop API-Schlëssel",
"endpoint": "API-Endpunkt",
"test_mode": "Testmodus",
"test_mode_help": "Wann aktivéiert, ginn keng Ännerungen bei Letzshop gemaach"
},
"sync": {
"title": "Synchronisatioun",
"auto_sync": "Auto-Sync",
"auto_sync_help": "Bestellungen automatesch vun Letzshop synchroniséieren",
"interval": "Sync-Intervall",
"interval_help": "Minutten tëscht Synchronisatiounen",
"last_sync": "Lescht Sync",
"last_status": "Leschte Status",
"sync_now": "Elo synchroniséieren"
},
"carrier": {
"title": "Versandastellungen",
"default_carrier": "Standard-Versanddienstleeschter",
"greco": "Greco",
"colissimo": "Colissimo",
"xpresslogistics": "XpressLogistics",
"label_url": "Label-URL-Präfix"
},
"historical": {
"title": "Historeschen Import",
"subtitle": "Vergaangen Bestellungen vun Letzshop importéieren",
"start_import": "Historeschen Import starten",
"phase": "Phas",
"confirmed": "Bestätegt Bestellungen",
"unconfirmed": "Onbestätegt Bestellungen",
"fetching": "Ofruff...",
"processing": "Veraarbecht...",
"page": "Säit",
"fetched": "Ofgeruff",
"processed": "Veraarbecht",
"imported": "Importéiert",
"updated": "Aktualiséiert",
"skipped": "Iwwersprong"
},
"vendors": {
"title": "Verkeeferverzeechnes",
"subtitle": "Letzshop-Verkeefer duerchsichen",
"claim": "Reklaméieren",
"claimed": "Reklaméiert",
"unclaimed": "Net reklaméiert",
"last_synced": "Lescht synchroniséiert"
}
},
"export": {
"title": "Produkter exportéieren",
"subtitle": "Produkter am Marketplace-Format exportéieren",
"format": "Format",
"format_csv": "CSV",
"format_xml": "XML",
"download": "Export eroflueden"
},
"messages": {
"import_started": "Import erfollegräich gestart",
"import_completed": "Import ofgeschloss",
"import_failed": "Import feelgeschloen",
"credentials_saved": "Umeldedaten erfollegräich gespäichert",
"sync_started": "Synchronisatioun gestart",
"sync_completed": "Synchronisatioun ofgeschloss",
"sync_failed": "Synchronisatioun feelgeschloen",
"export_ready": "Export prett zum Eroflueden",
"error_loading": "Feeler beim Lueden vun den Daten"
},
"filters": {
"all_marketplaces": "All Marketplacen",
"all_vendors": "All Verkeefer",
"search_placeholder": "Produkter sichen..."
}
}

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}...')>"
)

View File

@@ -2,33 +2,36 @@
"""
Marketplace module route registration.
This module provides functions to register marketplace routes
with module-based access control.
This module provides marketplace routes with module-based access control.
NOTE: Routers are NOT auto-imported to avoid circular dependencies.
Import directly from admin.py or vendor.py as needed:
from app.modules.marketplace.routes.admin import admin_router, admin_letzshop_router
from app.modules.marketplace.routes.vendor import vendor_router, vendor_letzshop_router
Structure:
- routes/api/ - REST API endpoints
- routes/pages/ - HTML page rendering (templates)
NOTE: Routers are not eagerly imported here to avoid circular imports.
Import directly from routes/api/admin.py or routes/api/vendor.py instead.
"""
# Routers are imported on-demand to avoid circular dependencies
# Do NOT add auto-imports here
__all__ = ["admin_router", "admin_letzshop_router", "vendor_router", "vendor_letzshop_router"]
def __getattr__(name: str):
"""Lazy import routers to avoid circular dependencies."""
"""Lazy import of routers to avoid circular imports."""
if name == "admin_router":
from app.modules.marketplace.routes.admin import admin_router
from app.modules.marketplace.routes.api.admin import admin_router
return admin_router
elif name == "admin_letzshop_router":
from app.modules.marketplace.routes.admin import admin_letzshop_router
from app.modules.marketplace.routes.api.admin import admin_letzshop_router
return admin_letzshop_router
elif name == "vendor_router":
from app.modules.marketplace.routes.vendor import vendor_router
from app.modules.marketplace.routes.api.vendor import vendor_router
return vendor_router
elif name == "vendor_letzshop_router":
from app.modules.marketplace.routes.vendor import vendor_letzshop_router
from app.modules.marketplace.routes.api.vendor import vendor_letzshop_router
return vendor_letzshop_router
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = ["admin_router", "admin_letzshop_router", "vendor_router", "vendor_letzshop_router"]

View File

@@ -0,0 +1,35 @@
# app/modules/marketplace/routes/api/__init__.py
"""
Marketplace module API routes.
Provides REST API endpoints for marketplace integration:
- Admin API: Import jobs, vendor directory, marketplace products
- Vendor API: Letzshop sync, product imports, exports
NOTE: Routers are not eagerly imported here to avoid circular imports.
Import directly from admin.py or vendor.py instead.
"""
def __getattr__(name: str):
"""Lazy import of routers to avoid circular imports."""
if name == "admin_router":
from app.modules.marketplace.routes.api.admin import admin_router
return admin_router
elif name == "admin_letzshop_router":
from app.modules.marketplace.routes.api.admin import admin_letzshop_router
return admin_letzshop_router
elif name == "vendor_router":
from app.modules.marketplace.routes.api.vendor import vendor_router
return vendor_router
elif name == "vendor_letzshop_router":
from app.modules.marketplace.routes.api.vendor import vendor_letzshop_router
return vendor_letzshop_router
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = ["admin_router", "admin_letzshop_router", "vendor_router", "vendor_letzshop_router"]

View File

@@ -1,4 +1,4 @@
# app/modules/marketplace/routes/admin.py
# app/modules/marketplace/routes/api/admin.py
"""
Marketplace module admin routes.
@@ -11,13 +11,18 @@ Includes:
- /letzshop/* - Letzshop integration
"""
import importlib
from fastapi import APIRouter, Depends
from app.api.deps import require_module_access
# Import original routers (direct import to avoid circular dependency)
from app.api.v1.admin.marketplace import router as marketplace_original_router
from app.api.v1.admin.letzshop import router as letzshop_original_router
# Import original routers using importlib to avoid circular imports
# (direct import triggers app.api.v1.admin.__init__.py which imports us)
_marketplace_module = importlib.import_module("app.api.v1.admin.marketplace")
_letzshop_module = importlib.import_module("app.api.v1.admin.letzshop")
marketplace_original_router = _marketplace_module.router
letzshop_original_router = _letzshop_module.router
# Create module-aware router for marketplace
admin_router = APIRouter(

View File

@@ -1,4 +1,4 @@
# app/modules/marketplace/routes/vendor.py
# app/modules/marketplace/routes/api/vendor.py
"""
Marketplace module vendor routes.
@@ -11,13 +11,18 @@ Includes:
- /letzshop/* - Letzshop integration
"""
import importlib
from fastapi import APIRouter, Depends
from app.api.deps import require_module_access
# Import original routers (direct import to avoid circular dependency)
from app.api.v1.vendor.marketplace import router as marketplace_original_router
from app.api.v1.vendor.letzshop import router as letzshop_original_router
# Import original routers using importlib to avoid circular imports
# (direct import triggers app.api.v1.vendor.__init__.py which imports us)
_marketplace_module = importlib.import_module("app.api.v1.vendor.marketplace")
_letzshop_module = importlib.import_module("app.api.v1.vendor.letzshop")
marketplace_original_router = _marketplace_module.router
letzshop_original_router = _letzshop_module.router
# Create module-aware router for marketplace
vendor_router = APIRouter(

View File

@@ -0,0 +1,9 @@
# app/modules/marketplace/routes/pages/__init__.py
"""
Marketplace module page routes (HTML rendering).
Provides Jinja2 template rendering for marketplace management.
Note: Page routes can be added here as needed.
"""
__all__ = []

View File

@@ -1,9 +1,8 @@
# app/modules/marketplace/schemas/__init__.py
"""
Marketplace module Pydantic schemas.
Marketplace module Pydantic schemas for API request/response validation.
Re-exports marketplace schemas from the central schemas location.
Provides a module-local import path while maintaining backwards compatibility.
This is the canonical location for marketplace schemas.
Usage:
from app.modules.marketplace.schemas import (
@@ -13,13 +12,18 @@ Usage:
)
"""
from models.schema.marketplace_import_job import (
from app.modules.marketplace.schemas.marketplace_import_job import (
MarketplaceImportJobRequest,
AdminMarketplaceImportJobRequest,
MarketplaceImportJobResponse,
MarketplaceImportJobListResponse,
MarketplaceImportErrorResponse,
MarketplaceImportErrorListResponse,
AdminMarketplaceImportJobResponse,
AdminMarketplaceImportJobListResponse,
MarketplaceImportJobStatusUpdate,
)
from models.schema.marketplace_product import (
from app.modules.marketplace.schemas.marketplace_product import (
# Translation schemas
MarketplaceProductTranslationSchema,
# Base schemas
@@ -42,6 +46,11 @@ __all__ = [
"AdminMarketplaceImportJobRequest",
"MarketplaceImportJobResponse",
"MarketplaceImportJobListResponse",
"MarketplaceImportErrorResponse",
"MarketplaceImportErrorListResponse",
"AdminMarketplaceImportJobResponse",
"AdminMarketplaceImportJobListResponse",
"MarketplaceImportJobStatusUpdate",
# Product schemas
"MarketplaceProductTranslationSchema",
"MarketplaceProductBase",

View File

@@ -0,0 +1,170 @@
# app/modules/marketplace/schemas/marketplace_import_job.py
"""Pydantic schemas for marketplace import jobs."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
class MarketplaceImportJobRequest(BaseModel):
"""Request schema for triggering marketplace import.
Note: vendor_id is injected by middleware, not from request body.
"""
source_url: str = Field(..., description="URL to CSV file from marketplace")
marketplace: str = Field(default="Letzshop", description="Marketplace name")
batch_size: int | None = Field(
1000, description="Processing batch size", ge=100, le=10000
)
language: str = Field(
default="en",
description="Language code for product translations (e.g., 'en', 'fr', 'de')",
)
@field_validator("source_url")
@classmethod
def validate_url(cls, v):
if not v.startswith(("http://", "https://")): # noqa: SEC-034
raise ValueError("URL must start with http:// or https://") # noqa: SEC-034
return v.strip()
@field_validator("marketplace")
@classmethod
def validate_marketplace(cls, v):
return v.strip()
@field_validator("language")
@classmethod
def validate_language(cls, v):
# Basic language code validation (2-5 chars)
v = v.strip().lower()
if not 2 <= len(v) <= 5:
raise ValueError("Language code must be 2-5 characters (e.g., 'en', 'fr')")
return v
class AdminMarketplaceImportJobRequest(BaseModel):
"""Request schema for admin-triggered marketplace import.
Includes vendor_id since admin can import for any vendor.
"""
vendor_id: int = Field(..., description="Vendor ID to import products for")
source_url: str = Field(..., description="URL to CSV file from marketplace")
marketplace: str = Field(default="Letzshop", description="Marketplace name")
batch_size: int | None = Field(
1000, description="Processing batch size", ge=100, le=10000
)
language: str = Field(
default="en",
description="Language code for product translations (e.g., 'en', 'fr', 'de')",
)
@field_validator("source_url")
@classmethod
def validate_url(cls, v):
if not v.startswith(("http://", "https://")): # noqa: SEC-034
raise ValueError("URL must start with http:// or https://") # noqa: SEC-034
return v.strip()
@field_validator("marketplace")
@classmethod
def validate_marketplace(cls, v):
return v.strip()
@field_validator("language")
@classmethod
def validate_language(cls, v):
v = v.strip().lower()
if not 2 <= len(v) <= 5:
raise ValueError("Language code must be 2-5 characters (e.g., 'en', 'fr')")
return v
class MarketplaceImportErrorResponse(BaseModel):
"""Response schema for individual import error."""
model_config = ConfigDict(from_attributes=True)
id: int
row_number: int
identifier: str | None = None
error_type: str
error_message: str
row_data: dict | None = None
created_at: datetime
class MarketplaceImportErrorListResponse(BaseModel):
"""Response schema for list of import errors."""
errors: list[MarketplaceImportErrorResponse]
total: int
import_job_id: int
class MarketplaceImportJobResponse(BaseModel):
"""Response schema for marketplace import job."""
model_config = ConfigDict(from_attributes=True)
job_id: int
vendor_id: int
vendor_code: str | None = None # Populated from vendor relationship
vendor_name: str | None = None # Populated from vendor relationship
marketplace: str
source_url: str
status: str
language: str | None = None # Language used for translations
# Counts
imported: int = 0
updated: int = 0
total_processed: int = 0
error_count: int = 0
# Error details
error_message: str | None = None
# Timestamps
created_at: datetime
started_at: datetime | None = None
completed_at: datetime | None = None
class MarketplaceImportJobListResponse(BaseModel):
"""Response schema for list of import jobs."""
jobs: list[MarketplaceImportJobResponse]
total: int
skip: int
limit: int
class AdminMarketplaceImportJobResponse(MarketplaceImportJobResponse):
"""Extended response schema for admin with additional fields."""
id: int # Alias for job_id (frontend compatibility)
error_details: list = [] # Placeholder for future error details
created_by_name: str | None = None # Username of who created the job
class AdminMarketplaceImportJobListResponse(BaseModel):
"""Response schema for paginated list of import jobs (admin)."""
items: list[AdminMarketplaceImportJobResponse]
total: int
page: int
limit: int
class MarketplaceImportJobStatusUpdate(BaseModel):
"""Schema for updating import job status (internal use)."""
status: str
imported_count: int | None = None
updated_count: int | None = None
error_count: int | None = None
total_processed: int | None = None
error_message: str | None = None

View File

@@ -0,0 +1,225 @@
# app/modules/marketplace/schemas/marketplace_product.py
"""Pydantic schemas for MarketplaceProduct API validation.
Note: title and description are stored in MarketplaceProductTranslation table,
but we keep them in the API schemas for convenience. The service layer
handles creating/updating translations separately.
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from models.schema.inventory import ProductInventorySummary
class MarketplaceProductTranslationSchema(BaseModel):
"""Schema for product translation."""
model_config = ConfigDict(from_attributes=True)
language: str
title: str
description: str | None = None
short_description: str | None = None
meta_title: str | None = None
meta_description: str | None = None
url_slug: str | None = None
class MarketplaceProductBase(BaseModel):
"""Base schema for marketplace products."""
marketplace_product_id: str | None = None
# Localized fields (passed to translations)
title: str | None = None
description: str | None = None
# Links and media
link: str | None = None
image_link: str | None = None
additional_image_link: str | None = None
# Status
availability: str | None = None
is_active: bool | None = None
# Pricing
price: str | None = None
sale_price: str | None = None
currency: str | None = None
# Product identifiers
brand: str | None = None
gtin: str | None = None
mpn: str | None = None
sku: str | None = None
# Product attributes
condition: str | None = None
adult: str | None = None
multipack: int | None = None
is_bundle: str | None = None
age_group: str | None = None
color: str | None = None
gender: str | None = None
material: str | None = None
pattern: str | None = None
size: str | None = None
size_type: str | None = None
size_system: str | None = None
item_group_id: str | None = None
# Categories
google_product_category: str | None = None
product_type_raw: str | None = (
None # Original feed value (renamed from product_type)
)
category_path: str | None = None
# Custom labels
custom_label_0: str | None = None
custom_label_1: str | None = None
custom_label_2: str | None = None
custom_label_3: str | None = None
custom_label_4: str | None = None
# Unit pricing
unit_pricing_measure: str | None = None
unit_pricing_base_measure: str | None = None
identifier_exists: str | None = None
shipping: str | None = None
# Source tracking
marketplace: str | None = None
vendor_name: str | None = None
source_url: str | None = None
# Product type classification
product_type_enum: str | None = (
None # 'physical', 'digital', 'service', 'subscription'
)
is_digital: bool | None = None
# Digital product fields
digital_delivery_method: str | None = None
platform: str | None = None
license_type: str | None = None
# Physical product fields
weight: float | None = None
weight_unit: str | None = None
class MarketplaceProductCreate(MarketplaceProductBase):
"""Schema for creating a marketplace product."""
marketplace_product_id: str = Field(
..., description="Unique product identifier from marketplace"
)
# Title is required for API creation (will be stored in translations)
title: str = Field(..., description="Product title")
class MarketplaceProductUpdate(MarketplaceProductBase):
"""Schema for updating a marketplace product.
All fields are optional - only provided fields will be updated.
"""
class MarketplaceProductResponse(BaseModel):
"""Schema for marketplace product API response."""
model_config = ConfigDict(from_attributes=True)
id: int
marketplace_product_id: str
# These will be populated from translations
title: str | None = None
description: str | None = None
# Links and media
link: str | None = None
image_link: str | None = None
additional_image_link: str | None = None
# Status
availability: str | None = None
is_active: bool | None = None
# Pricing
price: str | None = None
price_numeric: float | None = None
sale_price: str | None = None
sale_price_numeric: float | None = None
currency: str | None = None
# Product identifiers
brand: str | None = None
gtin: str | None = None
mpn: str | None = None
sku: str | None = None
# Product attributes
condition: str | None = None
color: str | None = None
size: str | None = None
# Categories
google_product_category: str | None = None
product_type_raw: str | None = None
category_path: str | None = None
# Source tracking
marketplace: str | None = None
vendor_name: str | None = None
# Product type
product_type_enum: str | None = None
is_digital: bool | None = None
platform: str | None = None
# Timestamps
created_at: datetime
updated_at: datetime
# Translations (optional - included when requested)
translations: list[MarketplaceProductTranslationSchema] | None = None
class MarketplaceProductListResponse(BaseModel):
"""Schema for paginated product list response."""
products: list[MarketplaceProductResponse]
total: int
skip: int
limit: int
class MarketplaceProductDetailResponse(BaseModel):
"""Schema for detailed product response with inventory."""
product: MarketplaceProductResponse
inventory_info: ProductInventorySummary | None = None
translations: list[MarketplaceProductTranslationSchema] | None = None
class MarketplaceImportRequest(BaseModel):
"""Schema for marketplace import request."""
url: str = Field(..., description="URL to CSV file")
marketplace: str = Field(default="Letzshop", description="Marketplace name")
vendor_name: str | None = Field(default=None, description="Vendor name")
language: str = Field(default="en", description="Language code for translations")
batch_size: int = Field(default=100, ge=1, le=1000, description="Batch size")
class MarketplaceImportResponse(BaseModel):
"""Schema for marketplace import response."""
job_id: int
status: str
message: str