Initial project upload

This commit is contained in:
Mohamed Mathar Irfan
2026-07-28 17:57:02 +05:30
commit ed6610d5d8
23919 changed files with 3003316 additions and 0 deletions

View File

@@ -0,0 +1,324 @@
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"}