195 lines
6.9 KiB
Python
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,
|
|
}
|