226 lines
8.4 KiB
Python
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)
|