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:
2025-12-11 17:29:13 +01:00
parent 92a1c0249f
commit f2af3aae29
7 changed files with 535 additions and 103 deletions

View File

@@ -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]: