Files
orion/app/services/product_service.py

425 lines
15 KiB
Python

# app/services/product_service.py
"""
Product service for managing product operations and data processing.
This module provides classes and functions for:
- Product CRUD operations with validation
- Advanced product filtering and search
- Stock information integration
- CSV export functionality
"""
import csv
import logging
from datetime import datetime, timezone
from io import StringIO
from typing import Generator, List, Optional, Tuple
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.exceptions import (
ProductNotFoundException,
ProductAlreadyExistsException,
InvalidProductDataException,
ProductValidationException,
ValidationException,
)
from models.schemas.product import ProductCreate, ProductUpdate
from models.schemas.stock import StockLocationResponse, StockSummaryResponse
from models.database.product import Product
from models.database.stock import Stock
from app.utils.data_processing import GTINProcessor, PriceProcessor
logger = logging.getLogger(__name__)
class ProductService:
"""Service class for Product operations following the application's service pattern."""
def __init__(self):
"""Class constructor."""
self.gtin_processor = GTINProcessor()
self.price_processor = PriceProcessor()
def create_product(self, db: Session, product_data: ProductCreate) -> Product:
"""Create a new product with validation."""
try:
# Process and validate GTIN if provided
if product_data.gtin:
normalized_gtin = self.gtin_processor.normalize(product_data.gtin)
if not normalized_gtin:
raise InvalidProductDataException("Invalid GTIN format", field="gtin")
product_data.gtin = normalized_gtin
# Process price if provided
if product_data.price:
try:
parsed_price, currency = self.price_processor.parse_price_currency(
product_data.price
)
if parsed_price:
product_data.price = parsed_price
product_data.currency = currency
except ValueError as e:
# Convert ValueError to domain-specific exception
raise InvalidProductDataException(str(e), field="price")
# Set default marketplace if not provided
if not product_data.marketplace:
product_data.marketplace = "Letzshop"
# Validate required fields
if not product_data.product_id or not product_data.product_id.strip():
raise ProductValidationException("Product ID is required", field="product_id")
if not product_data.title or not product_data.title.strip():
raise ProductValidationException("Product title is required", field="title")
db_product = Product(**product_data.model_dump())
db.add(db_product)
db.commit()
db.refresh(db_product)
logger.info(f"Created product {db_product.product_id}")
return db_product
except (InvalidProductDataException, ProductValidationException):
db.rollback()
raise # Re-raise custom exceptions
except IntegrityError as e:
db.rollback()
logger.error(f"Database integrity error: {str(e)}")
if "product_id" in str(e).lower() or "unique" in str(e).lower():
raise ProductAlreadyExistsException(product_data.product_id)
else:
raise ProductValidationException("Data integrity constraint violation")
except Exception as e:
db.rollback()
logger.error(f"Error creating product: {str(e)}")
raise ValidationException("Failed to create product")
def get_product_by_id(self, db: Session, product_id: str) -> Optional[Product]:
"""Get a product by its ID."""
try:
return db.query(Product).filter(Product.product_id == product_id).first()
except Exception as e:
logger.error(f"Error getting product {product_id}: {str(e)}")
return None
def get_product_by_id_or_raise(self, db: Session, product_id: str) -> Product:
"""
Get a product by its ID or raise exception.
Args:
db: Database session
product_id: Product ID to find
Returns:
Product object
Raises:
ProductNotFoundException: If product doesn't exist
"""
product = self.get_product_by_id(db, product_id)
if not product:
raise ProductNotFoundException(product_id)
return product
def get_products_with_filters(
self,
db: Session,
skip: int = 0,
limit: int = 100,
brand: Optional[str] = None,
category: Optional[str] = None,
availability: Optional[str] = None,
marketplace: Optional[str] = None,
shop_name: Optional[str] = None,
search: Optional[str] = None,
) -> Tuple[List[Product], int]:
"""
Get products with filtering and pagination.
Args:
db: Database session
skip: Number of records to skip
limit: Maximum records to return
brand: Brand filter
category: Category filter
availability: Availability filter
marketplace: Marketplace filter
shop_name: Shop name filter
search: Search term
Returns:
Tuple of (products_list, total_count)
"""
try:
query = db.query(Product)
# Apply filters
if brand:
query = query.filter(Product.brand.ilike(f"%{brand}%"))
if category:
query = query.filter(Product.google_product_category.ilike(f"%{category}%"))
if availability:
query = query.filter(Product.availability == availability)
if marketplace:
query = query.filter(Product.marketplace.ilike(f"%{marketplace}%"))
if shop_name:
query = query.filter(Product.shop_name.ilike(f"%{shop_name}%"))
if search:
# Search in title, description, marketplace, and shop_name
search_term = f"%{search}%"
query = query.filter(
(Product.title.ilike(search_term))
| (Product.description.ilike(search_term))
| (Product.marketplace.ilike(search_term))
| (Product.shop_name.ilike(search_term))
)
total = query.count()
products = query.offset(skip).limit(limit).all()
return products, total
except Exception as e:
logger.error(f"Error getting products with filters: {str(e)}")
raise ValidationException("Failed to retrieve products")
def update_product(self, db: Session, product_id: str, product_update: ProductUpdate) -> Product:
"""Update product with validation."""
try:
product = self.get_product_by_id_or_raise(db, product_id)
# Update fields
update_data = product_update.model_dump(exclude_unset=True)
# Validate GTIN if being updated
if "gtin" in update_data and update_data["gtin"]:
normalized_gtin = self.gtin_processor.normalize(update_data["gtin"])
if not normalized_gtin:
raise InvalidProductDataException("Invalid GTIN format", field="gtin")
update_data["gtin"] = normalized_gtin
# Process price if being updated
if "price" in update_data and update_data["price"]:
try:
parsed_price, currency = self.price_processor.parse_price_currency(
update_data["price"]
)
if parsed_price:
update_data["price"] = parsed_price
update_data["currency"] = currency
except ValueError as e:
# Convert ValueError to domain-specific exception
raise InvalidProductDataException(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 ProductValidationException("Product title cannot be empty", field="title")
for key, value in update_data.items():
setattr(product, key, value)
product.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(product)
logger.info(f"Updated product {product_id}")
return product
except (ProductNotFoundException, InvalidProductDataException, ProductValidationException):
db.rollback()
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error updating product {product_id}: {str(e)}")
raise ValidationException("Failed to update product")
def delete_product(self, db: Session, product_id: str) -> bool:
"""
Delete product and associated stock.
Args:
db: Database session
product_id: Product ID to delete
Returns:
True if deletion successful
Raises:
ProductNotFoundException: If product doesn't exist
"""
try:
product = self.get_product_by_id_or_raise(db, product_id)
# Delete associated stock entries if GTIN exists
if product.gtin:
db.query(Stock).filter(Stock.gtin == product.gtin).delete()
db.delete(product)
db.commit()
logger.info(f"Deleted product {product_id}")
return True
except ProductNotFoundException:
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error deleting product {product_id}: {str(e)}")
raise ValidationException("Failed to delete product")
def get_stock_info(self, db: Session, gtin: str) -> Optional[StockSummaryResponse]:
"""
Get stock information for a product by GTIN.
Args:
db: Database session
gtin: GTIN to look up stock for
Returns:
StockSummaryResponse if stock found, None otherwise
"""
try:
stock_entries = db.query(Stock).filter(Stock.gtin == gtin).all()
if not stock_entries:
return None
total_quantity = sum(entry.quantity for entry in stock_entries)
locations = [
StockLocationResponse(location=entry.location, quantity=entry.quantity)
for entry in stock_entries
]
return StockSummaryResponse(
gtin=gtin, total_quantity=total_quantity, locations=locations
)
except Exception as e:
logger.error(f"Error getting stock info for GTIN {gtin}: {str(e)}")
return None
import csv
from io import StringIO
from typing import Generator, Optional
from sqlalchemy.orm import Session
def generate_csv_export(
self,
db: Session,
marketplace: Optional[str] = None,
shop_name: Optional[str] = None,
) -> Generator[str, None, None]:
"""
Generate CSV export with streaming for memory efficiency and proper CSV escaping.
Args:
db: Database session
marketplace: Optional marketplace filter
shop_name: Optional shop name filter
Yields:
CSV content as strings with proper escaping
"""
try:
# Create a StringIO buffer for CSV writing
output = StringIO()
writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL)
# Write header row
headers = [
"product_id", "title", "description", "link", "image_link",
"availability", "price", "currency", "brand", "gtin",
"marketplace", "shop_name"
]
writer.writerow(headers)
yield output.getvalue()
# Clear buffer for reuse
output.seek(0)
output.truncate(0)
batch_size = 1000
offset = 0
while True:
query = db.query(Product)
# Apply marketplace filters
if marketplace:
query = query.filter(Product.marketplace.ilike(f"%{marketplace}%"))
if shop_name:
query = query.filter(Product.shop_name.ilike(f"%{shop_name}%"))
products = query.offset(offset).limit(batch_size).all()
if not products:
break
for product in products:
# Create CSV row with proper escaping
row_data = [
product.product_id or "",
product.title or "",
product.description or "",
product.link or "",
product.image_link or "",
product.availability or "",
product.price or "",
product.currency or "",
product.brand or "",
product.gtin or "",
product.marketplace or "",
product.shop_name or "",
]
writer.writerow(row_data)
yield output.getvalue()
# Clear buffer for next row
output.seek(0)
output.truncate(0)
offset += batch_size
except Exception as e:
logger.error(f"Error generating CSV export: {str(e)}")
raise ValidationException("Failed to generate CSV export")
def product_exists(self, db: Session, product_id: str) -> bool:
"""Check if product exists by ID."""
try:
return (
db.query(Product).filter(Product.product_id == product_id).first()
is not None
)
except Exception as e:
logger.error(f"Error checking if product exists: {str(e)}")
return False
# Private helper methods
def _validate_product_data(self, product_data: dict) -> None:
"""Validate product data structure."""
required_fields = ['product_id', 'title']
for field in required_fields:
if field not in product_data or not product_data[field]:
raise ProductValidationException(f"{field} is required", field=field)
def _normalize_product_data(self, product_data: dict) -> dict:
"""Normalize and clean product data."""
normalized = product_data.copy()
# Trim whitespace from string fields
string_fields = ['product_id', 'title', 'description', 'brand', 'marketplace', 'shop_name']
for field in string_fields:
if field in normalized and normalized[field]:
normalized[field] = normalized[field].strip()
return normalized
# Create service instance
product_service = ProductService()