1783 lines
74 KiB
Python
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"}
|
|
|
|
|