Skip to content

Commit 81b79b1

Browse files
committed
Add catalog category and browse endpoints
- Introduced new API routes for catalog categories and browsing by subcategory. - Implemented slugification for subcategory names and weight conversion to grams. - Enhanced product listing with sorting by lightest weight and included product details in the response. - Utilized defaultdict for grouping categories and improved error handling for missing subcategories.
1 parent e066762 commit 81b79b1

1 file changed

Lines changed: 130 additions & 0 deletions

File tree

app/api/resources.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import csv
22
import logging
3+
import re
4+
from collections import defaultdict
5+
from itertools import groupby
6+
from operator import attrgetter
37
from typing import Optional
48

59
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -12,6 +16,7 @@
1216
from models.base import Brand, CatalogProduct, Product, User, Category, ProductVariant
1317
from utils.auth import authenticate
1418
from utils.consts import DEVELOPMENT
19+
from utils.weight import convert_weight
1520
from seed.categories import default_categories
1621

1722
logger = logging.getLogger(__name__)
@@ -158,6 +163,131 @@ def catalog_search(
158163
} for r in rows]
159164

160165

166+
def _slugify(name: str) -> str:
167+
s = name.lower()
168+
s = re.sub(r'[&/]', '', s)
169+
s = re.sub(r'[^a-z0-9]+', '-', s)
170+
return s.strip('-')
171+
172+
173+
def _weight_to_grams(weight, weight_unit) -> float | None:
174+
if weight is None or weight_unit is None:
175+
return None
176+
try:
177+
return convert_weight(weight, weight_unit, "g")
178+
except Exception:
179+
return None
180+
181+
182+
@route.get("/catalog/categories")
183+
def catalog_categories():
184+
rows = (
185+
db.session.query(
186+
CatalogProduct.category_suggestion,
187+
CatalogProduct.subcategory,
188+
func.count(CatalogProduct.id).label("cnt"),
189+
)
190+
.filter(
191+
CatalogProduct.status == "approved",
192+
CatalogProduct.subcategory.isnot(None),
193+
CatalogProduct.category_suggestion.isnot(None),
194+
)
195+
.group_by(CatalogProduct.category_suggestion, CatalogProduct.subcategory)
196+
.order_by(CatalogProduct.category_suggestion, CatalogProduct.subcategory)
197+
.all()
198+
)
199+
200+
grouped: dict[str, list] = defaultdict(list)
201+
for cat, sub, cnt in rows:
202+
grouped[cat].append({
203+
"name": sub,
204+
"slug": _slugify(sub),
205+
"product_count": cnt,
206+
})
207+
208+
return [
209+
{"category": cat, "subcategories": subs}
210+
for cat, subs in sorted(grouped.items())
211+
]
212+
213+
214+
@route.get("/catalog/browse/{slug}")
215+
def catalog_browse(slug: str):
216+
# Build slug -> subcategory name lookup from live data
217+
distinct = (
218+
db.session.query(CatalogProduct.subcategory)
219+
.filter(
220+
CatalogProduct.status == "approved",
221+
CatalogProduct.subcategory.isnot(None),
222+
)
223+
.distinct()
224+
.all()
225+
)
226+
slug_map = {_slugify(r[0]): r[0] for r in distinct}
227+
subcategory_name = slug_map.get(slug)
228+
if not subcategory_name:
229+
raise HTTPException(404, "Subcategory not found")
230+
231+
entries = (
232+
db.session.query(CatalogProduct)
233+
.filter(
234+
CatalogProduct.status == "approved",
235+
CatalogProduct.subcategory == subcategory_name,
236+
)
237+
.order_by(CatalogProduct.brand_name, CatalogProduct.product_name)
238+
.all()
239+
)
240+
241+
category_name = entries[0].category_suggestion if entries else None
242+
243+
products = []
244+
key_fn = attrgetter("brand_name", "product_name")
245+
for (brand, product), group_iter in groupby(entries, key=key_fn):
246+
variants_raw = list(group_iter)
247+
variants = []
248+
lightest_g: float | None = None
249+
product_url: str | None = None
250+
251+
for v in variants_raw:
252+
w_g = _weight_to_grams(v.weight, v.weight_unit)
253+
if w_g is not None and (lightest_g is None or w_g < lightest_g):
254+
lightest_g = w_g
255+
if not product_url and v.product_url:
256+
product_url = v.product_url
257+
258+
variants.append({
259+
"id": v.id,
260+
"variant_name": v.variant_name,
261+
"display_name": v.display_name,
262+
"weight": float(v.weight) if v.weight is not None else None,
263+
"weight_unit": v.weight_unit,
264+
"image_url": v.image_url,
265+
"description": v.description,
266+
"additional_specs": v.additional_specs,
267+
})
268+
269+
products.append({
270+
"brand_name": brand,
271+
"product_name": product,
272+
"product_url": product_url,
273+
"lightest_weight_g": round(lightest_g, 2) if lightest_g is not None else None,
274+
"variants": variants,
275+
})
276+
277+
products.sort(key=lambda p: (
278+
p["lightest_weight_g"] is None,
279+
p["lightest_weight_g"] or 0,
280+
))
281+
282+
return {
283+
"subcategory": subcategory_name,
284+
"category": category_name,
285+
"slug": slug,
286+
"product_count": len(products),
287+
"products": products,
288+
}
289+
290+
161291
@route.get("/brand/search/{query}")
162292
def search_brands(query: str, user: User = Depends(authenticate)):
163293
search = "%{}%".format(query.strip())

0 commit comments

Comments
 (0)