# Slice 2: Marketplace Product Import ## Vendor Imports Products from Letzshop **Status**: 📋 NOT STARTED **Timeline**: Week 2 (5 days) **Prerequisites**: Slice 1 complete ## 🎯 Slice Objectives Enable vendors to import product catalogs from Letzshop marketplace via CSV files. ### User Stories - As a Vendor Owner, I can configure my Letzshop CSV URL - As a Vendor Owner, I can trigger product imports from Letzshop - As a Vendor Owner, I can view import job status and history - The system processes CSV data in the background - As a Vendor Owner, I can see real-time import progress ### Success Criteria - [ ] Vendor can configure Letzshop CSV URL (FR, EN, DE) - [ ] Vendor can trigger import jobs manually - [ ] System downloads and processes CSV files - [ ] Import status updates in real-time (Alpine.js) - [ ] Import history is properly tracked - [ ] Error handling for failed imports - [ ] Products stored in staging area (MarketplaceProduct table) - [ ] Large CSV files process without timeout ## 📋 Backend Implementation ### Database Models #### MarketplaceProduct Model (`models/database/marketplace_product.py`) ```python class MarketplaceProduct(Base, TimestampMixin): """ Staging table for imported marketplace products Products stay here until vendor publishes them to catalog """ __tablename__ = "marketplace_products" id = Column(Integer, primary_key=True, index=True) vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False) import_job_id = Column(Integer, ForeignKey("marketplace_import_jobs.id")) # External identifiers external_sku = Column(String, nullable=False, index=True) marketplace = Column(String, default="letzshop") # Future: other marketplaces # Product information (from CSV) title = Column(String, nullable=False) description = Column(Text) price = Column(Numeric(10, 2)) currency = Column(String(3), default="EUR") # Categories and attributes category = Column(String) brand = Column(String) attributes = Column(JSON, default=dict) # Store all CSV columns # Images image_urls = Column(JSON, default=list) # List of image URLs # Inventory stock_quantity = Column(Integer) is_in_stock = Column(Boolean, default=True) # Status is_selected = Column(Boolean, default=False) # Ready to publish? is_published = Column(Boolean, default=False) # Already in catalog? published_product_id = Column(Integer, ForeignKey("products.id"), nullable=True) # Metadata language = Column(String(2)) # 'fr', 'en', 'de' raw_data = Column(JSON) # Store complete CSV row # Relationships vendor = relationship("Vendor", back_populates="marketplace_products") import_job = relationship("MarketplaceImportJob", back_populates="products") published_product = relationship("Product", back_populates="marketplace_source") # Indexes __table_args__ = ( Index('ix_marketplace_vendor_sku', 'vendor_id', 'external_sku'), Index('ix_marketplace_selected', 'vendor_id', 'is_selected'), ) ``` #### MarketplaceImportJob Model (`models/database/marketplace.py`) ```python class MarketplaceImportJob(Base, TimestampMixin): """Track CSV import jobs""" __tablename__ = "marketplace_import_jobs" id = Column(Integer, primary_key=True, index=True) vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False) # Job details marketplace = Column(String, default="letzshop") csv_url = Column(String, nullable=False) language = Column(String(2)) # 'fr', 'en', 'de' # Status tracking status = Column( String, default="pending" ) # pending, processing, completed, failed # Progress total_rows = Column(Integer, default=0) processed_rows = Column(Integer, default=0) imported_count = Column(Integer, default=0) updated_count = Column(Integer, default=0) error_count = Column(Integer, default=0) # Timing started_at = Column(DateTime, nullable=True) completed_at = Column(DateTime, nullable=True) # Error handling error_message = Column(Text, nullable=True) error_details = Column(JSON, nullable=True) # Relationships vendor = relationship("Vendor", back_populates="import_jobs") products = relationship("MarketplaceProduct", back_populates="import_job") ``` ### Pydantic Schemas #### Import Schemas (`models/schema/marketplace.py`) ```python from pydantic import BaseModel, HttpUrl from typing import Optional, List from datetime import datetime class MarketplaceImportCreate(BaseModel): """Create new import job""" csv_url: HttpUrl language: str = Field(..., regex="^(fr|en|de)$") marketplace: str = "letzshop" class MarketplaceImportJobResponse(BaseModel): """Import job details""" id: int vendor_id: int marketplace: str csv_url: str language: str status: str total_rows: int processed_rows: int imported_count: int updated_count: int error_count: int started_at: Optional[datetime] completed_at: Optional[datetime] error_message: Optional[str] created_at: datetime class Config: from_attributes = True class MarketplaceProductResponse(BaseModel): """Marketplace product in staging""" id: int vendor_id: int external_sku: str title: str description: Optional[str] price: float currency: str category: Optional[str] brand: Optional[str] stock_quantity: Optional[int] is_in_stock: bool is_selected: bool is_published: bool image_urls: List[str] language: str created_at: datetime class Config: from_attributes = True ``` ### Service Layer #### Marketplace Service (`app/services/marketplace_service.py`) ```python from typing import List, Dict, Any import csv import requests from io import StringIO from sqlalchemy.orm import Session from models.database.marketplace_product import MarketplaceProduct from models.database.marketplace import MarketplaceImportJob class MarketplaceService: """Handle marketplace product imports""" async def create_import_job( self, vendor_id: int, csv_url: str, language: str, db: Session ) -> MarketplaceImportJob: """Create new import job and start processing""" # Create job record job = MarketplaceImportJob( vendor_id=vendor_id, csv_url=csv_url, language=language, marketplace="letzshop", status="pending" ) db.add(job) db.commit() db.refresh(job) # Trigger background processing from tasks.marketplace_import import process_csv_import process_csv_import.delay(job.id) return job def process_csv_import(self, job_id: int, db: Session): """ Process CSV import (called by Celery task) This is a long-running operation """ job = db.query(MarketplaceImportJob).get(job_id) if not job: return try: # Update status job.status = "processing" job.started_at = datetime.utcnow() db.commit() # Download CSV response = requests.get(job.csv_url, timeout=30) response.raise_for_status() # Parse CSV csv_content = StringIO(response.text) reader = csv.DictReader(csv_content) # Count total rows rows = list(reader) job.total_rows = len(rows) db.commit() # Process each row for idx, row in enumerate(rows): try: self._process_csv_row(job, row, db) job.processed_rows = idx + 1 # Commit every 100 rows if idx % 100 == 0: db.commit() except Exception as e: job.error_count += 1 # Log error but continue # Final commit job.status = "completed" job.completed_at = datetime.utcnow() db.commit() except Exception as e: job.status = "failed" job.error_message = str(e) job.completed_at = datetime.utcnow() db.commit() def _process_csv_row( self, job: MarketplaceImportJob, row: Dict[str, Any], db: Session ): """Process single CSV row""" # Extract fields from CSV external_sku = row.get('SKU') or row.get('sku') if not external_sku: raise ValueError("Missing SKU in CSV row") # Check if product already exists existing = db.query(MarketplaceProduct).filter( MarketplaceProduct.vendor_id == job.vendor_id, MarketplaceProduct.external_sku == external_sku ).first() # Parse image URLs image_urls = [] for i in range(1, 6): # Support up to 5 images img_url = row.get(f'Image{i}') or row.get(f'image_{i}') if img_url: image_urls.append(img_url) if existing: # Update existing product existing.title = row.get('Title') or row.get('title') existing.description = row.get('Description') existing.price = float(row.get('Price', 0)) existing.stock_quantity = int(row.get('Stock', 0)) existing.is_in_stock = existing.stock_quantity > 0 existing.category = row.get('Category') existing.brand = row.get('Brand') existing.image_urls = image_urls existing.raw_data = row existing.import_job_id = job.id job.updated_count += 1 else: # Create new product product = MarketplaceProduct( vendor_id=job.vendor_id, import_job_id=job.id, external_sku=external_sku, marketplace="letzshop", title=row.get('Title') or row.get('title'), description=row.get('Description'), price=float(row.get('Price', 0)), currency="EUR", category=row.get('Category'), brand=row.get('Brand'), stock_quantity=int(row.get('Stock', 0)), is_in_stock=int(row.get('Stock', 0)) > 0, image_urls=image_urls, language=job.language, raw_data=row, is_selected=False, is_published=False ) db.add(product) job.imported_count += 1 def get_import_jobs( self, vendor_id: int, db: Session, skip: int = 0, limit: int = 20 ) -> List[MarketplaceImportJob]: """Get import job history for vendor""" return db.query(MarketplaceImportJob).filter( MarketplaceImportJob.vendor_id == vendor_id ).order_by( MarketplaceImportJob.created_at.desc() ).offset(skip).limit(limit).all() def get_marketplace_products( self, vendor_id: int, db: Session, import_job_id: Optional[int] = None, is_selected: Optional[bool] = None, skip: int = 0, limit: int = 100 ) -> List[MarketplaceProduct]: """Get marketplace products in staging""" query = db.query(MarketplaceProduct).filter( MarketplaceProduct.vendor_id == vendor_id, MarketplaceProduct.is_published == False # Only unpublished ) if import_job_id: query = query.filter(MarketplaceProduct.import_job_id == import_job_id) if is_selected is not None: query = query.filter(MarketplaceProduct.is_selected == is_selected) return query.order_by( MarketplaceProduct.created_at.desc() ).offset(skip).limit(limit).all() ``` ### API Endpoints #### Marketplace Endpoints (`app/api/v1/vendor/marketplace.py`) ```python from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from typing import List, Optional router = APIRouter() @router.post("/import", response_model=MarketplaceImportJobResponse) async def trigger_import( import_data: MarketplaceImportCreate, current_user: User = Depends(get_current_vendor_user), vendor: Vendor = Depends(get_current_vendor), db: Session = Depends(get_db) ): """Trigger CSV import from marketplace""" service = MarketplaceService() job = await service.create_import_job( vendor_id=vendor.id, csv_url=str(import_data.csv_url), language=import_data.language, db=db ) return job @router.get("/jobs", response_model=List[MarketplaceImportJobResponse]) async def get_import_jobs( skip: int = 0, limit: int = 20, current_user: User = Depends(get_current_vendor_user), vendor: Vendor = Depends(get_current_vendor), db: Session = Depends(get_db) ): """Get import job history""" service = MarketplaceService() jobs = service.get_import_jobs(vendor.id, db, skip, limit) return jobs @router.get("/jobs/{job_id}", response_model=MarketplaceImportJobResponse) async def get_import_job( job_id: int, current_user: User = Depends(get_current_vendor_user), vendor: Vendor = Depends(get_current_vendor), db: Session = Depends(get_db) ): """Get specific import job status""" job = db.query(MarketplaceImportJob).filter( MarketplaceImportJob.id == job_id, MarketplaceImportJob.vendor_id == vendor.id ).first() if not job: raise HTTPException(status_code=404, detail="Import job not found") return job @router.get("/products", response_model=List[MarketplaceProductResponse]) async def get_marketplace_products( import_job_id: Optional[int] = None, is_selected: Optional[bool] = None, skip: int = 0, limit: int = 100, current_user: User = Depends(get_current_vendor_user), vendor: Vendor = Depends(get_current_vendor), db: Session = Depends(get_db) ): """Get products in marketplace staging area""" service = MarketplaceService() products = service.get_marketplace_products( vendor.id, db, import_job_id, is_selected, skip, limit ) return products ``` ### Background Tasks #### Celery Task (`tasks/marketplace_import.py`) ```python from celery import shared_task from app.core.database import SessionLocal from app.services.marketplace_service import MarketplaceService @shared_task(bind=True, max_retries=3) def process_csv_import(self, job_id: int): """ Process CSV import in background This can take several minutes for large files """ db = SessionLocal() try: service = MarketplaceService() service.process_csv_import(job_id, db) except Exception as e: # Retry on failure raise self.retry(exc=e, countdown=60) finally: db.close() ``` ## 🎨 Frontend Implementation ### Templates #### Import Dashboard (`templates/vendor/marketplace/imports.html`) ```html {% extends "vendor/base_vendor.html" %} {% block title %}Product Import{% endblock %} {% block content %}
Configure these URLs in vendor settings
No imports yet. Start your first import!
| ID | Language | Status | Progress | Results | Started | Actions |
|---|---|---|---|---|---|---|
|
✓ imported
↻ updated
✗ errors
|