"""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, Optional from fastapi import APIRouter, HTTPException, Query from app.electronics.db.connection import connect from app.electronics.db.repository import product_rating_and_reviews 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() return {"total": total, "products": [_clean(r) for r in rows]} @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}/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]