""" @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)