99from fastapi import APIRouter , Depends , HTTPException , Query
1010from fastapi_sqlalchemy import db
1111from pydantic import BaseModel
12- from sqlalchemy import func
12+ from sqlalchemy import func , or_
1313from sqlalchemy .exc import IntegrityError
1414from sqlalchemy .orm import joinedload
1515
@@ -212,35 +212,9 @@ def catalog_categories():
212212 )
213213
214214
215- @route .get ("/catalog/browse/{slug}" )
216- def catalog_browse (slug : str ):
217- # Build slug -> subcategory name lookup from live data
218- distinct = (
219- db .session .query (CatalogProduct .subcategory )
220- .filter (
221- CatalogProduct .status == "approved" ,
222- CatalogProduct .subcategory .isnot (None ),
223- )
224- .distinct ()
225- .all ()
226- )
227- slug_map = {_slugify (r [0 ]): r [0 ] for r in distinct }
228- subcategory_name = slug_map .get (slug )
229- if not subcategory_name :
230- raise HTTPException (404 , "Subcategory not found" )
231-
232- entries = (
233- db .session .query (CatalogProduct )
234- .filter (
235- CatalogProduct .status == "approved" ,
236- CatalogProduct .subcategory == subcategory_name ,
237- )
238- .order_by (CatalogProduct .brand_name , CatalogProduct .product_name )
239- .all ()
240- )
241-
242- category_name = entries [0 ].category_suggestion if entries else None
243-
215+ def _serialize_product_groups (entries ):
216+ """Group CatalogProduct rows (sorted by brand, product) into products
217+ with nested variants. Shared by catalog browse and product search."""
244218 products = []
245219 key_fn = attrgetter ("brand_name" , "product_name" )
246220 for (brand , product ), group_iter in groupby (entries , key = key_fn ):
@@ -261,24 +235,65 @@ def catalog_browse(slug: str):
261235
262236 variants .append ({
263237 "id" : v .id ,
238+ "brand_id" : v .brand_id ,
239+ "product_id" : v .product_id ,
240+ "product_variant_id" : v .product_variant_id ,
264241 "variant_name" : v .variant_name ,
265242 "display_name" : v .display_name ,
266243 "weight" : float (v .weight ) if v .weight is not None else None ,
267244 "weight_unit" : v .weight_unit ,
268245 "image_url" : v .image_url ,
269246 "description" : v .description ,
270247 "additional_specs" : v .additional_specs ,
248+ "kcal" : v .kcal ,
271249 })
272250
251+ first = variants_raw [0 ]
273252 products .append ({
274253 "brand_name" : brand ,
275254 "product_name" : product ,
276255 "product_url" : product_url ,
277256 "catalog_url_slug" : catalog_url_slug ,
257+ "category" : first .category_suggestion ,
258+ "subcategory" : first .subcategory ,
259+ "subcategory_slug" : _slugify (first .subcategory ) if first .subcategory else None ,
278260 "lightest_weight_g" : round (lightest_g , 2 ) if lightest_g is not None else None ,
279261 "variants" : variants ,
280262 })
281263
264+ return products
265+
266+
267+ @route .get ("/catalog/browse/{slug}" )
268+ def catalog_browse (slug : str ):
269+ # Build slug -> subcategory name lookup from live data
270+ distinct = (
271+ db .session .query (CatalogProduct .subcategory )
272+ .filter (
273+ CatalogProduct .status == "approved" ,
274+ CatalogProduct .subcategory .isnot (None ),
275+ )
276+ .distinct ()
277+ .all ()
278+ )
279+ slug_map = {_slugify (r [0 ]): r [0 ] for r in distinct }
280+ subcategory_name = slug_map .get (slug )
281+ if not subcategory_name :
282+ raise HTTPException (404 , "Subcategory not found" )
283+
284+ entries = (
285+ db .session .query (CatalogProduct )
286+ .filter (
287+ CatalogProduct .status == "approved" ,
288+ CatalogProduct .subcategory == subcategory_name ,
289+ )
290+ .order_by (CatalogProduct .brand_name , CatalogProduct .product_name )
291+ .all ()
292+ )
293+
294+ category_name = entries [0 ].category_suggestion if entries else None
295+
296+ products = _serialize_product_groups (entries )
282297 products .sort (key = lambda p : (
283298 p ["lightest_weight_g" ] is None ,
284299 p ["lightest_weight_g" ] or 0 ,
@@ -293,6 +308,37 @@ def catalog_browse(slug: str):
293308 }
294309
295310
311+ MAX_GEAR_SEARCH_PRODUCTS = 50
312+
313+
314+ @route .get ("/catalog/products/search" )
315+ def catalog_product_search (q : str = "" ):
316+ """Freeform gear search across brand and product names.
317+ Returns grouped products in the same shape as catalog browse."""
318+ q = q .strip ()
319+ if len (q ) < 2 :
320+ return []
321+
322+ search = f"%{ q } %"
323+ entries = (
324+ db .session .query (CatalogProduct )
325+ .filter (
326+ CatalogProduct .status == "approved" ,
327+ or_ (
328+ CatalogProduct .brand_name .ilike (search ),
329+ CatalogProduct .product_name .ilike (search ),
330+ (CatalogProduct .brand_name + " " +
331+ CatalogProduct .product_name ).ilike (search ),
332+ ),
333+ )
334+ .order_by (CatalogProduct .brand_name , CatalogProduct .product_name )
335+ .limit (500 )
336+ .all ()
337+ )
338+
339+ return _serialize_product_groups (entries )[:MAX_GEAR_SEARCH_PRODUCTS ]
340+
341+
296342@route .get ("/brand/search/{query}" )
297343def search_brands (query : str , user : User = Depends (authenticate )):
298344 search = "%{}%" .format (query .strip ())
0 commit comments