"""Products router. GET /api/products (filters: status, category, search, limit, offset) GET /api/products/{id} POST /api/products/{id}/publish POST /api/products/{id}/pause GET /api/products/{id}/price-history POST /api/products/import (body: {supplier_id, feed_type, data_or_url}) NOTE: /import is declared before /{id} so it isn't captured by the path param. """ from decimal import Decimal from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import or_ from sqlalchemy.orm import Session from app.database import get_db from app.engines.importer import import_feed from app.models import AuditLog, PriceHistory, Product, Supplier from app.schemas import ImportRequest, PriceHistoryOut, ProductOut router = APIRouter() PUBLISHABLE_FROM = {"imported", "price_calculated", "content_generated", "quality_check", "paused"} def _get_product_or_404(db, product_id): product = db.query(Product).filter(Product.id == product_id).first() if product is None: raise HTTPException(status_code=404, detail="product not found") return product @router.get("", response_model=List[ProductOut]) def list_products( status: Optional[str] = None, category: Optional[str] = None, search: Optional[str] = None, limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0), db: Session = Depends(get_db), ): q = db.query(Product) if status: q = q.filter(Product.status == status) if category: q = q.filter(Product.category == category) if search: like = f"%{search}%" q = q.filter(or_(Product.title.ilike(like), Product.sku.ilike(like))) return q.order_by(Product.created_at.desc()).offset(offset).limit(limit).all() @router.post("/import") def import_products(body: ImportRequest, db: Session = Depends(get_db)): supplier = db.query(Supplier).filter(Supplier.id == body.supplier_id).first() if supplier is None: raise HTTPException(status_code=404, detail="supplier not found") try: summary = import_feed(db, supplier, feed_type=body.feed_type, data_or_url=body.data_or_url) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) db.add( AuditLog( actor="api", action="import_requested", entity="supplier", entity_id=str(supplier.id), detail={"feed_type": body.feed_type, "received": summary["received"]}, ) ) db.commit() return summary @router.get("/{product_id}", response_model=ProductOut) def get_product(product_id, db: Session = Depends(get_db)): return _get_product_or_404(db, product_id) @router.post("/{product_id}/publish", response_model=ProductOut) def publish_product(product_id, db: Session = Depends(get_db)): product = _get_product_or_404(db, product_id) if product.status not in PUBLISHABLE_FROM: raise HTTPException(status_code=400, detail=f"cannot publish product in status '{product.status}'") if product.retail_price is None or Decimal(str(product.retail_price)) <= 0: raise HTTPException(status_code=400, detail="product has no calculated retail price") product.status = "published" db.add( AuditLog( actor="api", action="product_published", entity="product", entity_id=str(product.id), detail={"sku": product.sku, "retail_price": float(product.retail_price)}, ) ) db.commit() db.refresh(product) return product @router.post("/{product_id}/pause", response_model=ProductOut) def pause_product(product_id, db: Session = Depends(get_db)): product = _get_product_or_404(db, product_id) product.status = "paused" db.add( AuditLog( actor="api", action="product_paused", entity="product", entity_id=str(product.id), detail={"sku": product.sku}, ) ) db.commit() db.refresh(product) return product @router.get("/{product_id}/price-history", response_model=List[PriceHistoryOut]) def price_history(product_id, db: Session = Depends(get_db)): _get_product_or_404(db, product_id) rows = ( db.query(PriceHistory) .filter(PriceHistory.product_id == product_id) .order_by(PriceHistory.created_at.desc()) .all() ) return rows