216 lines
9.1 KiB
Python
216 lines
9.1 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, 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]
|