from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status from app.core.deps import require_org, require_client_roles, require_write_access, Principal from app.models import Brand, Product, Plan, AuditLog from app.models.documents import ManufacturerInfo, ProductDetails from app.models.enums import ClientRole, ProductStatus from app.schemas.common import ProductCreate router = APIRouter(prefix="/products", tags=["products"]) def _out(p: Product, full: bool = False) -> dict: base = { "id": str(p.id), "name": p.name, "sku": p.sku, "category": p.category, "brand_id": p.brand_id, "brand_name": p.brand_name, "image_url": p.image_url, "status": p.status, "trust_score": p.trust_score, "recalled": p.recalled, "batch_count": p.batch_count, "qr_count": p.qr_count, "scan_count": p.scan_count, "created_at": p.created_at,"qr_status": "Generated" if p.qr_count > 0 else "Not Generated", } if full: base.update({ "description": p.description, "manufacturer": p.manufacturer.model_dump(), "details": p.details.model_dump(), "compliance_badges": [c.model_dump() for c in p.compliance_badges], "recycling_info": p.recycling_info, "internal_notes": p.internal_notes, "qr_type": p.qr_type, "display_brand_logo": p.display_brand_logo, }) return base from datetime import datetime from typing import Optional from fastapi import Depends @router.get("") async def list_products( p: Principal = Depends(require_org), # Search search: Optional[str] = None, # Filters brand_id: Optional[str] = None, category: Optional[str] = None, status_filter: Optional[str] = None, qr_status: Optional[str] = None, trust_from: Optional[int] = None, trust_to: Optional[int] = None, created_from: Optional[datetime] = None, created_to: Optional[datetime] = None, # Pagination page: int = 1, page_size: int = 20, ): products = await Product.find( Product.org_id == str(p.org.id) ).to_list() # Hide archived by default if status_filter: products = [p for p in products if p.status == status_filter] else: products = [ p for p in products if p.status != ProductStatus.archived ] # Search if search: s = search.lower() products = [ p for p in products if ( s in (p.name or "").lower() or s in (p.sku or "").lower() or s in (p.brand_name or "").lower() or s in str(p.trust_score) ) ] # Brand if brand_id: products = [ p for p in products if p.brand_id == brand_id ] # Category if category: products = [ p for p in products if p.category == category ] # QR Status if qr_status: if qr_status == "generated": products = [ p for p in products if p.qr_count > 0 ] elif qr_status == "not_generated": products = [ p for p in products if p.qr_count == 0 ] # Trust Score if trust_from is not None: products = [ p for p in products if p.trust_score >= trust_from ] if trust_to is not None: products = [ p for p in products if p.trust_score <= trust_to ] # Created Date if created_from: products = [ p for p in products if p.created_at >= created_from ] if created_to: products = [ p for p in products if p.created_at <= created_to ] products.sort( key=lambda p: p.created_at, reverse=True, # newest first ) total = len(products) start = (page - 1) * page_size end = start + page_size products = products[start:end] plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None return { "items": [_out(x) for x in products], "total": total, "page": page, "page_size": page_size, "pages": (total + page_size - 1) // page_size, "count": p.org.product_count, "limit": plan.product_limit if plan else None, "limit_reached": bool( plan and plan.product_limit is not None and p.org.product_count >= plan.product_limit ), } @router.post("", status_code=201) async def create_product(body: ProductCreate, p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))): require_write_access(p) # ----- plan product-limit enforcement (the only enforced limit) ----- plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None if plan and plan.product_limit is not None and p.org.product_count >= plan.product_limit: raise HTTPException( status.HTTP_403_FORBIDDEN, detail={"error": "product_limit_reached", "message": f"You've reached your plan limit of {plan.product_limit} products. Upgrade to add more.", "limit": plan.product_limit, "plan": plan.name, "upgrade": True}, ) if not body.brand_id: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": "Please select a brand before saving."}) brand = await Brand.get(body.brand_id) if not brand or brand.org_id != str(p.org.id): raise HTTPException(status.HTTP_404_NOT_FOUND, "Brand not found") product = Product( org_id=str(p.org.id), brand_id=body.brand_id, brand_name=brand.name, name=body.name, sku=body.sku, category=body.category, description=body.description, image_url=body.image_url, manufacturer=body.manufacturer or ManufacturerInfo(), details=body.details or ProductDetails(), compliance_badges=body.compliance_badges, recycling_info=body.recycling_info, internal_notes=body.internal_notes, qr_type=body.qr_type, display_brand_logo=body.display_brand_logo, status=body.status, ) await product.insert() p.org.product_count += 1 await p.org.save() brand.product_count += 1 await brand.save() await AuditLog(org_id=str(p.org.id), user_id=str(p.user.id), user_name=p.user.name, user_role=p.client_role, action="product.create", module="products", details=f"Created product {product.name}").insert() return _out(product) @router.get("/{product_id}") async def get_product(product_id: str, p: Principal = Depends(require_org)): product = await Product.get(product_id) if not product or product.org_id != str(p.org.id): raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found") return _out(product, full=True) @router.put("/{product_id}") async def update_product(product_id: str, body: dict, p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))): product = await Product.get(product_id) if not product or product.org_id != str(p.org.id): raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found") for k in ("name", "sku", "category", "description", "image_url", "recycling_info", "internal_notes", "qr_type", "display_brand_logo", "status", "trust_score", "recalled"): if k in body: setattr(product, k, body[k]) if "brand_id" in body and body["brand_id"]: brand = await Brand.get(body["brand_id"]) if brand and brand.org_id == str(p.org.id): product.brand_id = body["brand_id"] product.brand_name = brand.name if "manufacturer" in body and body["manufacturer"]: product.manufacturer = ManufacturerInfo(**body["manufacturer"]) if "details" in body and body["details"]: product.details = ProductDetails(**body["details"]) await product.save() await AuditLog( org_id=str(p.org.id), user_id=str(p.user.id), user_name=p.user.name, user_role=p.client_role, action="product.update", module="products", details=f"Updated product {product.name}", ).insert() return _out(product, full=True) @router.post("/{product_id}/duplicate", status_code=201) async def duplicate_product(product_id: str, p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))): """Clone a product as a new draft (counts against the plan limit).""" require_write_access(p) src = await Product.get(product_id) if not src or src.org_id != str(p.org.id): raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found") plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None if plan and plan.product_limit is not None and p.org.product_count >= plan.product_limit: raise HTTPException(status.HTTP_403_FORBIDDEN, detail={"error": "product_limit_reached", "message": f"You've reached your plan limit of {plan.product_limit} products. Upgrade to add more.", "limit": plan.product_limit, "plan": plan.name, "upgrade": True}) data = src.model_dump(exclude={"id", "revision_id"}) data.update({"name": f"{src.name} (Copy)", "sku": (src.sku or "") + "-COPY", "status": ProductStatus.draft, "batch_count": 0, "qr_count": 0, "scan_count": 0}) dup = Product(**data) await dup.insert() p.org.product_count += 1 await p.org.save() await AuditLog(org_id=str(p.org.id), user_id=str(p.user.id), user_name=p.user.name, action="product.duplicate", module="products", details=f"Duplicated {src.name}").insert() return _out(dup, full=True) @router.put("/{product_id}/archive") async def archive_product(product_id: str, p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))): """Soft delete — status=archived. Existing QRs keep working; hidden from default list.""" product = await Product.get(product_id) if not product or product.org_id != str(p.org.id): raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found") product.status = ProductStatus.archived await product.save() p.org.product_count = max(0, p.org.product_count - 1) await p.org.save() await AuditLog(org_id=str(p.org.id), user_id=str(p.user.id), user_name=p.user.name, action="product.archive", module="products", details=f"Archived {product.name}").insert() return {"status": "archived"} @router.put("/{product_id}/restore") async def restore_product(product_id: str, p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))): product = await Product.get(product_id) if not product or product.org_id != str(p.org.id): raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found") plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None if plan and plan.product_limit is not None and p.org.product_count >= plan.product_limit: raise HTTPException(status.HTTP_403_FORBIDDEN, detail={"error": "product_limit_reached", "message": "Restoring exceeds your plan limit. Upgrade first.", "upgrade": True}) product.status = ProductStatus.active await product.save() p.org.product_count += 1 await p.org.save() await AuditLog( org_id=str(p.org.id), user_id=str(p.user.id), user_name=p.user.name, user_role=p.client_role, action="product.restore", module="products", details=f"Restored product {product.name}", ).insert() return {"status": "active"}