from typing import Any from sqlalchemy import and_, asc, desc, func, or_ from sqlalchemy.orm import Session from app.models.category import Category from app.models.product import Product, ProductStatus from app.models.review import Review from app.models.tag import ProductTag class SearchService: """ Service for advanced search and filtering of products. """ @staticmethod def search_products( db: Session, search_query: str | None = None, category_id: str | None = None, tag_ids: list[str] | None = None, min_price: float | None = None, max_price: float | None = None, min_rating: int | None = None, status: ProductStatus | None = ProductStatus.PUBLISHED, sort_by: str = "relevance", sort_order: str = "desc", is_featured: bool | None = None, seller_id: str | None = None, offset: int = 0, limit: int = 100 ) -> dict[str, Any]: """ Search and filter products with advanced options. Args: db: Database session search_query: Text to search in product name and description category_id: Filter by category ID tag_ids: Filter by tag IDs (products must have ALL specified tags) min_price: Minimum price filter max_price: Maximum price filter min_rating: Minimum average rating filter status: Filter by product status sort_by: Field to sort by (name, price, created_at, rating, relevance) sort_order: Sort order (asc or desc) is_featured: Filter by featured status seller_id: Filter by seller ID offset: Pagination offset limit: Pagination limit Returns: Dict with products and total count """ # Start with base query query = db.query(Product).outerjoin(Review, Product.id == Review.product_id) # Apply filters filters = [] # Status filter if status: filters.append(Product.status == status) # Search query filter if search_query: search_term = f"%{search_query}%" filters.append( or_( Product.name.ilike(search_term), Product.description.ilike(search_term), Product.sku.ilike(search_term) ) ) # Category filter if category_id: # Get all subcategory IDs recursively subcategory_ids = [category_id] def get_subcategories(parent_id): subcategories = db.query(Category).filter(Category.parent_id == parent_id).all() for subcategory in subcategories: subcategory_ids.append(subcategory.id) get_subcategories(subcategory.id) get_subcategories(category_id) filters.append(Product.category_id.in_(subcategory_ids)) # Tag filters if tag_ids: # This creates a subquery where products must have ALL the specified tags for tag_id in tag_ids: # For each tag, we add a separate exists condition to ensure all tags are present tag_subquery = db.query(ProductTag).filter( ProductTag.product_id == Product.id, ProductTag.tag_id == tag_id ).exists() filters.append(tag_subquery) # Price filters if min_price is not None: filters.append(Product.price >= min_price) if max_price is not None: filters.append(Product.price <= max_price) # Featured filter if is_featured is not None: filters.append(Product.is_featured == is_featured) # Seller filter if seller_id: filters.append(Product.seller_id == seller_id) # Apply all filters if filters: query = query.filter(and_(*filters)) # Rating filter (applied separately as it's an aggregation) if min_rating is not None: # Group by product ID and filter by average rating query = query.group_by(Product.id).having( func.avg(Review.rating) >= min_rating ) # Count total results before pagination total_count = query.count() # Apply sorting if sort_by == "name": order_func = asc if sort_order == "asc" else desc query = query.order_by(order_func(Product.name)) elif sort_by == "price": order_func = asc if sort_order == "asc" else desc query = query.order_by(order_func(Product.price)) elif sort_by == "created_at": order_func = asc if sort_order == "asc" else desc query = query.order_by(order_func(Product.created_at)) elif sort_by == "rating": order_func = asc if sort_order == "asc" else desc query = query.group_by(Product.id).order_by(order_func(func.avg(Review.rating))) else: # Default to relevance (works best with search_query) # For relevance, if there's a search query, we first sort by search match quality if search_query: # This prioritizes exact name matches, then description matches search_term = f"%{search_query}%" # Custom ordering function for relevance relevance_score = ( # Exact match in name (highest priority) func.case( [(Product.name.ilike(search_query), 100)], else_=0 ) + # Name starts with the query func.case( [(Product.name.ilike(f"{search_query}%"), 50)], else_=0 ) + # Name contains the query func.case( [(Product.name.ilike(search_term), 25)], else_=0 ) + # Description contains the query func.case( [(Product.description.ilike(search_term), 10)], else_=0 ) ) order_func = desc if sort_order == "desc" else asc query = query.order_by(order_func(relevance_score), desc(Product.is_featured)) else: # If no search query, sort by featured status and most recent query = query.order_by(desc(Product.is_featured), desc(Product.created_at)) # Apply pagination query = query.offset(offset).limit(limit) # Execute query products = query.all() # Prepare response data return { "total": total_count, "products": products, "offset": offset, "limit": limit, }