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:
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
122
app/modules/marketplace/locales/de.json
Normal file
122
app/modules/marketplace/locales/de.json
Normal 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..."
|
||||
}
|
||||
}
|
||||
122
app/modules/marketplace/locales/en.json
Normal file
122
app/modules/marketplace/locales/en.json
Normal 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..."
|
||||
}
|
||||
}
|
||||
122
app/modules/marketplace/locales/fr.json
Normal file
122
app/modules/marketplace/locales/fr.json
Normal 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..."
|
||||
}
|
||||
}
|
||||
122
app/modules/marketplace/locales/lb.json
Normal file
122
app/modules/marketplace/locales/lb.json
Normal 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..."
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
379
app/modules/marketplace/models/letzshop.py
Normal file
379
app/modules/marketplace/models/letzshop.py
Normal 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}')>"
|
||||
119
app/modules/marketplace/models/marketplace_import_job.py
Normal file
119
app/modules/marketplace/models/marketplace_import_job.py
Normal 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})>"
|
||||
)
|
||||
307
app/modules/marketplace/models/marketplace_product.py
Normal file
307
app/modules/marketplace/models/marketplace_product.py
Normal 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,
|
||||
)
|
||||
@@ -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}...')>"
|
||||
)
|
||||
@@ -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"]
|
||||
|
||||
35
app/modules/marketplace/routes/api/__init__.py
Normal file
35
app/modules/marketplace/routes/api/__init__.py
Normal 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"]
|
||||
@@ -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(
|
||||
@@ -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(
|
||||
9
app/modules/marketplace/routes/pages/__init__.py
Normal file
9
app/modules/marketplace/routes/pages/__init__.py
Normal 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__ = []
|
||||
@@ -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",
|
||||
|
||||
170
app/modules/marketplace/schemas/marketplace_import_job.py
Normal file
170
app/modules/marketplace/schemas/marketplace_import_job.py
Normal 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
|
||||
225
app/modules/marketplace/schemas/marketplace_product.py
Normal file
225
app/modules/marketplace/schemas/marketplace_product.py
Normal 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
|
||||
Reference in New Issue
Block a user