ifixkart-backend/Backend/app/api/v1/routers/storefront.py

226 lines
8.4 KiB
Python

"""
@router Public Storefront Router (Backend/app/api/v1/routers/storefront.py)
@purpose Read-only public endpoints for layout widgets, reviews, and settings powered by StorefrontService.
"""
from fastapi import APIRouter, Depends, Query, HTTPException, status
from sqlalchemy.orm import Session, selectinload
from sqlalchemy import select
from typing import List, Dict, Any, Optional
from app.core.database.db_session import get_db
from app.services.StorefrontService import StorefrontService
from app.services.CatalogSearchService import apply_product_search
from app.models.ProductModel import Product, ProductVariant
from app.models.CategoryModel import Category
from app.models.BrandModel import Brand
from app.models.DeviceCatalogModel import ServiceType
from app.schemas.Catalog import ProductResponse
router = APIRouter(prefix="/api/v1/storefront", tags=["Public Storefront Dynamic Services"])
@router.get("/live-search")
def live_search(
q: str = Query(..., min_length=2, max_length=200),
category: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""
Autocomplete search for the storefront header. Caps results so 500k catalogs stay cheap.
"""
from app.core.database.cache_manager import cache
cache_key = f"storefront:live-search:{q.strip().lower()}:{category or 'all'}"
cached = cache.get(cache_key)
if cached is not None:
return cached
stmt = (
select(Product)
.options(
selectinload(Product.images),
selectinload(Product.variants).selectinload(ProductVariant.images),
)
.where(Product.status == "active")
)
if category and category != "all":
stmt = stmt.join(Category, Product.category_id == Category.category_id).where(
(Category.slug == category) | (Category.category_id == category)
)
stmt = apply_product_search(stmt, q).order_by(Product.created_at.desc()).limit(8)
products = db.execute(stmt).scalars().all()
term = f"%{q.strip()}%"
categories = db.execute(
select(Category).where(Category.is_active == True, Category.name.ilike(term)).limit(5)
).scalars().all()
brands = db.execute(
select(Brand).where(Brand.is_active == True, Brand.name.ilike(term)).limit(5)
).scalars().all()
services = db.execute(
select(ServiceType).where(ServiceType.is_active == True, ServiceType.name.ilike(term)).limit(5)
).scalars().all()
payload = {
"products": [ProductResponse.model_validate(p).model_dump(mode="json") for p in products],
"categories": [
{
"category_id": c.category_id,
"name": c.name,
"slug": c.slug,
"parent_category_id": c.parent_category_id,
"description": c.description,
"image_url": c.image_url,
"is_active": c.is_active,
}
for c in categories
],
"brands": [
{
"brand_id": b.brand_id,
"name": b.name,
"slug": b.slug,
"logo_url": b.logo_url,
"is_active": b.is_active,
}
for b in brands
],
"services": [
{
"service_type_id": s.service_type_id if hasattr(s, "service_type_id") else getattr(s, "type_id", None),
"name": s.name,
"slug": getattr(s, "slug", None),
}
for s in services
],
}
cache.set(cache_key, payload, ttl_seconds=15)
return payload
@router.get("/layout/{page}")
def get_storefront_layout(page: str, region: Optional[str] = Query(None), db: Session = Depends(get_db)):
"""
Returns active storefront layout widgets ordered by display_order.
"""
service = StorefrontService(db)
return service.get_layout(page, region)
@router.get("/reviews/{product_id}")
def get_product_reviews(product_id: str, db: Session = Depends(get_db)):
"""
Returns approved customer reviews for a given product.
"""
service = StorefrontService(db)
return service.get_reviews(product_id)
@router.get("/settings/public")
def get_public_settings(group: Optional[str] = Query(None), db: Session = Depends(get_db)):
"""
Returns public key-value store settings.
"""
service = StorefrontService(db)
return service.get_public_settings(group)
@router.get("/catalog/categories/{category_id}/parent-hierarchy")
def get_category_parent_hierarchy(category_id: str, db: Session = Depends(get_db)):
"""
Returns the product-derived Brand → Series → Model hierarchy for a category.
Only includes brands/series/models that have active products in this category.
"""
from sqlalchemy import select, distinct
from app.models.CategoryModel import Category
from app.models.ProductModel import Product
from app.models.BrandModel import Brand
from app.models.DeviceCatalogModel import DeviceSeries, DeviceModel
from app.schemas.Catalog import (
CategoryParentHierarchyResponse, HierarchyBrandItem,
HierarchySeriesItem, HierarchyModelItem,
)
# Verify category exists
category = db.execute(
select(Category).where(Category.category_id == category_id)
).scalar_one_or_none()
if not category:
raise HTTPException(status_code=404, detail="Category not found")
# Fetch all active products in this category that have brand + series + model set
rows = db.execute(
select(
Brand.brand_id, Brand.name, Brand.slug, Brand.logo_url,
DeviceSeries.series_id, DeviceSeries.name.label("series_name"), DeviceSeries.slug.label("series_slug"),
DeviceModel.model_id, DeviceModel.name.label("model_name"), DeviceModel.slug.label("model_slug"), DeviceModel.image_url.label("model_image")
)
.select_from(Product)
.join(Brand, Product.brand_id == Brand.brand_id)
.outerjoin(DeviceModel, Product.device_model_id == DeviceModel.model_id)
.outerjoin(DeviceSeries, DeviceModel.series_id == DeviceSeries.series_id)
.where(
Product.category_id == category_id,
Product.status == "active",
Brand.is_active == True,
(DeviceSeries.series_id == None) | (DeviceSeries.is_active == True),
(DeviceModel.model_id == None) | (DeviceModel.is_active == True),
)
.distinct()
).all()
# Build nested hierarchy in memory
brands_map: dict = {}
for row in rows:
bid = row.brand_id
if bid not in brands_map:
brands_map[bid] = {
"brand_id": bid,
"name": row.name,
"slug": row.slug,
"logo_url": row.logo_url,
"series": {},
}
sid = row.series_id
series_name = row.series_name
series_slug = row.series_slug
# If a model exists but series is missing, group it under a virtual "General Models" series
if not sid and row.model_id:
sid = f"no-series-{bid}"
series_name = "General Models"
series_slug = "general"
if sid:
if sid not in brands_map[bid]["series"]:
brands_map[bid]["series"][sid] = {
"series_id": sid,
"name": series_name,
"slug": series_slug,
"models": {},
}
mid = row.model_id
if mid:
if mid not in brands_map[bid]["series"][sid]["models"]:
brands_map[bid]["series"][sid]["models"][mid] = {
"model_id": mid,
"name": row.model_name,
"slug": row.model_slug,
"image_url": row.model_image,
}
# Convert maps to sorted lists
brands_list = []
for b in brands_map.values():
series_list = []
for s in b["series"].values():
models_list = list(s["models"].values())
series_list.append(HierarchySeriesItem(
series_id=s["series_id"], name=s["name"], slug=s["slug"],
models=[HierarchyModelItem(**m) for m in models_list]
))
brands_list.append(HierarchyBrandItem(
brand_id=b["brand_id"], name=b["name"], slug=b["slug"], logo_url=b["logo_url"],
series=series_list
))
return CategoryParentHierarchyResponse(category_id=category_id, brands=brands_list)