195 lines
6.9 KiB
Python

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,
}