ifixkart-backend/Backend/app/api/v1/routers/CatalogRouter.py

1783 lines
74 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, status, Query, BackgroundTasks
from sqlalchemy.orm import Session, selectinload
from sqlalchemy import select, func
from typing import List, Dict, Any, Optional
import ulid
import os
import re
from decimal import Decimal
from datetime import datetime
from app.core.database.db_session import get_db
from app.core.permissions.RoleChecker import RoleChecker
from app.models.UserModel import User
from app.models.BrandModel import Brand
from app.models.CategoryModel import Category
from app.models.TagModel import Tag
from app.models.CollectionModel import Collection
from app.models.SeoMetadataModel import SeoMetadata
from app.models.PartsModel import Part, PartDeviceCompatibility, RepairVariantPart
from app.models.StockMovementModel import StockMovement
from app.models.PurchaseOrderModel import PurchaseOrder, PurchaseOrderItem
from app.models.DeviceCatalogModel import DeviceSeries, DeviceModel, ServiceType, RepairService, RepairVariant, RepairVariantImage
from app.models.ProductModel import AttributeType, Product, ProductImage, ProductVariant, VariantAttribute, VariantImage
from app.services.InventoryService import apply_stock_target, get_available_stock_map
from app.services.CatalogSearchService import apply_product_search
from app.schemas.Catalog import (
BrandCreate, BrandUpdate, BrandResponse,
CategoryCreate, CategoryUpdate, CategoryResponse,
CategoryParentHierarchyResponse, HierarchyBrandItem, HierarchySeriesItem, HierarchyModelItem,
TagCreate, TagResponse,
CollectionCreate, CollectionResponse,
SeoMetadataCreate, SeoMetadataResponse,
PartCreate, PartUpdate, PartResponse,
PartDeviceCompatibilityCreate, PartDeviceCompatibilityResponse,
StockMovementCreate, StockMovementResponse,
PurchaseOrderCreate, PurchaseOrderUpdate, PurchaseOrderResponse,
DeviceSeriesCreate, DeviceSeriesUpdate, DeviceSeriesResponse,
DeviceModelCreate, DeviceModelUpdate, DeviceModelResponse,
ServiceTypeCreate, ServiceTypeUpdate, ServiceTypeResponse,
RepairServiceCreate, RepairServiceUpdate, RepairServiceResponse,
RepairVariantCreate, RepairVariantUpdate, RepairVariantResponse,
AttributeTypeCreate, AttributeTypeUpdate, AttributeTypeResponse,
ProductCreate, ProductResponse, ProductCardResponse, ProductPaginatedResponse
)
router = APIRouter(prefix="/api/v1/catalog", tags=["Catalog & Inventory Management"])
# --- Helper ---
def slugify(text: str) -> str:
text = text.lower().strip()
text = re.sub(r'[^\w\s-]', '', text)
text = re.sub(r'[\s_-]+', '-', text)
return text
def _variant_ids_from_serialized(products: List[Dict[str, Any]]) -> List[str]:
ids: List[str] = []
for product in products:
for variant in product.get("variants") or []:
variant_id = variant.get("variant_id")
if variant_id:
ids.append(variant_id)
return ids
def attach_available_stock(products: List[Dict[str, Any]], db: Session) -> List[Dict[str, Any]]:
stock_map = get_available_stock_map(_variant_ids_from_serialized(products), db)
for product in products:
for variant in product.get("variants") or []:
variant["available_stock"] = stock_map.get(variant.get("variant_id"), 0)
return products
def product_response_with_stock(product: Product, db: Session) -> ProductResponse:
payload = ProductResponse.model_validate(product)
stock_map = get_available_stock_map([v.variant_id for v in payload.variants], db)
for variant in payload.variants:
variant.available_stock = stock_map.get(variant.variant_id, 0)
return payload
def _reload_product(product_id: str, db: Session) -> Product:
return db.execute(
select(Product)
.options(
selectinload(Product.variants).selectinload(ProductVariant.attributes),
selectinload(Product.variants).selectinload(ProductVariant.images),
selectinload(Product.images),
)
.where(Product.product_id == product_id)
).scalar_one()
# ================= BRAND ENDPOINTS =================
@router.get("/brands/all", response_model=List[BrandResponse])
def get_all_brands(
category_id: Optional[str] = Query(None),
device_type: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
if device_type:
from app.models.BrandDeviceTypeModel import BrandDeviceType
stmt = select(Brand).join(BrandDeviceType, Brand.brand_id == BrandDeviceType.brand_id).where(
Brand.is_active == True,
BrandDeviceType.device_type == device_type
)
return db.execute(stmt).scalars().all()
if category_id:
from app.models.ProductModel import Product
stmt = select(Brand).where(
Brand.is_active == True,
Brand.brand_id.in_(
select(Product.brand_id)
.where(Product.category_id == category_id, Product.status == 'active')
)
)
return db.execute(stmt).scalars().all()
return db.execute(select(Brand)).scalars().all()
@router.post("/brands/create", response_model=BrandResponse, status_code=status.HTTP_201_CREATED)
def create_brand(data: BrandCreate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
slug = slugify(data.name)
existing = db.execute(select(Brand).where(Brand.slug == slug)).scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Brand already exists")
brand_id = str(ulid.ULID())
brand = Brand(
brand_id=brand_id,
name=data.name,
slug=slug,
logo_url=data.logo_url,
is_active=True
)
db.add(brand)
if data.device_types:
from app.models.BrandDeviceTypeModel import BrandDeviceType
for dt in data.device_types:
db.add(BrandDeviceType(brand_id=brand_id, device_type=dt))
db.commit()
db.refresh(brand)
return brand
# ================= CATEGORY ENDPOINTS =================
@router.get("/categories/all", response_model=List[CategoryResponse])
def get_all_categories(has_products: bool = Query(False), db: Session = Depends(get_db)):
if has_products:
from sqlalchemy import exists
from app.models.ProductModel import Product
stmt = select(Category).where(
Category.is_active == True,
exists().where(
(Product.category_id == Category.category_id) & (Product.status == 'active')
)
)
return db.execute(stmt).scalars().all()
return db.execute(select(Category).where(Category.is_active == True).order_by(Category.sort_order)).scalars().all()
@router.post("/categories/create", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
def create_category(data: CategoryCreate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
slug = slugify(data.name)
existing = db.execute(select(Category).where(Category.slug == slug)).scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Category already exists")
category = Category(
category_id=str(ulid.ULID()),
parent_category_id=data.parent_category_id,
name=data.name,
slug=slug,
description=data.description,
image_url=data.image_url,
sort_order=data.sort_order,
is_parent_feature=data.is_parent_feature,
is_active=True
)
db.add(category)
db.commit()
db.refresh(category)
return category
# ================= PARTS & INVENTORY ENDPOINTS =================
@router.get("/parts/all", response_model=List[PartResponse])
def get_all_parts(db: Session = Depends(get_db)):
parts = db.execute(select(Part)).scalars().all()
response = []
for p in parts:
# Compute stock from Stock Ledger (sum of movements quantity)
stock_sum = db.execute(
select(func.sum(StockMovement.quantity))
.where(StockMovement.entity_type == "part", StockMovement.entity_id == p.part_id)
).scalar() or 0
response.append(PartResponse(
part_id=p.part_id,
sku=p.sku,
name=p.name,
cost_price=p.cost_price,
low_stock_alert=p.low_stock_alert,
supplier=p.supplier,
barcode=p.barcode,
is_active=p.is_active,
stock=int(stock_sum)
))
return response
@router.post("/parts/create", response_model=PartResponse, status_code=status.HTTP_201_CREATED)
def create_part(data: PartCreate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
existing = db.execute(select(Part).where(Part.sku == data.sku)).scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Part with this SKU already exists")
part = Part(
part_id=str(ulid.ULID()),
sku=data.sku,
name=data.name,
cost_price=data.cost_price,
low_stock_alert=data.low_stock_alert,
supplier=data.supplier,
barcode=data.barcode,
is_active=True
)
db.add(part)
db.commit()
db.refresh(part)
# Return computed stock as 0 initially
return PartResponse(
part_id=part.part_id,
sku=part.sku,
name=part.name,
cost_price=part.cost_price,
low_stock_alert=part.low_stock_alert,
supplier=part.supplier,
barcode=part.barcode,
is_active=part.is_active,
stock=0
)
# ================= DEVICE CATALOG ENDPOINTS =================
@router.post("/device-series/create", response_model=DeviceSeriesResponse, status_code=status.HTTP_201_CREATED)
def create_device_series(data: DeviceSeriesCreate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
slug = slugify(data.name)
existing = db.execute(select(DeviceSeries).where(DeviceSeries.slug == slug)).scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Device series already exists")
brand = db.execute(select(Brand).where(Brand.brand_id == data.brand_id)).scalar_one_or_none()
if not brand:
raise HTTPException(status_code=404, detail="Brand not found")
device_type = data.device_type
supported_types = brand.device_types
if len(supported_types) > 1:
if not device_type:
raise HTTPException(
status_code=400,
detail=f"This brand supports multiple device options {supported_types}. Please specify a device_type."
)
if device_type not in supported_types:
raise HTTPException(
status_code=400,
detail=f"Device type '{device_type}' is not supported by brand '{brand.name}'. Valid options: {supported_types}."
)
elif len(supported_types) == 1:
device_type = supported_types[0]
series = DeviceSeries(
series_id=str(ulid.ULID()),
brand_id=data.brand_id,
name=data.name,
slug=slug,
device_type=device_type,
sort_order=data.sort_order,
is_active=True
)
db.add(series)
db.commit()
db.refresh(series)
return series
@router.get("/device-types")
def get_device_types():
return ["mobile", "laptop", "tablet", "smartwatch"]
@router.get("/device-series/all", response_model=List[DeviceSeriesResponse])
def get_all_device_series(
brand_id: Optional[str] = None,
device_type: Optional[str] = None,
db: Session = Depends(get_db)
):
stmt = select(DeviceSeries)
if brand_id:
stmt = stmt.where(DeviceSeries.brand_id == brand_id)
if device_type:
stmt = stmt.where(DeviceSeries.device_type == device_type)
return db.execute(stmt).scalars().all()
@router.get("/device-models/all", response_model=List[DeviceModelResponse])
def get_all_device_models(
series_id: Optional[str] = None,
brand_id: Optional[str] = None,
db: Session = Depends(get_db)
):
stmt = select(DeviceModel)
if series_id:
stmt = stmt.where(DeviceModel.series_id == series_id)
if brand_id:
stmt = stmt.where(DeviceModel.brand_id == brand_id)
return db.execute(stmt).scalars().all()
@router.get("/service/repair-config/{model_id}")
def get_repair_config_by_model(model_id: str, db: Session = Depends(get_db)):
model = db.execute(select(DeviceModel).where(DeviceModel.model_id == model_id)).scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="Device model not found")
stmt = select(RepairService).where(RepairService.model_id == model_id)
services = db.execute(stmt).scalars().all()
categories = []
for s in services:
st = db.execute(select(ServiceType).where(ServiceType.service_type_id == s.service_type_id)).scalar_one_or_none()
variants = db.execute(select(RepairVariant).where(RepairVariant.repair_service_id == s.repair_service_id, RepairVariant.status == "active")).scalars().all()
categories.append({
"repair_service_id": s.repair_service_id,
"service_type_id": s.service_type_id,
"category_name": st.name if st else "Repair Service",
"slug": s.slug,
"description": s.description,
"variants": [
{
"variant_id": v.variant_id,
"name": v.name,
"price": float(v.price),
"duration_minutes": v.duration_minutes,
"warranty_days": v.warranty_days
} for v in variants
]
})
return {
"model_id": model.model_id,
"model_name": model.name,
"brand_id": model.brand_id,
"series_id": model.series_id,
"device_type": model.device_type,
"categories": categories
}
@router.post("/device-models/create", response_model=DeviceModelResponse, status_code=status.HTTP_201_CREATED)
def create_device_model(data: DeviceModelCreate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
slug = slugify(data.name)
existing = db.execute(select(DeviceModel).where(DeviceModel.slug == slug)).scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Device model already exists")
# Fetch parent series and brand to build full_path
series = None
if data.series_id:
series = db.execute(select(DeviceSeries).where(DeviceSeries.series_id == data.series_id)).scalar_one_or_none()
brand = db.execute(select(Brand).where(Brand.brand_id == data.brand_id)).scalar_one()
if series:
full_path = f"/repair/{brand.slug}/{series.slug}/{slug}"
else:
full_path = f"/repair/{brand.slug}/{slug}"
device_type = data.device_type or (series.device_type if series else None)
if not device_type:
supported_types = brand.device_types
if len(supported_types) > 1:
if not device_type:
raise HTTPException(
status_code=400,
detail=f"This brand supports multiple device options {supported_types}. Please specify a device_type."
)
if device_type not in supported_types:
raise HTTPException(
status_code=400,
detail=f"Device type '{device_type}' is not supported by brand '{brand.name}'. Valid options: {supported_types}."
)
elif len(supported_types) == 1:
device_type = supported_types[0]
model = DeviceModel(
model_id=str(ulid.ULID()),
series_id=data.series_id,
brand_id=data.brand_id,
name=data.name,
slug=slug,
device_type=device_type,
full_path=full_path,
release_year=data.release_year,
image_url=data.image_url,
is_active=True
)
db.add(model)
db.commit()
db.refresh(model)
return model
@router.get("/device-models/all", response_model=List[DeviceModelResponse])
def get_all_device_models(db: Session = Depends(get_db)):
return db.execute(select(DeviceModel)).scalars().all()
# ================= SERVICE TYPES & REPAIR SERVICES =================
@router.post("/service-types/create", response_model=ServiceTypeResponse, status_code=status.HTTP_201_CREATED)
def create_service_type(data: ServiceTypeCreate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
slug = slugify(data.name)
existing = db.execute(select(ServiceType).where(ServiceType.slug == slug)).scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Service type already exists")
service_type = ServiceType(
service_type_id=str(ulid.ULID()),
name=data.name,
slug=slug,
icon_url=data.icon_url,
description=data.description,
is_active=True
)
db.add(service_type)
db.commit()
db.refresh(service_type)
return service_type
@router.get("/service-types/all", response_model=List[ServiceTypeResponse])
def get_all_service_types(db: Session = Depends(get_db)):
return db.execute(select(ServiceType)).scalars().all()
@router.post("/repair-services/create", response_model=RepairServiceResponse, status_code=status.HTTP_201_CREATED)
def create_repair_service(data: RepairServiceCreate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
existing = db.execute(select(RepairService).where(
RepairService.model_id == data.model_id,
RepairService.service_type_id == data.service_type_id
)).scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Repair service already mapped to this device model")
# Fetch parents to build full_path
model = db.execute(select(DeviceModel).where(DeviceModel.model_id == data.model_id)).scalar_one()
service_type = db.execute(select(ServiceType).where(ServiceType.service_type_id == data.service_type_id)).scalar_one()
full_path = f"{model.full_path}/{service_type.slug}"
repair_service = RepairService(
repair_service_id=str(ulid.ULID()),
model_id=data.model_id,
service_type_id=data.service_type_id,
slug=service_type.slug,
full_path=full_path,
description=data.description
)
db.add(repair_service)
db.commit()
db.refresh(repair_service)
return repair_service
@router.get("/repair-services/all", response_model=List[RepairServiceResponse])
def get_all_repair_services(db: Session = Depends(get_db)):
return db.execute(select(RepairService)).scalars().all()
# ================= REPAIR VARIANTS & BOM =================
@router.post("/repair-variants/create", response_model=RepairVariantResponse, status_code=status.HTTP_201_CREATED)
def create_repair_variant(data: RepairVariantCreate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
variant_id = str(ulid.ULID())
variant = RepairVariant(
variant_id=variant_id,
repair_service_id=data.repair_service_id,
name=data.name,
price=data.price,
cost=data.cost,
duration_minutes=data.duration_minutes,
warranty_days=data.warranty_days,
status="active"
)
db.add(variant)
# Add Bill of Materials (BOM) parts
if data.parts:
for p in data.parts:
# Verify part exists
part = db.execute(select(Part).where(Part.part_id == p.part_id)).scalar_one_or_none()
if not part:
raise HTTPException(status_code=400, detail=f"Part with ID {p.part_id} does not exist")
bom_item = RepairVariantPart(
id=str(ulid.ULID()),
variant_id=variant_id,
part_id=p.part_id,
quantity=p.quantity
)
db.add(bom_item)
db.commit()
db.refresh(variant)
return variant
@router.get("/repair-variants/all", response_model=List[RepairVariantResponse])
def get_all_repair_variants(db: Session = Depends(get_db)):
return db.execute(select(RepairVariant)).scalars().all()
# ================= STOCK LEDGER & MANUAL ADJUSTMENTS =================
@router.get("/stock-movements/history", response_model=List[StockMovementResponse])
def get_stock_movements_history(db: Session = Depends(get_db)):
return db.execute(select(StockMovement).order_by(StockMovement.created_at.desc())).scalars().all()
@router.post("/stock-movements/adjust", response_model=StockMovementResponse, status_code=status.HTTP_201_CREATED)
def create_manual_stock_adjustment(
data: StockMovementCreate,
db: Session = Depends(get_db),
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))
):
if data.movement_type not in ["Adjustment", "Damage"]:
raise HTTPException(status_code=400, detail="Only 'Adjustment' and 'Damage' movement types are allowed for manual adjustments")
movement = StockMovement(
movement_id=str(ulid.ULID()),
entity_type=data.entity_type,
entity_id=data.entity_id,
movement_type=data.movement_type,
quantity=data.quantity,
reference_type=data.reference_type,
reference_id=data.reference_id
)
db.add(movement)
db.commit()
db.refresh(movement)
return movement
# ================= PURCHASE ORDERS =================
@router.post("/purchase-orders/create", response_model=PurchaseOrderResponse, status_code=status.HTTP_201_CREATED)
def create_purchase_order(data: PurchaseOrderCreate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
po_id = str(ulid.ULID())
# Generate sequential PO number
total_pos = db.execute(select(func.count(PurchaseOrder.purchase_order_id))).scalar() or 0
po_number = f"PO-{datetime.utcnow().year}-{total_pos + 1:04d}"
total_amount = Decimal(0.0)
po = PurchaseOrder(
purchase_order_id=po_id,
po_number=po_number,
supplier_name=data.supplier_name,
status="Draft",
total_amount=0.0
)
db.add(po)
for item in data.items:
item_id = str(ulid.ULID())
po_item = PurchaseOrderItem(
id=item_id,
purchase_order_id=po_id,
part_id=item.part_id,
quantity_ordered=item.quantity_ordered,
quantity_received=0,
unit_price=item.unit_price
)
db.add(po_item)
total_amount += item.unit_price * item.quantity_ordered
po.total_amount = total_amount
db.commit()
db.refresh(po)
return po
@router.get("/purchase-orders/all", response_model=List[PurchaseOrderResponse])
def get_all_purchase_orders(db: Session = Depends(get_db)):
return db.execute(select(PurchaseOrder)).scalars().all()
@router.post("/purchase-orders/{po_id}/receive", response_model=PurchaseOrderResponse)
def receive_purchase_order_items(
po_id: str,
data: PurchaseOrderUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))
):
po = db.execute(select(PurchaseOrder).where(PurchaseOrder.purchase_order_id == po_id)).scalar_one_or_none()
if not po:
raise HTTPException(status_code=404, detail="Purchase Order not found")
if po.status == "Received":
raise HTTPException(status_code=400, detail="Purchase Order is already fully received")
if data.items_received:
for receive_data in data.items_received:
po_item = db.execute(select(PurchaseOrderItem).where(
PurchaseOrderItem.id == receive_data["id"],
PurchaseOrderItem.purchase_order_id == po_id
)).scalar_one_or_none()
if not po_item:
raise HTTPException(status_code=400, detail=f"PO Item {receive_data['id']} does not exist in this purchase order")
qty_rec = receive_data["quantity_received"]
if qty_rec <= 0:
continue
po_item.quantity_received += qty_rec
# Write to Stock Ledger (Purchase movement)
movement = StockMovement(
movement_id=str(ulid.ULID()),
entity_type="part",
entity_id=po_item.part_id,
movement_type="Purchase",
quantity=qty_rec,
reference_type="PurchaseOrder",
reference_id=po_id
)
db.add(movement)
po.status = "Received"
db.commit()
db.refresh(po)
return po
# ================= ATTRIBUTE TYPE ENDPOINTS =================
@router.get("/attributes/all", response_model=List[AttributeTypeResponse])
def get_all_attributes(db: Session = Depends(get_db)):
return db.execute(select(AttributeType)).scalars().all()
@router.post("/attributes/create", response_model=AttributeTypeResponse, status_code=status.HTTP_201_CREATED)
def create_attribute_type(data: AttributeTypeCreate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
existing = db.execute(select(AttributeType).where(AttributeType.code == data.code)).scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Attribute code already exists")
attr = AttributeType(
attribute_id=str(ulid.ULID()),
name=data.name,
code=data.code,
status="active",
preset_values=data.preset_values or []
)
db.add(attr)
db.commit()
db.refresh(attr)
return attr
# ================= PRODUCT ENDPOINTS =================
@router.get("/products/all")
def get_all_products(
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=200),
search: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""
Admin catalog page. Paginated so 500k+ SKUs never load into memory at once.
Product fields may be cached; available_stock is always computed for the current page.
"""
from app.core.database.cache_manager import cache
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
import copy
cache_key = f"catalog:products:admin:page={page}:limit={limit}:search={search}"
cached_data = cache.get(cache_key)
if cached_data is None:
stmt = (
select(Product)
.options(
selectinload(Product.variants).selectinload(ProductVariant.attributes),
selectinload(Product.variants).selectinload(ProductVariant.images),
selectinload(Product.images),
)
)
stmt = apply_product_search(stmt, search)
count_stmt = select(func.count()).select_from(
apply_product_search(select(Product.product_id), search).subquery()
)
total_count = db.execute(count_stmt).scalar() or 0
offset = (page - 1) * limit
products = db.execute(stmt.order_by(Product.created_at.desc()).offset(offset).limit(limit)).scalars().all()
cached_data = {
"total": total_count,
"page": page,
"limit": limit,
"products": jsonable_encoder(products),
}
cache.set(cache_key, cached_data, ttl_seconds=60)
payload = {
"total": cached_data["total"],
"page": cached_data["page"],
"limit": cached_data["limit"],
"products": attach_available_stock(copy.deepcopy(cached_data["products"]), db),
}
return JSONResponse(content=payload)
@router.get("/products", response_model=ProductPaginatedResponse)
def get_products(
page: int = Query(1, ge=1),
limit: int = Query(24, ge=1, le=100),
search: Optional[str] = Query(None),
category: Optional[str] = Query(None),
brand: Optional[str] = Query(None),
device: Optional[str] = Query(None),
series: Optional[str] = Query(None),
model: Optional[str] = Query(None),
price_min: Optional[float] = Query(None),
price_max: Optional[float] = Query(None),
sort: Optional[str] = Query(None),
availability: Optional[bool] = Query(None),
rating: Optional[float] = Query(None),
discount: Optional[float] = Query(None),
cursor: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
import base64
from app.core.database.cache_manager import cache
# Generate Cache Key based on all parameters (caching for 5 min / 300s)
cache_key = f"catalog:products:page={page}:limit={limit}:search={search}:category={category}:brand={brand}:device={device}:series={series}:model={model}:pmin={price_min}:pmax={price_max}:sort={sort}:avail={availability}:rat={rating}:disc={discount}:cursor={cursor}"
cached_data = cache.get(cache_key)
if cached_data:
return cached_data
# Base Query with Eager Loading (eliminates N+1 query bottleneck)
stmt = select(Product).options(
selectinload(Product.variants).selectinload(ProductVariant.images),
selectinload(Product.images)
).where(Product.status == "active")
# Dynamic Filtering
if search:
stmt = apply_product_search(stmt, search)
if category:
stmt = stmt.join(Category).where((Category.category_id == category) | (Category.slug == category))
if brand:
stmt = stmt.join(Brand, Product.brand_id == Brand.brand_id).where((Brand.brand_id == brand) | (Brand.slug == brand))
if model:
stmt = stmt.join(DeviceModel, Product.device_model_id == DeviceModel.model_id).where((DeviceModel.model_id == model) | (DeviceModel.slug == model))
if series:
stmt = stmt.join(DeviceSeries, Product.device_series_id == DeviceSeries.series_id).where((DeviceSeries.series_id == series) | (DeviceSeries.slug == series))
# Join variants for price & availability filtering
variant_filters = []
if price_min is not None:
variant_filters.append(ProductVariant.price >= price_min)
if price_max is not None:
variant_filters.append(ProductVariant.price <= price_max)
if availability is not None:
variant_filters.append(ProductVariant.status == "active")
if variant_filters:
subq = select(ProductVariant.product_id).where(*variant_filters)
stmt = stmt.where(Product.product_id.in_(subq))
# Sorting logic
if sort == "price-low":
min_price_subq = select(func.min(ProductVariant.price)).where(ProductVariant.product_id == Product.product_id).scalar_subquery()
stmt = stmt.order_by(min_price_subq.asc())
elif sort == "price-high":
min_price_subq = select(func.min(ProductVariant.price)).where(ProductVariant.product_id == Product.product_id).scalar_subquery()
stmt = stmt.order_by(min_price_subq.desc())
elif sort == "a-z":
stmt = stmt.order_by(Product.name.asc())
elif sort == "z-a":
stmt = stmt.order_by(Product.name.desc())
elif sort == "newest":
stmt = stmt.order_by(Product.created_at.desc())
else:
stmt = stmt.order_by(Product.created_at.desc())
# Count Total Matches
total_stmt = select(func.count()).select_from(stmt.subquery())
total_count = db.execute(total_stmt).scalar() or 0
# Cursor pagination offset calculation
offset = (page - 1) * limit
if cursor:
try:
# Decode cursor (Base64 encoded offset integer)
decoded = base64.b64decode(cursor.encode("utf-8")).decode("utf-8")
offset = int(decoded)
except Exception:
pass
stmt = stmt.offset(offset).limit(limit)
products_list = db.execute(stmt).scalars().all()
page_variant_ids = []
for p in products_list:
page_variant_ids.extend([v.variant_id for v in (p.variants or [])])
stock_map = get_available_stock_map(page_variant_ids, db)
# Map to lightweight DTO schema
mapped_products = []
for p in products_list:
first_var = p.variants[0] if p.variants else None
price = first_var.price if first_var else Decimal("0.0")
compare_price = first_var.compare_price if first_var else None
# Calculate discount
discount_percent = 0
if price and compare_price and compare_price > price:
discount_percent = int(((compare_price - price) / compare_price) * 100)
thumbnail_url = None
if p.images:
banner_img = next((img.image_url for img in p.images if img.is_banner), p.images[0].image_url)
thumbnail_url = banner_img
elif first_var and first_var.images:
thumbnail_url = first_var.images[0].image_url
mapped_products.append(ProductCardResponse(
product_id=p.product_id,
category_id=p.category_id,
slug=p.slug,
name=p.name,
thumbnail_url=thumbnail_url,
price=price,
compare_price=compare_price,
discount_percent=discount_percent,
rating=p.rating if hasattr(p, "rating") and p.rating is not None else 4.8,
stock_count=stock_map.get(first_var.variant_id, 0) if first_var else 0,
first_variant_id=first_var.variant_id if first_var else None,
badge=p.badge if hasattr(p, "badge") else None,
brand_name=p.brand.name if p.brand else None
))
# Generate next cursor if there are more products
next_cursor = None
if offset + limit < total_count:
next_offset = offset + limit
next_cursor = base64.b64encode(str(next_offset).encode("utf-8")).decode("utf-8")
response_payload = ProductPaginatedResponse(
total=total_count,
page=page,
limit=limit,
cursor=next_cursor,
products=mapped_products
)
cache.set(cache_key, response_payload.model_dump(), ttl_seconds=30)
return response_payload
@router.get("/products/detail/{slug}", response_model=Dict[str, Any])
def get_product_detail_by_slug(slug: str, db: Session = Depends(get_db)):
from app.core.database.cache_manager import cache
# Cache key (cached for 5 min / 300s)
cache_key = f"catalog:product_detail:{slug}"
cached_data = cache.get(cache_key)
if cached_data:
return cached_data
product = db.execute(
select(Product)
.options(
selectinload(Product.variants).selectinload(ProductVariant.attributes),
selectinload(Product.variants).selectinload(ProductVariant.images),
selectinload(Product.images),
selectinload(Product.brand),
selectinload(Product.device_series),
selectinload(Product.device_model),
)
.where(Product.slug == slug)
).scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
product_serialized = product_response_with_stock(product, db).model_dump(mode="json")
from app.models.ProductReviewModel import ProductReview
reviews = db.execute(select(ProductReview).where(ProductReview.product_id == product.product_id, ProductReview.is_approved == True)).scalars().all()
reviews_serialized = [
{
"review_id": r.review_id,
"author_name": r.author_name,
"rating": r.rating,
"verified_buyer": r.verified_purchase,
"review_date": r.created_at.strftime("%Y-%m-%d") if r.created_at else None,
"title": r.title,
"comment": r.comment
}
for r in reviews
]
related = db.execute(
select(Product)
.where(Product.category_id == product.category_id, Product.product_id != product.product_id)
.limit(4)
).scalars().all()
related_serialized = [product_response_with_stock(p, db).model_dump(mode="json") for p in related]
compatibles = []
if product.device_model:
compatibles.append({
"model_id": product.device_model.model_id,
"name": product.device_model.name,
"slug": product.device_model.slug
})
response_payload = {
"product": product_serialized,
"reviews": reviews_serialized,
"related_products": related_serialized,
"compatible_devices": compatibles,
"specifications": [
{"label": "Manufacturer", "value": product.brand.name if product.brand else "Generic"},
{"label": "Series", "value": product.device_series.name if product.device_series else "N/A"},
{"label": "Model Reference", "value": product.device_model.name if product.device_model else "N/A"}
]
}
cache.set(cache_key, response_payload, ttl_seconds=30)
return response_payload
@router.post("/products/create", response_model=ProductResponse, status_code=status.HTTP_201_CREATED)
def create_product(data: ProductCreate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
category = db.execute(select(Category).where(Category.category_id == data.category_id)).scalar_one_or_none()
if not category:
raise HTTPException(status_code=404, detail="Category not found")
slug = slugify(data.name)
existing = db.execute(select(Product).where(Product.slug == slug)).scalar_one_or_none()
if existing:
slug = f"{slug}-{str(ulid.ULID())[:8].lower()}"
path_segments = [category.slug]
if data.brand_id:
brand = db.execute(select(Brand).where(Brand.brand_id == data.brand_id)).scalar_one_or_none()
if brand:
path_segments.append(brand.slug)
path_segments.append(slug)
full_path = "/" + "/".join(path_segments)
device_type = data.device_type
if data.device_model_id:
model = db.execute(select(DeviceModel).where(DeviceModel.model_id == data.device_model_id)).scalar_one_or_none()
if model and model.device_type:
device_type = model.device_type
if not device_type and data.device_series_id:
series = db.execute(select(DeviceSeries).where(DeviceSeries.series_id == data.device_series_id)).scalar_one_or_none()
if series and series.device_type:
device_type = series.device_type
if not device_type and data.brand_id:
brand = db.execute(select(Brand).where(Brand.brand_id == data.brand_id)).scalar_one_or_none()
if brand:
supported_types = brand.device_types
if len(supported_types) > 1:
if not device_type:
raise HTTPException(
status_code=400,
detail=f"This brand supports multiple device options {supported_types}. Please specify a device_type."
)
if device_type not in supported_types:
raise HTTPException(
status_code=400,
detail=f"Device type '{device_type}' is not supported by brand '{brand.name}'. Valid options: {supported_types}."
)
elif len(supported_types) == 1:
device_type = supported_types[0]
product_id = str(ulid.ULID())
product = Product(
product_id=product_id,
category_id=data.category_id,
brand_id=data.brand_id,
device_series_id=data.device_series_id,
device_model_id=data.device_model_id,
device_type=device_type,
name=data.name,
slug=slug,
full_path=full_path,
description=data.description,
status="active"
)
db.add(product)
for img in data.images:
product_img = ProductImage(
image_id=str(ulid.ULID()),
product_id=product_id,
image_url=img.image_url,
alt_text=img.alt_text,
sort_order=img.sort_order,
is_banner=img.is_banner
)
db.add(product_img)
for var in data.variants:
existing_sku = db.execute(select(ProductVariant).where(ProductVariant.sku == var.sku)).scalar_one_or_none()
if existing_sku:
raise HTTPException(status_code=400, detail=f"Variant SKU '{var.sku}' already exists")
variant_id = str(ulid.ULID())
product_var = ProductVariant(
variant_id=variant_id,
product_id=product_id,
sku=var.sku,
barcode=var.barcode,
price=var.price,
compare_price=var.compare_price,
cost_price=var.cost_price,
low_stock_threshold=var.low_stock_threshold,
status="active"
)
db.add(product_var)
for attr in var.attributes:
attr_type = db.execute(select(AttributeType).where(AttributeType.attribute_id == attr.attribute_id)).scalar_one_or_none()
if not attr_type:
raise HTTPException(status_code=404, detail=f"Attribute Type '{attr.attribute_id}' not found")
var_attr = VariantAttribute(
id=str(ulid.ULID()),
variant_id=variant_id,
attribute_id=attr.attribute_id,
attribute_value=attr.attribute_value
)
db.add(var_attr)
if var.images:
for img in var.images:
var_img = VariantImage(
image_id=str(ulid.ULID()),
variant_id=variant_id,
image_url=img.image_url,
sort_order=img.sort_order,
is_primary=img.is_primary
)
db.add(var_img)
db.flush()
opening_stock = var.initial_stock if var.initial_stock is not None else 0
if opening_stock > 0:
apply_stock_target(
variant_id,
opening_stock,
db,
notes="Opening stock on product create",
commit=False,
)
db.commit()
from app.core.database.cache_manager import cache
cache.invalidate_prefix("catalog:products:")
cache.invalidate_prefix("storefront:live-search")
cache.delete(f"catalog:product_detail:{product.slug}")
return product_response_with_stock(_reload_product(product_id, db), db)
@router.post("/products/bulk-delete")
def bulk_delete_products(payload: Dict[str, List[str]], db: Session = Depends(get_db)):
"""
Bulk deletes specified products along with their variants, variant attributes,
variant images, product images, and removes associated image files on disk.
"""
import os
from pathlib import Path
product_ids = payload.get("product_ids", [])
if not product_ids:
return {"status": "success", "deleted_products_count": 0, "message": "No product IDs provided"}
deleted_products_count = 0
deleted_images_count = 0
project_root = Path(__file__).resolve().parents[4]
upload_base_dir = str(project_root / "uploads")
for pid in product_ids:
prod = db.query(Product).filter(Product.product_id == pid).first()
if not prod:
continue
# 1. Clean up product images on disk
if prod.images:
for img in prod.images:
if img.image_url and "/uploads/" in img.image_url:
rel_path = img.image_url.split("/uploads/")[-1]
disk_path = os.path.join(upload_base_dir, rel_path)
if os.path.exists(disk_path):
try:
os.remove(disk_path)
deleted_images_count += 1
except Exception:
pass
# 2. Clean up variant images on disk
if prod.variants:
for var in prod.variants:
if var.images:
for v_img in var.images:
if v_img.image_url and "/uploads/" in v_img.image_url:
rel_path = v_img.image_url.split("/uploads/")[-1]
disk_path = os.path.join(upload_base_dir, rel_path)
if os.path.exists(disk_path):
try:
os.remove(disk_path)
deleted_images_count += 1
except Exception:
pass
# 3. Delete Product from DB (cascade deletes variants, variant_images, variant_attributes, product_images)
db.delete(prod)
deleted_products_count += 1
db.commit()
from app.core.database.cache_manager import cache
cache.invalidate_prefix("catalog:products:")
cache.invalidate_prefix("storefront:live-search")
cache.invalidate_prefix("catalog:product_detail:")
return {
"status": "success",
"deleted_products_count": deleted_products_count,
"deleted_images_count": deleted_images_count,
"message": f"Successfully deleted {deleted_products_count} product(s) and removed associated media files."
}
# --- Update Endpoints ---
@router.put("/brands/update/{brand_id}", response_model=BrandResponse)
def update_brand(brand_id: str, data: BrandUpdate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
brand = db.execute(select(Brand).where(Brand.brand_id == brand_id)).scalar_one_or_none()
if not brand:
raise HTTPException(status_code=404, detail="Brand not found")
if data.name is not None:
brand.name = data.name
brand.slug = slugify(data.name)
if data.logo_url is not None:
brand.logo_url = data.logo_url
if data.is_active is not None:
brand.is_active = data.is_active
if data.device_types is not None:
from app.models.BrandDeviceTypeModel import BrandDeviceType
db.query(BrandDeviceType).filter(BrandDeviceType.brand_id == brand_id).delete()
for dt in data.device_types:
db.add(BrandDeviceType(brand_id=brand_id, device_type=dt))
db.commit()
db.refresh(brand)
return brand
@router.put("/categories/update/{category_id}", response_model=CategoryResponse)
def update_category(category_id: str, data: CategoryUpdate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
category = db.execute(select(Category).where(Category.category_id == category_id)).scalar_one_or_none()
if not category:
raise HTTPException(status_code=404, detail="Category not found")
if data.name is not None:
category.name = data.name
category.slug = slugify(data.name)
if data.parent_category_id is not None:
category.parent_category_id = data.parent_category_id
if data.description is not None:
category.description = data.description
if data.image_url is not None:
category.image_url = data.image_url
if data.sort_order is not None:
category.sort_order = data.sort_order
if data.is_active is not None:
category.is_active = data.is_active
if data.is_parent_feature is not None:
category.is_parent_feature = data.is_parent_feature
db.commit()
db.refresh(category)
return category
@router.put("/device-series/update/{series_id}", response_model=DeviceSeriesResponse)
def update_device_series(series_id: str, data: DeviceSeriesUpdate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
series = db.execute(select(DeviceSeries).where(DeviceSeries.series_id == series_id)).scalar_one_or_none()
if not series:
raise HTTPException(status_code=404, detail="Device series not found")
if data.brand_id is not None or data.device_type is not None:
target_brand_id = data.brand_id if data.brand_id is not None else series.brand_id
brand = db.execute(select(Brand).where(Brand.brand_id == target_brand_id)).scalar_one_or_none()
if not brand:
raise HTTPException(status_code=404, detail="Brand not found")
device_type = data.device_type if data.device_type is not None else series.device_type
supported_types = brand.device_types
if len(supported_types) > 1:
if not device_type:
raise HTTPException(
status_code=400,
detail=f"This brand supports multiple device options {supported_types}. Please specify a device_type."
)
if device_type not in supported_types:
raise HTTPException(
status_code=400,
detail=f"Device type '{device_type}' is not supported by brand '{brand.name}'. Valid options: {supported_types}."
)
elif len(supported_types) == 1:
device_type = supported_types[0]
series.device_type = device_type
if data.brand_id is not None:
series.brand_id = data.brand_id
if data.name is not None:
series.name = data.name
series.slug = slugify(data.name)
if data.sort_order is not None:
series.sort_order = data.sort_order
if data.is_active is not None:
series.is_active = data.is_active
db.commit()
db.refresh(series)
return series
@router.put("/device-models/update/{model_id}", response_model=DeviceModelResponse)
def update_device_model(model_id: str, data: DeviceModelUpdate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
model = db.execute(select(DeviceModel).where(DeviceModel.model_id == model_id)).scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="Device model not found")
if data.series_id is not None:
if data.series_id == "" or data.series_id is None:
model.series_id = None
else:
series = db.execute(select(DeviceSeries).where(DeviceSeries.series_id == data.series_id)).scalar_one_or_none()
if not series:
raise HTTPException(status_code=404, detail="Device series not found")
model.series_id = data.series_id
if data.brand_id is not None:
brand = db.execute(select(Brand).where(Brand.brand_id == data.brand_id)).scalar_one_or_none()
if not brand:
raise HTTPException(status_code=404, detail="Brand not found")
model.brand_id = data.brand_id
if data.device_type is not None or data.brand_id is not None or data.series_id is not None:
target_brand_id = model.brand_id
brand = db.execute(select(Brand).where(Brand.brand_id == target_brand_id)).scalar_one_or_none()
series = db.execute(select(DeviceSeries).where(DeviceSeries.series_id == model.series_id)).scalar_one_or_none() if model.series_id else None
device_type = data.device_type if data.device_type is not None else model.device_type
if not device_type and series:
device_type = series.device_type
if not device_type and brand:
supported_types = brand.device_types
if len(supported_types) > 1:
if not device_type:
raise HTTPException(
status_code=400,
detail=f"This brand supports multiple device options {supported_types}. Please specify a device_type."
)
if device_type not in supported_types:
raise HTTPException(
status_code=400,
detail=f"Device type '{device_type}' is not supported by brand '{brand.name}'. Valid options: {supported_types}."
)
elif len(supported_types) == 1:
device_type = supported_types[0]
model.device_type = device_type
if data.name is not None:
model.name = data.name
model.slug = slugify(data.name)
# Recalculate full path if brand and series are active
brand = db.execute(select(Brand).where(Brand.brand_id == model.brand_id)).scalar_one_or_none()
series = db.execute(select(DeviceSeries).where(DeviceSeries.series_id == model.series_id)).scalar_one_or_none() if model.series_id else None
if brand:
if series:
model.full_path = f"/repair/{brand.slug}/{series.slug}/{model.slug}"
else:
model.full_path = f"/repair/{brand.slug}/{model.slug}"
if data.release_year is not None:
model.release_year = data.release_year
if data.image_url is not None:
model.image_url = data.image_url
if data.is_active is not None:
model.is_active = data.is_active
db.commit()
db.refresh(model)
return model
@router.put("/service-types/update/{service_type_id}", response_model=ServiceTypeResponse)
def update_service_type(service_type_id: str, data: ServiceTypeUpdate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
st = db.execute(select(ServiceType).where(ServiceType.service_type_id == service_type_id)).scalar_one_or_none()
if not st:
raise HTTPException(status_code=404, detail="Service type not found")
if data.name is not None:
st.name = data.name
st.slug = slugify(data.name)
if data.icon_url is not None:
st.icon_url = data.icon_url
if data.description is not None:
st.description = data.description
if data.is_active is not None:
st.is_active = data.is_active
db.commit()
db.refresh(st)
return st
@router.put("/repair-services/update/{repair_service_id}", response_model=RepairServiceResponse)
def update_repair_service(repair_service_id: str, data: RepairServiceUpdate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
rs = db.execute(select(RepairService).where(RepairService.repair_service_id == repair_service_id)).scalar_one_or_none()
if not rs:
raise HTTPException(status_code=404, detail="Repair service mapping not found")
if data.model_id is not None:
model = db.execute(select(DeviceModel).where(DeviceModel.model_id == data.model_id)).scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="Device model not found")
rs.model_id = data.model_id
if data.service_type_id is not None:
st = db.execute(select(ServiceType).where(ServiceType.service_type_id == data.service_type_id)).scalar_one_or_none()
if not st:
raise HTTPException(status_code=404, detail="Service type not found")
rs.service_type_id = data.service_type_id
model = db.execute(select(DeviceModel).where(DeviceModel.model_id == rs.model_id)).scalar_one_or_none()
st = db.execute(select(ServiceType).where(ServiceType.service_type_id == rs.service_type_id)).scalar_one_or_none()
if model and st:
rs.slug = f"{model.slug}-{st.slug}"
rs.full_path = f"{model.full_path}/repair/{st.slug}"
if data.description is not None:
rs.description = data.description
db.commit()
db.refresh(rs)
return rs
@router.put("/repair-variants/update/{variant_id}", response_model=RepairVariantResponse)
def update_repair_variant(variant_id: str, data: RepairVariantUpdate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
rv = db.execute(select(RepairVariant).where(RepairVariant.variant_id == variant_id)).scalar_one_or_none()
if not rv:
raise HTTPException(status_code=404, detail="Repair variant not found")
if data.repair_service_id is not None:
rs = db.execute(select(RepairService).where(RepairService.repair_service_id == data.repair_service_id)).scalar_one_or_none()
if not rs:
raise HTTPException(status_code=404, detail="Repair service not found")
rv.repair_service_id = data.repair_service_id
if data.name is not None:
rv.name = data.name
if data.price is not None:
rv.price = data.price
if data.cost is not None:
rv.cost = data.cost
if data.duration_minutes is not None:
rv.duration_minutes = data.duration_minutes
if data.warranty_days is not None:
rv.warranty_days = data.warranty_days
if data.status is not None:
rv.status = data.status
if data.parts is not None:
db.query(RepairVariantPart).filter(RepairVariantPart.variant_id == variant_id).delete(synchronize_session=False)
for p in data.parts:
part = db.execute(select(Part).where(Part.part_id == p.part_id)).scalar_one_or_none()
if not part:
raise HTTPException(status_code=404, detail=f"BOM Part '{p.part_id}' not found")
rvp = RepairVariantPart(
id=str(ulid.ULID()),
variant_id=variant_id,
part_id=p.part_id,
quantity=p.quantity
)
db.add(rvp)
db.commit()
db.refresh(rv)
return rv
@router.put("/attributes/update/{attribute_id}", response_model=AttributeTypeResponse)
def update_attribute(attribute_id: str, data: AttributeTypeUpdate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
attr = db.execute(select(AttributeType).where(AttributeType.attribute_id == attribute_id)).scalar_one_or_none()
if not attr:
raise HTTPException(status_code=404, detail="Attribute Type not found")
if data.name is not None:
attr.name = data.name
if data.code is not None:
attr.code = data.code.lower().strip()
if data.preset_values is not None:
attr.preset_values = data.preset_values
db.commit()
db.refresh(attr)
return attr
@router.put("/products/update/{product_id}", response_model=ProductResponse)
def update_product(product_id: str, data: ProductCreate, db: Session = Depends(get_db), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))):
product = db.execute(select(Product).where(Product.product_id == product_id)).scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
category = db.execute(select(Category).where(Category.category_id == data.category_id)).scalar_one_or_none()
if not category:
raise HTTPException(status_code=404, detail="Category not found")
# Update basic details
product.category_id = data.category_id
product.brand_id = data.brand_id
product.device_series_id = data.device_series_id
product.device_model_id = data.device_model_id
if product.name != data.name:
product.name = data.name
slug = slugify(data.name)
existing = db.execute(select(Product).where((Product.slug == slug) & (Product.product_id != product_id))).scalar_one_or_none()
if existing:
slug = f"{slug}-{str(ulid.ULID())[:8].lower()}"
product.slug = slug
path_segments = [category.slug]
if data.brand_id:
brand = db.execute(select(Brand).where(Brand.brand_id == data.brand_id)).scalar_one_or_none()
if brand:
path_segments.append(brand.slug)
path_segments.append(slug)
product.full_path = "/" + "/".join(path_segments)
product.description = data.description
# 1. Update main product images
db.query(ProductImage).filter(ProductImage.product_id == product_id).delete(synchronize_session=False)
for img in data.images:
product_img = ProductImage(
image_id=str(ulid.ULID()),
product_id=product_id,
image_url=img.image_url,
alt_text=img.alt_text,
sort_order=img.sort_order,
is_banner=img.is_banner
)
db.add(product_img)
# 2. Update variants matching by SKU
existing_vars = db.execute(select(ProductVariant).where(ProductVariant.product_id == product_id)).scalars().all()
existing_vars_by_sku = {v.sku: v for v in existing_vars}
incoming_skus = set(v.sku for v in data.variants)
# Delete variants not in incoming payload
for sku, old_var in existing_vars_by_sku.items():
if sku not in incoming_skus:
db.delete(old_var)
# Process incoming variants
for var in data.variants:
if var.sku in existing_vars_by_sku:
product_var = existing_vars_by_sku[var.sku]
product_var.barcode = var.barcode
product_var.price = var.price
product_var.compare_price = var.compare_price
product_var.cost_price = var.cost_price
product_var.low_stock_threshold = var.low_stock_threshold
# Recreate variant attributes
db.query(VariantAttribute).filter(VariantAttribute.variant_id == product_var.variant_id).delete(synchronize_session=False)
for attr in var.attributes:
attr_type = db.execute(select(AttributeType).where(AttributeType.attribute_id == attr.attribute_id)).scalar_one_or_none()
if not attr_type:
raise HTTPException(status_code=404, detail=f"Attribute Type '{attr.attribute_id}' not found")
var_attr = VariantAttribute(
id=str(ulid.ULID()),
variant_id=product_var.variant_id,
attribute_id=attr.attribute_id,
attribute_value=attr.attribute_value
)
db.add(var_attr)
# Recreate variant images
db.query(VariantImage).filter(VariantImage.variant_id == product_var.variant_id).delete(synchronize_session=False)
if var.images:
for img in var.images:
var_img = VariantImage(
image_id=str(ulid.ULID()),
variant_id=product_var.variant_id,
image_url=img.image_url,
sort_order=img.sort_order,
is_primary=img.is_primary
)
db.add(var_img)
if var.initial_stock is not None:
apply_stock_target(
product_var.variant_id,
var.initial_stock,
db,
notes="Catalog stock update",
commit=False,
)
else:
# Check if SKU is used by another product
existing_sku = db.execute(select(ProductVariant).where(ProductVariant.sku == var.sku, ProductVariant.product_id != product_id)).scalar_one_or_none()
if existing_sku:
raise HTTPException(status_code=400, detail=f"Variant SKU '{var.sku}' is already in use by another product")
# Create new variant
variant_id = str(ulid.ULID())
product_var = ProductVariant(
variant_id=variant_id,
product_id=product_id,
sku=var.sku,
barcode=var.barcode,
price=var.price,
compare_price=var.compare_price,
cost_price=var.cost_price,
low_stock_threshold=var.low_stock_threshold,
status="active"
)
db.add(product_var)
for attr in var.attributes:
attr_type = db.execute(select(AttributeType).where(AttributeType.attribute_id == attr.attribute_id)).scalar_one_or_none()
if not attr_type:
raise HTTPException(status_code=404, detail=f"Attribute Type '{attr.attribute_id}' not found")
var_attr = VariantAttribute(
id=str(ulid.ULID()),
variant_id=variant_id,
attribute_id=attr.attribute_id,
attribute_value=attr.attribute_value
)
db.add(var_attr)
if var.images:
for img in var.images:
var_img = VariantImage(
image_id=str(ulid.ULID()),
variant_id=variant_id,
image_url=img.image_url,
sort_order=img.sort_order,
is_primary=img.is_primary
)
db.add(var_img)
db.flush()
opening_stock = var.initial_stock if var.initial_stock is not None else 0
if opening_stock > 0:
apply_stock_target(
variant_id,
opening_stock,
db,
notes="Opening stock on variant add",
commit=False,
)
db.commit()
from app.core.database.cache_manager import cache
cache.invalidate_prefix("catalog:products:")
cache.invalidate_prefix("storefront:live-search")
cache.invalidate_prefix("catalog:product_detail:")
return product_response_with_stock(_reload_product(product_id, db), db)
# ──────────────────────────────────────────────────────────────────────────────
# REFERENCE-COUNTED IMAGE CLEANUP & SINGLE ITEM DELETE ROUTINES
# ──────────────────────────────────────────────────────────────────────────────
def safe_delete_image(image_url: str, db: Session = None):
"""
Safely deletes physical image files from disk without executing blocking DB queries per image.
"""
if not image_url or not isinstance(image_url, str):
return
rel_url = image_url
if "://" in image_url:
from urllib.parse import urlparse
rel_url = urlparse(image_url).path
if not rel_url:
return
clean_path = rel_url.lstrip("/")
if clean_path.startswith("uploads/"):
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../.."))
file_path = os.path.join(project_root, clean_path)
if os.path.exists(file_path):
try:
os.remove(file_path)
print(f"🗑️ Deleted physical image file: {file_path}")
except Exception as e:
print(f"Error removing physical image file {file_path}: {e}")
def cleanup_images_background(image_urls: List[str]):
"""Background task to remove disk files asynchronously without delaying HTTP response."""
for img_url in set(image_urls):
safe_delete_image(img_url)
def helper_delete_single_product(prod: Product, db: Session, images_collector: List[str] = None):
if images_collector is not None:
if prod.images:
for img in prod.images:
if img.image_url: images_collector.append(img.image_url)
if prod.variants:
for var in prod.variants:
if var.images:
for v_img in var.images:
if v_img.image_url: images_collector.append(v_img.image_url)
db.delete(prod)
def helper_delete_single_device_model(m: DeviceModel, db: Session, images_collector: List[str] = None):
if images_collector is not None and m.image_url:
images_collector.append(m.image_url)
# Cascade delete products attached to this model
prods = db.execute(select(Product).where(Product.device_model_id == m.model_id)).scalars().all()
for prod in prods:
helper_delete_single_product(prod, db, images_collector)
# Cascade delete repair services attached to this model
services = db.execute(select(RepairService).where(RepairService.model_id == m.model_id)).scalars().all()
for service in services:
if service.variants:
for rv in service.variants:
if rv.images:
for rvi in rv.images:
if images_collector is not None and rvi.image_url:
images_collector.append(rvi.image_url)
db.delete(service)
db.delete(m)
def helper_delete_single_device_series(s: DeviceSeries, db: Session, images_collector: List[str] = None):
models = db.execute(select(DeviceModel).where(DeviceModel.series_id == s.series_id)).scalars().all()
for m in models:
helper_delete_single_device_model(m, db, images_collector)
prods = db.execute(select(Product).where(Product.device_series_id == s.series_id)).scalars().all()
for prod in prods:
helper_delete_single_product(prod, db, images_collector)
db.delete(s)
def helper_delete_single_category(cat: Category, db: Session, images_collector: List[str] = None):
if images_collector is not None and cat.image_url:
images_collector.append(cat.image_url)
# Cascade delete sub-categories recursively
sub_cats = db.execute(select(Category).where(Category.parent_category_id == cat.category_id)).scalars().all()
for sub in sub_cats:
helper_delete_single_category(sub, db, images_collector)
# Cascade delete products attached to this category
prods = db.execute(select(Product).where(Product.category_id == cat.category_id)).scalars().all()
for prod in prods:
helper_delete_single_product(prod, db, images_collector)
db.delete(cat)
@router.delete("/products/{product_id}")
def delete_product(product_id: str, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
prod = db.execute(select(Product).where(Product.product_id == product_id)).scalar_one_or_none()
if not prod:
raise HTTPException(status_code=404, detail="Product not found")
images_to_delete = []
helper_delete_single_product(prod, db, images_to_delete)
db.commit()
if images_to_delete:
background_tasks.add_task(cleanup_images_background, images_to_delete)
from app.core.database.cache_manager import cache
cache.invalidate_prefix("catalog:products:")
cache.invalidate_prefix("storefront:live-search")
cache.invalidate_prefix("catalog:product_detail:")
return {"status": "success", "message": f"Product '{prod.name}' and all media files deleted successfully"}
@router.delete("/categories/{category_id}")
def delete_category(category_id: str, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
cat = db.execute(select(Category).where(Category.category_id == category_id)).scalar_one_or_none()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
images_to_delete = []
helper_delete_single_category(cat, db, images_to_delete)
db.commit()
if images_to_delete:
background_tasks.add_task(cleanup_images_background, images_to_delete)
from app.core.database.cache_manager import cache
cache.invalidate_prefix("catalog:categories:")
cache.invalidate_prefix("catalog:products:")
return {"status": "success", "message": f"Category '{cat.name}' and all sub-categories and products deleted successfully"}
@router.delete("/brands/{brand_id}")
def delete_brand(brand_id: str, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
b = db.execute(select(Brand).where(Brand.brand_id == brand_id)).scalar_one_or_none()
if not b:
raise HTTPException(status_code=404, detail="Brand not found")
images_to_delete = []
if b.logo_url:
images_to_delete.append(b.logo_url)
# 1. Cascade delete device series attached to brand
series_list = db.execute(select(DeviceSeries).where(DeviceSeries.brand_id == brand_id)).scalars().all()
for s in series_list:
helper_delete_single_device_series(s, db, images_to_delete)
# 2. Cascade delete standalone device models attached to brand
models = db.execute(select(DeviceModel).where(DeviceModel.brand_id == brand_id)).scalars().all()
for m in models:
helper_delete_single_device_model(m, db, images_to_delete)
# 3. Cascade delete products attached to brand
prods = db.execute(select(Product).where(Product.brand_id == brand_id)).scalars().all()
for prod in prods:
helper_delete_single_product(prod, db, images_to_delete)
db.delete(b)
db.commit()
if images_to_delete:
background_tasks.add_task(cleanup_images_background, images_to_delete)
from app.core.database.cache_manager import cache
cache.invalidate_prefix("catalog:brands:")
cache.invalidate_prefix("catalog:products:")
cache.invalidate_prefix("catalog:categories:")
cache.invalidate_prefix("catalog:device-series:")
cache.invalidate_prefix("catalog:device-models:")
return {"status": "success", "message": f"Brand '{b.name}' and all related products, models, series, and services deleted successfully"}
@router.delete("/device-series/{series_id}")
def delete_device_series(series_id: str, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
s = db.execute(select(DeviceSeries).where(DeviceSeries.series_id == series_id)).scalar_one_or_none()
if not s:
raise HTTPException(status_code=404, detail="Device Series not found")
images_to_delete = []
helper_delete_single_device_series(s, db, images_to_delete)
db.commit()
if images_to_delete:
background_tasks.add_task(cleanup_images_background, images_to_delete)
from app.core.database.cache_manager import cache
cache.invalidate_prefix("catalog:device-series:")
cache.invalidate_prefix("catalog:device-models:")
cache.invalidate_prefix("catalog:products:")
return {"status": "success", "message": f"Device Series '{s.name}' and all associated models and products deleted successfully"}
@router.delete("/device-models/{model_id}")
def delete_device_model(model_id: str, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
m = db.execute(select(DeviceModel).where(DeviceModel.model_id == model_id)).scalar_one_or_none()
if not m:
raise HTTPException(status_code=404, detail="Device Model not found")
images_to_delete = []
helper_delete_single_device_model(m, db, images_to_delete)
db.commit()
if images_to_delete:
background_tasks.add_task(cleanup_images_background, images_to_delete)
from app.core.database.cache_manager import cache
cache.invalidate_prefix("catalog:device-models:")
cache.invalidate_prefix("catalog:products:")
return {"status": "success", "message": f"Device Model '{m.name}' and all associated products and repair services deleted successfully"}
@router.delete("/attributes/{attribute_id}")
def delete_attribute_type(attribute_id: str, db: Session = Depends(get_db)):
attr = db.execute(select(AttributeType).where(AttributeType.attribute_id == attribute_id)).scalar_one_or_none()
if not attr:
raise HTTPException(status_code=404, detail="Attribute Type not found")
# Delete linked variant attributes
db.query(VariantAttribute).filter(VariantAttribute.attribute_id == attribute_id).delete(synchronize_session=False)
db.delete(attr)
db.commit()
return {"status": "success", "message": f"Attribute Type '{attr.name}' deleted successfully"}