diff --git a/app/modules/marketplace/__init__.py b/app/modules/marketplace/__init__.py index 8d1ec3fe..906be756 100644 --- a/app/modules/marketplace/__init__.py +++ b/app/modules/marketplace/__init__.py @@ -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}") diff --git a/app/modules/marketplace/definition.py b/app/modules/marketplace/definition.py index 31ce8022..466b03c9 100644 --- a/app/modules/marketplace/definition.py +++ b/app/modules/marketplace/definition.py @@ -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 diff --git a/app/modules/marketplace/locales/de.json b/app/modules/marketplace/locales/de.json new file mode 100644 index 00000000..62c0816d --- /dev/null +++ b/app/modules/marketplace/locales/de.json @@ -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..." + } +} diff --git a/app/modules/marketplace/locales/en.json b/app/modules/marketplace/locales/en.json new file mode 100644 index 00000000..96470ed4 --- /dev/null +++ b/app/modules/marketplace/locales/en.json @@ -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..." + } +} diff --git a/app/modules/marketplace/locales/fr.json b/app/modules/marketplace/locales/fr.json new file mode 100644 index 00000000..134cb8c5 --- /dev/null +++ b/app/modules/marketplace/locales/fr.json @@ -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..." + } +} diff --git a/app/modules/marketplace/locales/lb.json b/app/modules/marketplace/locales/lb.json new file mode 100644 index 00000000..43320db4 --- /dev/null +++ b/app/modules/marketplace/locales/lb.json @@ -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..." + } +} diff --git a/app/modules/marketplace/models/__init__.py b/app/modules/marketplace/models/__init__.py index 66c49a38..495b3187 100644 --- a/app/modules/marketplace/models/__init__.py +++ b/app/modules/marketplace/models/__init__.py @@ -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", diff --git a/app/modules/marketplace/models/letzshop.py b/app/modules/marketplace/models/letzshop.py new file mode 100644 index 00000000..56293e42 --- /dev/null +++ b/app/modules/marketplace/models/letzshop.py @@ -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"" + + +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"" + + +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"" + + +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"" + + @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"" diff --git a/app/modules/marketplace/models/marketplace_import_job.py b/app/modules/marketplace/models/marketplace_import_job.py new file mode 100644 index 00000000..4b317250 --- /dev/null +++ b/app/modules/marketplace/models/marketplace_import_job.py @@ -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"" + ) + + +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"" + ) diff --git a/app/modules/marketplace/models/marketplace_product.py b/app/modules/marketplace/models/marketplace_product.py new file mode 100644 index 00000000..31543903 --- /dev/null +++ b/app/modules/marketplace/models/marketplace_product.py @@ -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"" + ) + + # === 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, +) diff --git a/app/modules/marketplace/models/marketplace_product_translation.py b/app/modules/marketplace/models/marketplace_product_translation.py new file mode 100644 index 00000000..67fc9a0b --- /dev/null +++ b/app/modules/marketplace/models/marketplace_product_translation.py @@ -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"" + ) diff --git a/app/modules/marketplace/routes/__init__.py b/app/modules/marketplace/routes/__init__.py index a014b302..6dcc78d3 100644 --- a/app/modules/marketplace/routes/__init__.py +++ b/app/modules/marketplace/routes/__init__.py @@ -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"] diff --git a/app/modules/marketplace/routes/api/__init__.py b/app/modules/marketplace/routes/api/__init__.py new file mode 100644 index 00000000..9c5abc3d --- /dev/null +++ b/app/modules/marketplace/routes/api/__init__.py @@ -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"] diff --git a/app/modules/marketplace/routes/admin.py b/app/modules/marketplace/routes/api/admin.py similarity index 68% rename from app/modules/marketplace/routes/admin.py rename to app/modules/marketplace/routes/api/admin.py index 249b7314..2c11165b 100644 --- a/app/modules/marketplace/routes/admin.py +++ b/app/modules/marketplace/routes/api/admin.py @@ -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( diff --git a/app/modules/marketplace/routes/vendor.py b/app/modules/marketplace/routes/api/vendor.py similarity index 67% rename from app/modules/marketplace/routes/vendor.py rename to app/modules/marketplace/routes/api/vendor.py index 336e577f..fc5e528d 100644 --- a/app/modules/marketplace/routes/vendor.py +++ b/app/modules/marketplace/routes/api/vendor.py @@ -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( diff --git a/app/modules/marketplace/routes/pages/__init__.py b/app/modules/marketplace/routes/pages/__init__.py new file mode 100644 index 00000000..1012df39 --- /dev/null +++ b/app/modules/marketplace/routes/pages/__init__.py @@ -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__ = [] diff --git a/app/modules/marketplace/schemas/__init__.py b/app/modules/marketplace/schemas/__init__.py index 3c846aa7..63fea748 100644 --- a/app/modules/marketplace/schemas/__init__.py +++ b/app/modules/marketplace/schemas/__init__.py @@ -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", diff --git a/app/modules/marketplace/schemas/marketplace_import_job.py b/app/modules/marketplace/schemas/marketplace_import_job.py new file mode 100644 index 00000000..a20e490f --- /dev/null +++ b/app/modules/marketplace/schemas/marketplace_import_job.py @@ -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 diff --git a/app/modules/marketplace/schemas/marketplace_product.py b/app/modules/marketplace/schemas/marketplace_product.py new file mode 100644 index 00000000..e34a117e --- /dev/null +++ b/app/modules/marketplace/schemas/marketplace_product.py @@ -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