325 lines
12 KiB
Python
325 lines
12 KiB
Python
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"}
|