from fastapi import APIRouter, Depends, HTTPException, status, Query, BackgroundTasks from sqlalchemy.orm import Session, selectinload from sqlalchemy import select, func, distinct from typing import List, Dict, Any, Optional import ulid import os import re from decimal import Decimal from datetime import datetime from pathlib import Path from app.core.database.db_session import get_db from app.core.permissions.RoleChecker import RoleChecker import app.models.db_base from app.models.DepartmentModel import Department 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.utils.html_utils import strip_html 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) if hasattr(product, 'brand') and product.brand: payload.brand_name = product.brand.name elif product.brand_id: b = db.execute(select(Brand).where(Brand.brand_id == product.brand_id)).scalar_one_or_none() if b: payload.brand_name = b.name 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)): from sqlalchemy import func from app.models.ProductModel import Product # Compute count of active products for each category counts_rows = db.execute( select(Product.category_id, func.count(Product.product_id)) .where(Product.status == "active") .group_by(Product.category_id) ).all() count_map = {cat_id: cnt for cat_id, cnt in counts_rows if cat_id} stmt = select(Category).where(Category.is_active == True) if has_products: stmt = stmt.where(Category.category_id.in_(count_map.keys())) categories = db.execute(stmt.order_by(Category.sort_order)).scalars().all() res = [] for cat in categories: data = { "category_id": cat.category_id, "parent_category_id": cat.parent_category_id, "name": cat.name, "slug": cat.slug, "description": cat.description, "image_url": cat.image_url, "sort_order": cat.sort_order, "is_parent_feature": getattr(cat, "is_parent_feature", False), "is_active": cat.is_active, "product_count": count_map.get(cat.category_id, 0), "created_at": cat.created_at, } res.append(data) return res @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), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))): 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), current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))): 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 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() encoded_products = jsonable_encoder(products) payload = { "total": total_count, "page": page, "limit": limit, "products": attach_available_stock(encoded_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 and not (category or search or brand or model or series): 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 all_cat_ids: Optional[List[str]] = None if category: target_cats = db.execute( select(Category.category_id).where((Category.category_id == category) | (Category.slug == category)) ).scalars().all() if target_cats: child_cats = db.execute( select(Category.category_id).where(Category.parent_category_id.in_(target_cats)) ).scalars().all() all_cat_ids = list(set(target_cats + child_cats)) stmt = stmt.where(Product.category_id.in_(all_cat_ids)) else: all_cat_ids = [category] stmt = stmt.where(Product.category_id == category) if search: stmt = apply_product_search(stmt, search) 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 count_subq = select(Product.product_id).where(Product.status == "active") if search: count_subq = apply_product_search(count_subq, search) if all_cat_ids: count_subq = count_subq.where(Product.category_id.in_(all_cat_ids)) if brand: count_subq = count_subq.join(Brand, Product.brand_id == Brand.brand_id).where((Brand.brand_id == brand) | (Brand.slug == brand)) if model: count_subq = count_subq.join(DeviceModel, Product.device_model_id == DeviceModel.model_id).where((DeviceModel.model_id == model) | (DeviceModel.slug == model)) if series: count_subq = count_subq.join(DeviceSeries, Product.device_series_id == DeviceSeries.series_id).where((DeviceSeries.series_id == series) | (DeviceSeries.slug == series)) if variant_filters: count_subq = count_subq.where(Product.product_id.in_(subq)) total_stmt = select(func.count()).select_from(count_subq.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 }) product_images_fallback = product_serialized.get("images") or [] for variant in product_serialized.get("variants") or []: variant_images = variant.get("images") or [] if not variant_images: variant["images"] = product_images_fallback 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] seo_title_val = strip_html(data.seo_title) or f"Buy {data.name} | iFixKart" raw_seo_desc = data.seo_description or (data.description if data.description else f"Order {data.name} online at iFixKart with fast shipping and warranty.") seo_desc_val = (strip_html(raw_seo_desc) or "")[:20000] meta_keywords_val = strip_html(data.meta_keywords) or f"{data.name}, mobile spare parts, replacement, ifixkart" 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, warranty_type=data.warranty_type, warranty_summary=data.warranty_summary, seo_title=seo_title_val, seo_description=seo_desc_val, meta_keywords=meta_keywords_val, show_specifications=data.show_specifications if data.show_specifications is not None else True, 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.clear() 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 product.warranty_type = data.warranty_type product.warranty_summary = data.warranty_summary product.seo_title = strip_html(data.seo_title) or f"Buy {data.name} | iFixKart" raw_update_seo = data.seo_description or (data.description if data.description else f"Order {data.name} online at iFixKart with fast shipping and warranty.") product.seo_description = (strip_html(raw_update_seo) or "")[:20000] product.meta_keywords = strip_html(data.meta_keywords) if data.show_specifications is not None: product.show_specifications = data.show_specifications # 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.clear() 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"}