Files
loyaly-catalogue/backend/app/api/routers/elec.py
2026-10-07 11:03:42 +05:30

287 lines
12 KiB
Python

"""Read-only catalogue API: category -> brand -> product -> per-platform offers.
Only VERIFIED products are served (see repository.refresh_verification), and
every price is returned with the site, URL, source type and time it was seen.
Money is returned as a decimal string, never a float.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any, Dict, List, Literal, Optional
from fastapi import APIRouter, HTTPException, Query
from app.electronics.db.connection import connect
from app.electronics import recommend as rec
from app.electronics.db.repository import (
other_variants,
product_rating_and_reviews,
rated_products,
rating_sources_for,
review_sentiment_counts,
similar_products,
)
from app.electronics.reviews import select_reviews
router = APIRouter(prefix="/elec", tags=["electronics"])
def _money(value: Optional[Decimal]) -> Optional[str]:
return None if value is None else format(value, "f")
def _clean(row: Dict[str, Any]) -> Dict[str, Any]:
out = {}
for k, v in row.items():
if isinstance(v, Decimal):
out[k] = _money(v) if k in ("price", "mrp", "best_price", "min_price", "max_price") else float(v)
elif hasattr(v, "isoformat"):
out[k] = v.isoformat()
else:
out[k] = v
return out
@router.get("/categories")
def categories() -> List[dict]:
with connect() as conn:
rows = conn.execute(
"SELECT c.slug, c.name, coalesce(sum(s.product_count), 0)::int AS product_count "
"FROM elec.category c LEFT JOIN elec.v_brand_summary s ON s.category = c.slug "
"GROUP BY c.slug, c.name ORDER BY c.name"
).fetchall()
return [_clean(r) for r in rows]
@router.get("/brands")
def brands(category: str = Query(...)) -> List[dict]:
with connect() as conn:
rows = conn.execute(
"""
SELECT b.name AS brand, b.slug AS brand_slug,
coalesce(s.product_count, 0)::int AS product_count, s.min_price, s.max_price,
(SELECT image_url FROM elec.v_brand_catalog v
WHERE v.brand_slug = b.slug AND v.category = %(c)s AND v.image_url IS NOT NULL
ORDER BY v.platform_count DESC LIMIT 1) AS sample_image
FROM elec.brand b
JOIN elec.brand_category bc ON bc.brand_id = b.id
JOIN elec.category c ON c.id = bc.category_id AND c.slug = %(c)s
LEFT JOIN elec.v_brand_summary s ON s.brand_slug = b.slug AND s.category = %(c)s
ORDER BY coalesce(s.product_count, 0) DESC, b.name
""",
{"c": category},
).fetchall()
return [_clean(r) for r in rows]
@router.get("/products")
def products(
category: Optional[str] = None,
brand: Optional[str] = None,
q: Optional[str] = Query(None, max_length=100),
min_price: Optional[Decimal] = None,
max_price: Optional[Decimal] = None,
in_stock: bool = False,
site: Optional[str] = None,
tn_only: bool = False,
limit: int = Query(48, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> dict:
where, params = ["TRUE"], {}
if category:
where.append("v.category = %(category)s"); params["category"] = category
if brand:
where.append("v.brand_slug = %(brand)s"); params["brand"] = brand
if q:
where.append("(v.display_name ILIKE %(q)s OR v.brand ILIKE %(q)s)"); params["q"] = f"%{q}%"
if min_price is not None:
where.append("v.best_price >= %(min_price)s"); params["min_price"] = min_price
if max_price is not None:
where.append("v.best_price <= %(max_price)s"); params["max_price"] = max_price
if in_stock:
where.append("EXISTS (SELECT 1 FROM elec.v_product_availability a WHERE a.product_id = v.product_id AND a.in_stock)")
if site:
where.append("EXISTS (SELECT 1 FROM elec.v_product_availability a WHERE a.product_id = v.product_id AND a.domain = %(site)s)")
params["site"] = site
if tn_only:
where.append("v.sold_by_tn_retailer")
sql_where = " AND ".join(where)
with connect() as conn:
total = conn.execute(f"SELECT count(*) AS n FROM elec.v_brand_catalog v WHERE {sql_where}", params).fetchone()["n"]
rows = conn.execute(
f"SELECT v.* FROM elec.v_brand_catalog v WHERE {sql_where} "
f"ORDER BY v.platform_count DESC, v.best_price NULLS LAST, v.display_name "
f"LIMIT %(limit)s OFFSET %(offset)s",
{**params, "limit": limit, "offset": offset},
).fetchall()
sources = rating_sources_for(conn, [r["product_id"] for r in rows])
out = []
for r in rows:
rating = _overall_rating(sources[r["product_id"]])
out.append({**_clean(r), "rating": rating["value"] if rating else None,
"rating_count": rating["count"] if rating else None})
return {"total": total, "products": out}
@router.get("/products/{product_id}")
def product(product_id: int) -> dict:
with connect() as conn:
row = conn.execute("SELECT * FROM elec.v_brand_catalog WHERE product_id = %s", (product_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Product not found or not verified")
specs = conn.execute("SELECT spec_sources FROM elec.product WHERE id = %s", (product_id,)).fetchone()
offers = conn.execute(
"SELECT * FROM elec.v_product_availability WHERE product_id = %s "
"ORDER BY (price IS NULL), (source_type = 'search_snippet'), price, site",
(product_id,),
).fetchall()
images = conn.execute(
"SELECT i.url, i.source_type, s.name AS site, l.source_url AS found_on "
"FROM elec.product_image i JOIN elec.source_listing l ON l.id = i.source_listing_id "
"JOIN elec.site s ON s.id = l.site_id WHERE i.product_id = %s ORDER BY i.rank, i.id",
(product_id,),
).fetchall()
rated = product_rating_and_reviews(conn, product_id)
result = _clean(row)
result["spec_sources"] = specs["spec_sources"] if specs else {}
result["offers"] = [_clean(o) for o in offers]
result["images"] = [dict(i) for i in images]
result["rating"] = _overall_rating(rated["sources"])
overall = result["rating"]["value"] if result["rating"] else None
result["reviews"] = [_clean(r) for r in select_reviews(overall, rated["reviews"])]
return result
def _overall_rating(sources: List[dict]) -> Optional[dict]:
"""The product's rating across the platforms that state one: the mean
weighted by each platform's rating count (a platform that states no count
weighs as 1). None when no platform states a rating - never a guess.
One reading per platform: a site with a page per colour repeats the same
model rating on each, and adding those up would multiply its count."""
if not sources:
return None
weight = lambda s: max(int(s["review_count"] or 0), 1) # noqa: E731
per_site: Dict[str, dict] = {}
for s in sources:
if s["site"] not in per_site or weight(s) > weight(per_site[s["site"]]):
per_site[s["site"]] = s
sources = list(per_site.values())
total = sum(weight(s) for s in sources)
value = sum(Decimal(s["rating"]) * weight(s) for s in sources) / total
counts = [s["review_count"] for s in sources if s["review_count"]]
return {
"value": round(float(value), 1),
"count": sum(counts) if counts else None,
"breakdown": _breakdown(sources),
"sources": [
{"site": s["site"], "rating": float(s["rating"]), "review_count": s["review_count"],
"breakdown": s.get("rating_breakdown"), "source_url": s["source_url"]}
for s in sources
],
}
def _breakdown(sources: List[dict]) -> Optional[List[dict]]:
"""5→1 star counts summed over the platforms that PUBLISH a breakdown.
None when none does - never estimated from the few review texts held."""
totals = {str(n): 0 for n in range(1, 6)}
stated = False
for s in sources:
for star, n in (s.get("rating_breakdown") or {}).items():
if star in totals and isinstance(n, int) and n > 0:
totals[star] += n
stated = True
if not stated:
return None
total = sum(totals.values())
return [{"stars": int(k), "count": totals[k], "percent": round(100 * totals[k] / total)}
for k in ("5", "4", "3", "2", "1")]
@router.get("/products/{product_id}/recommendations")
def recommendations(
product_id: int,
kind: Literal["similar", "better_rated"] = Query("similar", alias="type"),
limit: int = Query(rec.MAX_ITEMS, ge=1, le=12),
tn_only: bool = False,
) -> dict:
"""Products to suggest under this one's ratings and reviews (see
app/electronics/recommend.py), plus the same model's other variants.
type=similar: closest specs; type=better_rated: rated higher, similar price."""
with connect() as conn:
target = conn.execute(
"SELECT product_id, best_price FROM elec.v_brand_catalog WHERE product_id = %s", (product_id,)
).fetchone()
if not target:
raise HTTPException(status_code=404, detail="Product not found or not verified")
if kind == "similar":
similar = similar_products(conn, product_id, band=rec.SIMILAR_PRICE_BAND, tn_only=tn_only)
rated = (rated_products(conn, product_id, band=rec.SIMILAR_PRICE_BAND, tn_only=tn_only)
if len(similar) < rec.MIN_MATCHES else [])
else:
similar = []
rated = rated_products(conn, product_id, band=rec.BETTER_PRICE_BAND, tn_only=tn_only)
ids = {product_id} | {c["product_id"] for c in similar + rated}
ratings = {pid: _overall_rating(src) for pid, src in rating_sources_for(conn, list(ids)).items()}
def with_rating(c: dict) -> dict:
r = ratings.get(c["product_id"])
return {**c, "best_price": _float(c.get("best_price")),
"rating": r["value"] if r else None, "rating_count": r["count"] if r else None}
if kind == "similar":
picked = rec.recommend(with_rating(dict(target)), [with_rating(c) for c in similar],
[with_rating(c) for c in rated], limit)
else:
sentiment = review_sentiment_counts(conn, [c["product_id"] for c in rated])
picked = rec.better_rated(
with_rating(dict(target)),
[{**with_rating(c), "sentiment": sentiment.get(c["product_id"])} for c in rated], limit)
cards = {r["product_id"]: r for r in conn.execute(
"SELECT product_id, brand, display_name, ram_gb, storage_gb, image_url, best_price, best_price_site "
"FROM elec.v_brand_catalog WHERE product_id = ANY(%s)", ([c["product_id"] for c in picked],)
)}
variants = other_variants(conn, product_id)
items = [
{**_clean(cards[c["product_id"]]), "rating": c["rating"], "rating_count": c["rating_count"],
"basis": c["basis"], "reason": c["reason"]}
for c in picked if c["product_id"] in cards
]
return {"product_id": product_id, "type": kind, "items": items,
"other_variants": [_clean(v) for v in variants]}
def _float(value: Any) -> Optional[float]:
return None if value is None else float(value)
@router.get("/products/{product_id}/price-history")
def price_history(product_id: int) -> List[dict]:
with connect() as conn:
rows = conn.execute(
"""
SELECT s.name AS site, h.price, h.mrp, h.in_stock, h.source_type, h.observed_at
FROM elec.price_history h
JOIN elec.product_listing_map m ON m.listing_id = h.listing_id AND m.review_status IN ('auto','approved')
JOIN elec.source_listing l ON l.id = h.listing_id
JOIN elec.site s ON s.id = l.site_id
WHERE m.product_id = %s AND h.price IS NOT NULL
ORDER BY h.observed_at
""",
(product_id,),
).fetchall()
return [_clean(r) for r in rows]
@router.get("/sites")
def sites() -> List[dict]:
with connect() as conn:
rows = conn.execute(
"SELECT s.name, s.domain, s.kind, s.region, s.policy, s.probe_outcome, s.probed_at, "
"s.breaker_until, s.breaker_reason, s.probe_evidence->>'reason' AS probe_reason, "
"(SELECT count(*) FROM elec.source_listing l WHERE l.site_id = s.id)::int AS listings "
"FROM elec.site s ORDER BY (s.kind = 'brand_official'), s.name"
).fetchall()
return [_clean(r) for r in rows]