feat: update CSV import to support multi-language translations
- Add language parameter to import endpoints and background tasks - Extract translation fields (title, description, short_description) - Create/update MarketplaceProductTranslation records during import - Add MarketplaceProductTranslationSchema for API responses - Map product_type column to product_type_raw to avoid enum conflict - Parse prices to numeric format (price_numeric, sale_price_numeric) - Update marketplace product service for translation-based lookups - Update CSV export to retrieve titles from translations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,9 @@ This module provides classes and functions for:
|
||||
- Advanced product filtering and search
|
||||
- Inventory information integration
|
||||
- CSV export functionality
|
||||
|
||||
Note: Title and description are now stored in MarketplaceProductTranslation table.
|
||||
Use get_title(language) and get_description(language) methods on the model.
|
||||
"""
|
||||
|
||||
import csv
|
||||
@@ -15,8 +18,9 @@ from collections.abc import Generator
|
||||
from datetime import UTC, datetime
|
||||
from io import StringIO
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.exceptions import (
|
||||
InvalidMarketplaceProductDataException,
|
||||
@@ -28,6 +32,7 @@ from app.exceptions import (
|
||||
from app.utils.data_processing import GTINProcessor, PriceProcessor
|
||||
from models.database.inventory import Inventory
|
||||
from models.database.marketplace_product import MarketplaceProduct
|
||||
from models.database.marketplace_product_translation import MarketplaceProductTranslation
|
||||
from models.schema.inventory import InventoryLocationResponse, InventorySummaryResponse
|
||||
from models.schema.marketplace_product import (
|
||||
MarketplaceProductCreate,
|
||||
@@ -46,9 +51,25 @@ class MarketplaceProductService:
|
||||
self.price_processor = PriceProcessor()
|
||||
|
||||
def create_product(
|
||||
self, db: Session, product_data: MarketplaceProductCreate
|
||||
self,
|
||||
db: Session,
|
||||
product_data: MarketplaceProductCreate,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
language: str = "en",
|
||||
) -> MarketplaceProduct:
|
||||
"""Create a new product with validation."""
|
||||
"""Create a new product with validation.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
product_data: Product data from schema
|
||||
title: Product title (stored in translations table)
|
||||
description: Product description (stored in translations table)
|
||||
language: Language code for translation (default: 'en')
|
||||
|
||||
Returns:
|
||||
Created MarketplaceProduct instance
|
||||
"""
|
||||
try:
|
||||
# Process and validate GTIN if provided
|
||||
if product_data.gtin:
|
||||
@@ -85,13 +106,26 @@ class MarketplaceProductService:
|
||||
"MarketplaceProduct ID is required", field="marketplace_product_id"
|
||||
)
|
||||
|
||||
if not product_data.title or not product_data.title.strip():
|
||||
raise MarketplaceProductValidationException(
|
||||
"MarketplaceProduct title is required", field="title"
|
||||
)
|
||||
# Create the product (without title/description - those go in translations)
|
||||
product_dict = product_data.model_dump()
|
||||
# Remove any title/description if present in schema (for backwards compatibility)
|
||||
product_dict.pop("title", None)
|
||||
product_dict.pop("description", None)
|
||||
|
||||
db_product = MarketplaceProduct(**product_data.model_dump())
|
||||
db_product = MarketplaceProduct(**product_dict)
|
||||
db.add(db_product)
|
||||
db.flush() # Get the ID
|
||||
|
||||
# Create translation if title is provided
|
||||
if title and title.strip():
|
||||
translation = MarketplaceProductTranslation(
|
||||
marketplace_product_id=db_product.id,
|
||||
language=language,
|
||||
title=title.strip(),
|
||||
description=description.strip() if description else None,
|
||||
)
|
||||
db.add(translation)
|
||||
|
||||
db.flush()
|
||||
db.refresh(db_product)
|
||||
|
||||
@@ -123,6 +157,7 @@ class MarketplaceProductService:
|
||||
try:
|
||||
return (
|
||||
db.query(MarketplaceProduct)
|
||||
.options(joinedload(MarketplaceProduct.translations))
|
||||
.filter(
|
||||
MarketplaceProduct.marketplace_product_id == marketplace_product_id
|
||||
)
|
||||
@@ -164,6 +199,7 @@ class MarketplaceProductService:
|
||||
marketplace: str | None = None,
|
||||
vendor_name: str | None = None,
|
||||
search: str | None = None,
|
||||
language: str = "en",
|
||||
) -> tuple[list[MarketplaceProduct], int]:
|
||||
"""
|
||||
Get products with filtering and pagination.
|
||||
@@ -177,13 +213,16 @@ class MarketplaceProductService:
|
||||
availability: Availability filter
|
||||
marketplace: Marketplace filter
|
||||
vendor_name: Vendor name filter
|
||||
search: Search term
|
||||
search: Search term (searches in translations too)
|
||||
language: Language for search (default: 'en')
|
||||
|
||||
Returns:
|
||||
Tuple of (products_list, total_count)
|
||||
"""
|
||||
try:
|
||||
query = db.query(MarketplaceProduct)
|
||||
query = db.query(MarketplaceProduct).options(
|
||||
joinedload(MarketplaceProduct.translations)
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if brand:
|
||||
@@ -203,14 +242,22 @@ class MarketplaceProductService:
|
||||
MarketplaceProduct.vendor_name.ilike(f"%{vendor_name}%")
|
||||
)
|
||||
if search:
|
||||
# Search in title, description, marketplace, and name
|
||||
# Search in marketplace, vendor_name, brand, and translations
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(MarketplaceProduct.title.ilike(search_term))
|
||||
| (MarketplaceProduct.description.ilike(search_term))
|
||||
| (MarketplaceProduct.marketplace.ilike(search_term))
|
||||
| (MarketplaceProduct.vendor_name.ilike(search_term))
|
||||
# Join with translations for title/description search
|
||||
query = query.outerjoin(MarketplaceProductTranslation).filter(
|
||||
or_(
|
||||
MarketplaceProduct.marketplace.ilike(search_term),
|
||||
MarketplaceProduct.vendor_name.ilike(search_term),
|
||||
MarketplaceProduct.brand.ilike(search_term),
|
||||
MarketplaceProduct.gtin.ilike(search_term),
|
||||
MarketplaceProduct.marketplace_product_id.ilike(search_term),
|
||||
MarketplaceProductTranslation.title.ilike(search_term),
|
||||
MarketplaceProductTranslation.description.ilike(search_term),
|
||||
)
|
||||
)
|
||||
# Remove duplicates from join
|
||||
query = query.distinct()
|
||||
|
||||
total = query.count()
|
||||
products = query.offset(skip).limit(limit).all()
|
||||
@@ -226,14 +273,33 @@ class MarketplaceProductService:
|
||||
db: Session,
|
||||
marketplace_product_id: str,
|
||||
product_update: MarketplaceProductUpdate,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
language: str = "en",
|
||||
) -> MarketplaceProduct:
|
||||
"""Update product with validation."""
|
||||
"""Update product with validation.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
marketplace_product_id: ID of product to update
|
||||
product_update: Product update data from schema
|
||||
title: Updated title (stored in translations table)
|
||||
description: Updated description (stored in translations table)
|
||||
language: Language code for translation (default: 'en')
|
||||
|
||||
Returns:
|
||||
Updated MarketplaceProduct instance
|
||||
"""
|
||||
try:
|
||||
product = self.get_product_by_id_or_raise(db, marketplace_product_id)
|
||||
|
||||
# Update fields
|
||||
update_data = product_update.model_dump(exclude_unset=True)
|
||||
|
||||
# Remove title/description from update data (handled separately)
|
||||
update_data.pop("title", None)
|
||||
update_data.pop("description", None)
|
||||
|
||||
# Validate GTIN if being updated
|
||||
if "gtin" in update_data and update_data["gtin"]:
|
||||
normalized_gtin = self.gtin_processor.normalize(update_data["gtin"])
|
||||
@@ -256,18 +322,19 @@ class MarketplaceProductService:
|
||||
# Convert ValueError to domain-specific exception
|
||||
raise InvalidMarketplaceProductDataException(str(e), field="price")
|
||||
|
||||
# Validate required fields if being updated
|
||||
if "title" in update_data and (
|
||||
not update_data["title"] or not update_data["title"].strip()
|
||||
):
|
||||
raise MarketplaceProductValidationException(
|
||||
"MarketplaceProduct title cannot be empty", field="title"
|
||||
)
|
||||
|
||||
# Apply updates to product
|
||||
for key, value in update_data.items():
|
||||
setattr(product, key, value)
|
||||
if hasattr(product, key):
|
||||
setattr(product, key, value)
|
||||
|
||||
product.updated_at = datetime.now(UTC)
|
||||
|
||||
# Update or create translation if title/description provided
|
||||
if title is not None or description is not None:
|
||||
self._update_or_create_translation(
|
||||
db, product, title, description, language
|
||||
)
|
||||
|
||||
db.flush()
|
||||
db.refresh(product)
|
||||
|
||||
@@ -284,6 +351,41 @@ class MarketplaceProductService:
|
||||
logger.error(f"Error updating product {marketplace_product_id}: {str(e)}")
|
||||
raise ValidationException("Failed to update product")
|
||||
|
||||
def _update_or_create_translation(
|
||||
self,
|
||||
db: Session,
|
||||
product: MarketplaceProduct,
|
||||
title: str | None,
|
||||
description: str | None,
|
||||
language: str,
|
||||
) -> None:
|
||||
"""Update existing translation or create new one."""
|
||||
existing = (
|
||||
db.query(MarketplaceProductTranslation)
|
||||
.filter(
|
||||
MarketplaceProductTranslation.marketplace_product_id == product.id,
|
||||
MarketplaceProductTranslation.language == language,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
if title is not None:
|
||||
existing.title = title.strip() if title else existing.title
|
||||
if description is not None:
|
||||
existing.description = description.strip() if description else None
|
||||
existing.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
# Only create if we have a title
|
||||
if title and title.strip():
|
||||
new_translation = MarketplaceProductTranslation(
|
||||
marketplace_product_id=product.id,
|
||||
language=language,
|
||||
title=title.strip(),
|
||||
description=description.strip() if description else None,
|
||||
)
|
||||
db.add(new_translation)
|
||||
|
||||
def delete_product(self, db: Session, marketplace_product_id: str) -> bool:
|
||||
"""
|
||||
Delete product and associated inventory.
|
||||
@@ -305,6 +407,7 @@ class MarketplaceProductService:
|
||||
if product.gtin:
|
||||
db.query(Inventory).filter(Inventory.gtin == product.gtin).delete()
|
||||
|
||||
# Translations will be cascade deleted
|
||||
db.delete(product)
|
||||
db.flush()
|
||||
|
||||
@@ -354,16 +457,12 @@ class MarketplaceProductService:
|
||||
logger.error(f"Error getting inventory info for GTIN {gtin}: {str(e)}")
|
||||
return None
|
||||
|
||||
import csv
|
||||
from io import StringIO
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
def generate_csv_export(
|
||||
self,
|
||||
db: Session,
|
||||
marketplace: str | None = None,
|
||||
vendor_name: str | None = None,
|
||||
language: str = "en",
|
||||
) -> Generator[str, None, None]:
|
||||
"""
|
||||
Generate CSV export with streaming for memory efficiency and proper CSV escaping.
|
||||
@@ -372,6 +471,7 @@ class MarketplaceProductService:
|
||||
db: Database session
|
||||
marketplace: Optional marketplace filter
|
||||
vendor_name: Optional vendor name filter
|
||||
language: Language code for title/description (default: 'en')
|
||||
|
||||
Yields:
|
||||
CSV content as strings with proper escaping
|
||||
@@ -394,7 +494,7 @@ class MarketplaceProductService:
|
||||
"brand",
|
||||
"gtin",
|
||||
"marketplace",
|
||||
"name",
|
||||
"vendor_name",
|
||||
]
|
||||
writer.writerow(headers)
|
||||
yield output.getvalue()
|
||||
@@ -407,7 +507,9 @@ class MarketplaceProductService:
|
||||
offset = 0
|
||||
|
||||
while True:
|
||||
query = db.query(MarketplaceProduct)
|
||||
query = db.query(MarketplaceProduct).options(
|
||||
joinedload(MarketplaceProduct.translations)
|
||||
)
|
||||
|
||||
# Apply marketplace filters
|
||||
if marketplace:
|
||||
@@ -424,11 +526,15 @@ class MarketplaceProductService:
|
||||
break
|
||||
|
||||
for product in products:
|
||||
# Get title and description from translations
|
||||
title = product.get_title(language) or ""
|
||||
description = product.get_description(language) or ""
|
||||
|
||||
# Create CSV row with proper escaping
|
||||
row_data = [
|
||||
product.marketplace_product_id or "",
|
||||
product.title or "",
|
||||
product.description or "",
|
||||
title,
|
||||
description,
|
||||
product.link or "",
|
||||
product.image_link or "",
|
||||
product.availability or "",
|
||||
@@ -471,7 +577,7 @@ class MarketplaceProductService:
|
||||
# Private helper methods
|
||||
def _validate_product_data(self, product_data: dict) -> None:
|
||||
"""Validate product data structure."""
|
||||
required_fields = ["marketplace_product_id", "title"]
|
||||
required_fields = ["marketplace_product_id"]
|
||||
|
||||
for field in required_fields:
|
||||
if field not in product_data or not product_data[field]:
|
||||
@@ -486,11 +592,9 @@ class MarketplaceProductService:
|
||||
# Trim whitespace from string fields
|
||||
string_fields = [
|
||||
"marketplace_product_id",
|
||||
"title",
|
||||
"description",
|
||||
"brand",
|
||||
"marketplace",
|
||||
"name",
|
||||
"vendor_name",
|
||||
]
|
||||
for field in string_fields:
|
||||
if field in normalized and normalized[field]:
|
||||
|
||||
Reference in New Issue
Block a user