backend apis updation
This commit is contained in:
@@ -1,12 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.api.schemas import AllProductsOut, BrandsOut, CategoriesOut, ProductListOut, ProductOut
|
||||
from app.api.schemas import (
|
||||
AllProductsOut,
|
||||
BrandCardOut,
|
||||
BrandCardsOut,
|
||||
BrandsOut,
|
||||
CategoriesOut,
|
||||
ProductListOut,
|
||||
ProductOut,
|
||||
)
|
||||
from app.services.s3_service import s3_service
|
||||
from app.services.vector_store import (
|
||||
get_brand_overview,
|
||||
list_available_brands,
|
||||
list_categories_for_brand,
|
||||
get_products_by_brand,
|
||||
@@ -106,6 +116,53 @@ def get_brands() -> BrandsOut:
|
||||
return BrandsOut(brands=list_available_brands())
|
||||
|
||||
|
||||
def _initials(name: str) -> str:
|
||||
"""Monogram for the card's image fallback - there are no logo assets."""
|
||||
words = [w for w in re.split(r"[^A-Za-z0-9]+", name) if w]
|
||||
if not words:
|
||||
return "?"
|
||||
if len(words) == 1:
|
||||
return words[0][:2].upper()
|
||||
return (words[0][0] + words[1][0]).upper()
|
||||
|
||||
|
||||
# Declared before /brands/{brand}/... so "overview" is never read as a brand
|
||||
# name. The path-segment counts differ, so this is belt-and-braces.
|
||||
@router.get("/brands/overview", response_model=BrandCardsOut)
|
||||
def get_brand_cards(
|
||||
refresh: bool = Query(False, description="Bypass the short-lived overview cache"),
|
||||
) -> BrandCardsOut:
|
||||
"""Per-brand summaries for the home page card grid.
|
||||
|
||||
Additive: GET /brands keeps returning a plain list of names, which the
|
||||
sidebar and the admin/user pages rely on.
|
||||
"""
|
||||
rows = get_brand_overview(force_refresh=refresh)
|
||||
|
||||
cards = []
|
||||
for row in rows:
|
||||
name = row["display_name"]
|
||||
image_url = _clean_url(row.get("sample_image_url"))
|
||||
if not image_url and s3_service.enabled and row.get("sample_image_id"):
|
||||
image_url = s3_service.get_product_image_url(name, row["sample_image_id"])
|
||||
|
||||
cards.append(BrandCardOut(
|
||||
name=name,
|
||||
slug=row["suffix"],
|
||||
product_count=row["product_count"],
|
||||
category_count=row["category_count"],
|
||||
categories=list(row.get("categories") or []),
|
||||
image_url=image_url,
|
||||
initials=_initials(name),
|
||||
))
|
||||
|
||||
return BrandCardsOut(
|
||||
total_brands=len(cards),
|
||||
total_products=sum(c.product_count for c in cards),
|
||||
brands=cards,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/brands/{brand}/categories", response_model=CategoriesOut)
|
||||
def get_brand_categories(brand: str) -> CategoriesOut:
|
||||
return CategoriesOut(brand=brand, categories=list_categories_for_brand(brand))
|
||||
|
||||
@@ -48,6 +48,26 @@ def _run_background_auto_seed():
|
||||
logger.error("Background Auto-Init error: %s", e)
|
||||
|
||||
|
||||
def _run_background_brand_sync() -> None:
|
||||
"""Reconcile brand tables against data/seed_catalogs/ in both directions.
|
||||
|
||||
Complements the auto-seed above rather than replacing it: that one only
|
||||
fires against a completely empty database, so without this a brand table
|
||||
created after first boot never gets a seed file, and a seed file added
|
||||
after first boot is never loaded.
|
||||
"""
|
||||
try:
|
||||
from app.services.brand_sync import reconcile_brand_catalogs
|
||||
summary = reconcile_brand_catalogs()
|
||||
logger.info("🔁 Brand catalog reconcile: %s", summary)
|
||||
except Exception as e:
|
||||
logger.error("Brand catalog reconcile error: %s", e)
|
||||
|
||||
|
||||
class BrandSyncRequest(BaseModel):
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
@router.get("/system/status", response_model=SystemStatusOut)
|
||||
def get_system_status() -> SystemStatusOut:
|
||||
"""Return unified status of database, vector store, stores, and frontend build."""
|
||||
@@ -89,3 +109,23 @@ def initialize_system(background_tasks: BackgroundTasks) -> Dict[str, Any]:
|
||||
"status": "started",
|
||||
"message": "Background initialization triggered. Check /api/system/status for progress.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/system/brand-sync", dependencies=[Depends(require_admin)])
|
||||
def sync_brand_catalogs(payload: BrandSyncRequest, background_tasks: BackgroundTasks) -> Dict[str, Any]:
|
||||
"""Reconcile brand tables with their seed catalog files.
|
||||
|
||||
`dry_run` answers inline - it is a handful of count queries and writes
|
||||
nothing, so it is safe to poke at. A real run is backgrounded because
|
||||
exporting a large brand serialises thousands of 384-float embeddings.
|
||||
"""
|
||||
from app.services.brand_sync import reconcile_brand_catalogs
|
||||
|
||||
if payload.dry_run:
|
||||
return {"status": "ok", "summary": reconcile_brand_catalogs(dry_run=True)}
|
||||
|
||||
background_tasks.add_task(_run_background_brand_sync)
|
||||
return {
|
||||
"status": "started",
|
||||
"message": "Brand catalog reconcile triggered. Check /api/system/status for progress.",
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
import pandas as pd
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -16,14 +14,13 @@ from app.services.vector_store import (
|
||||
_sanitize_name,
|
||||
get_products_by_brand,
|
||||
)
|
||||
from app.services.brand_sync import upsert_products_into_catalog_file
|
||||
from app.services.embeddings_service import embed_texts
|
||||
from app.services.s3_service import s3_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/user/products", tags=["user_products"])
|
||||
|
||||
SEED_DIR = Path(__file__).resolve().parents[3] / "data" / "seed_catalogs"
|
||||
|
||||
|
||||
class AddProductRequest(BaseModel):
|
||||
brand: str = Field(..., description="Brand name, e.g. Lion Dates")
|
||||
@@ -174,50 +171,14 @@ def _enrich_and_save_product(req: AddProductRequest) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _update_json_catalog_file(brand: str, product_dict: Dict[str, Any]) -> None:
|
||||
SEED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
"""Append/update one product in the brand's seed catalog.
|
||||
|
||||
# Determine seed file name (e.g. brand_catalog_lion_dates.json)
|
||||
brand_slug = _sanitize_name(resolve_parent_brand(brand))
|
||||
file_path = SEED_DIR / f"brand_catalog_{brand_slug}.json"
|
||||
|
||||
# Strip embedding before saving to JSON file for clean JSON size
|
||||
clean_dict = {k: v for k, v in product_dict.items() if k != "embedding"}
|
||||
|
||||
if file_path.exists():
|
||||
try:
|
||||
data = json.loads(file_path.read_text(encoding="utf-8-sig"))
|
||||
except Exception as e:
|
||||
logger.warning("Could not read existing catalog JSON %s: %s", file_path.name, e)
|
||||
data = {"brand": brand, "products": []}
|
||||
else:
|
||||
data = {
|
||||
"brand": brand.lower(),
|
||||
"search_query": f"{brand} products catalog",
|
||||
"generation_timestamp": str(Path(__file__).resolve()),
|
||||
"total_products": 0,
|
||||
"total_images": 0,
|
||||
"products": [],
|
||||
}
|
||||
|
||||
products_list = data.get("products", [])
|
||||
|
||||
# Replace existing or append new product
|
||||
updated = False
|
||||
for i, p in enumerate(products_list):
|
||||
if p.get("image_id") == clean_dict["image_id"] or p.get("product_name") == clean_dict["product_name"]:
|
||||
products_list[i] = clean_dict
|
||||
updated = True
|
||||
break
|
||||
|
||||
if not updated:
|
||||
products_list.append(clean_dict)
|
||||
|
||||
data["products"] = products_list
|
||||
data["total_products"] = len(products_list)
|
||||
data["total_images"] = sum(len(p.get("image_urls") or []) for p in products_list)
|
||||
|
||||
file_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
logger.info("✅ Updated JSON seed file '%s' (total products: %d)", file_path.name, data["total_products"])
|
||||
Delegates to brand_sync so this shares the file-resolution rules with the
|
||||
startup reconcile. That also fixes a mis-targeting bug this used to have:
|
||||
picking the file by sanitised slug wrote P&G products to a new
|
||||
brand_catalog_p_g.json instead of the real brand_catalog_p_and_g.json.
|
||||
"""
|
||||
upsert_products_into_catalog_file(brand, [product_dict])
|
||||
|
||||
|
||||
@router.post("/add", status_code=201, dependencies=[Depends(require_permission("add_product"))])
|
||||
|
||||
@@ -79,6 +79,27 @@ class BrandsOut(BaseModel):
|
||||
brands: List[str]
|
||||
|
||||
|
||||
class BrandCardOut(BaseModel):
|
||||
"""A brand as shown on the home page card grid.
|
||||
|
||||
`name` is the same string GET /api/brands returns, so the frontend can
|
||||
pass it straight back to /api/brands/{brand}/products.
|
||||
"""
|
||||
name: str
|
||||
slug: str
|
||||
product_count: int
|
||||
category_count: int
|
||||
categories: List[str] = Field(default_factory=list)
|
||||
image_url: Optional[str] = None
|
||||
initials: str
|
||||
|
||||
|
||||
class BrandCardsOut(BaseModel):
|
||||
total_brands: int
|
||||
total_products: int
|
||||
brands: List[BrandCardOut]
|
||||
|
||||
|
||||
class CategoriesOut(BaseModel):
|
||||
brand: str
|
||||
categories: List[str]
|
||||
|
||||
Reference in New Issue
Block a user