"""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]