VortexCommerceBackend / app /api /products.py
hisham-cmd's picture
Full Sync: Explicit Commit Operations (Pure & Optimized)
b2be963 verified
Raw
History Blame Contribute Delete
26.8 kB
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import or_, desc, asc, func, text, case, cast, String
from sqlalchemy.orm import Session, joinedload
from datetime import datetime, timezone
from app.db.base import get_db
from app.models.product import Product, ProductImage, Category
from app.schemas.product import (
ProductCreate,
ProductUpdate,
ProductListItem,
ProductDetail,
ProductImageCreate,
PaginatedResponse,
)
from app.schemas.auth import AuthResponse
from app.core.security import get_current_user
from app.core.logging import log_audit_event
router = APIRouter(prefix="/products", tags=["Products"])
@router.get("", response_model=PaginatedResponse)
async def list_products(
search: Optional[str] = Query(None),
brand: Optional[str] = Query(None),
category_id: Optional[int] = Query(None),
min_price: Optional[float] = Query(None),
max_price: Optional[float] = Query(None),
is_featured: Optional[bool] = Query(None),
has_discount: Optional[bool] = Query(None),
sort_by: str = Query("created_at", regex="^(created_at|price|rating|name_en)$"),
sort_order: str = Query("desc", regex="^(asc|desc)$"),
page: int = Query(1, ge=1),
page_size: int = Query(12, ge=1, le=2000),
limit: Optional[int] = Query(None, ge=1, le=2000),
specs_filter: Optional[str] = Query(None, description="JSON string of filters, e.g. {'RAM': ['8GB']}"),
db: Session = Depends(get_db),
):
# Use limit if provided, otherwise use page_size
effective_page_size = limit if limit is not None else page_size
from app.models.settings import StoreSettings
settings = db.query(StoreSettings).first()
global_discount = settings.global_discount if settings else 0
multiplier = (1 - global_discount / 100)
query = db.query(Product).filter(
Product.is_active == True, # noqa: E712
Product.deleted_at == None # noqa: E711
)
# Search (with Arabic Normalization: أ, إ, آ -> ا | ة -> ه | ى -> ي)
if search:
search_term = f"%{search}%"
# Search (with Arabic Normalization: أ, إ, آ -> ا | ة -> ه | ى -> ي | ignore Tashkeel)
def normalize_arabic(column):
# 1-7. Previous normalization
col = func.replace(column, 'أ', 'ا')
col = func.replace(col, 'إ', 'ا')
col = func.replace(col, 'آ', 'ا')
col = func.replace(col, 'ة', 'ه')
col = func.replace(col, 'ى', 'ي')
col = func.replace(col, 'ؤ', 'و')
col = func.replace(col, 'ئ', 'ي')
# 8. Ignore Tashkeel/Diacritics (Fatha, Damma, Kesra, Shadda, etc)
tashkeel = ['ً', 'ٌ', 'ٍ', 'َ', 'ُ', 'ِ', 'ّ', 'ْ', 'ـ']
for char in tashkeel:
col = func.replace(col, char, '')
return col
# Normalize the user's search term in python
n_search = search.replace('أ', 'ا').replace('إ', 'ا').replace('آ', 'ا').replace('ة', 'ه').replace('ى', 'ي').replace('ؤ', 'و').replace('ئ', 'ي')
# Remove Tashkeel from search term too
for char in ['ً', 'ٌ', 'ٍ', 'َ', 'ُ', 'ِ', 'ّ', 'ْ', 'ـ']:
n_search = n_search.replace(char, '')
n_search_term = f"%{n_search}%"
query = query.filter(
or_(
Product.name_en.ilike(search_term),
normalize_arabic(Product.name_ar).ilike(n_search_term),
Product.description_en.ilike(search_term),
normalize_arabic(Product.description_ar).ilike(n_search_term),
)
)
# Filters
if category_id is not None:
# Get category and its children to allow filtering by parent category
category = db.query(Category).filter(Category.id == category_id).first()
if category:
subcats = db.query(Category.id).filter(Category.parent_id == category_id).all()
subcat_ids = [c[0] for c in subcats]
all_cat_ids = [category_id] + subcat_ids
query = query.filter(Product.category_id.in_(all_cat_ids))
if brand:
brands = [b.strip() for b in brand.split(',') if b.strip()]
brand_conditions = []
for b in brands:
brand_search = f"%{b.lower()}%"
brand_conditions.append(
or_(
cast(Product.specs['brand_name'], String).ilike(brand_search),
cast(Product.specs['brand'], String).ilike(brand_search),
cast(Product.specs['details_en']['Brand Name'], String).ilike(brand_search),
cast(Product.specs['details_en']['Brand'], String).ilike(brand_search)
)
)
if brand_conditions:
query = query.filter(or_(*brand_conditions))
if specs_filter:
import json
try:
filters = json.loads(specs_filter)
if isinstance(filters, dict):
for key, values in filters.items():
if values and isinstance(values, list):
# Postgres JSONB extraction
query = query.filter(
or_(
cast(Product.specs[key], String).in_(values),
cast(Product.specs['details_en'][key], String).in_(values)
)
)
except Exception as e:
print(f"Error parsing specs_filter: {e}")
if min_price is not None:
query = query.filter(Product.price >= min_price)
if max_price is not None:
query = query.filter(Product.price <= max_price)
if is_featured is not None:
query = query.filter(Product.is_featured == is_featured)
if has_discount is True:
query = query.filter(Product.compare_price > Product.price)
elif has_discount is False:
query = query.filter(or_(Product.compare_price == None, Product.compare_price <= Product.price))
# Count
total = query.count()
# Sort
from sqlalchemy import case
# Custom Hybrid Sorting Logic:
# 1. New Products (ID > 3708) first, sorted by ID DESC
# 2. Original Catalog (ID <= 3708) next, sorted by ID ASC
query = query.order_by(
case(
(Product.id > 3708, 0),
else_=1
),
Product.id.asc()
)
# Pagination
offset = (page - 1) * effective_page_size
products = (
query.options(joinedload(Product.category), joinedload(Product.images))
.offset(offset)
.limit(effective_page_size)
.all()
)
# Map to response
items = []
for p in products:
primary_image = None
if p.images:
sorted_imgs = sorted(p.images, key=lambda x: x.sort_order)
primary_image = sorted_imgs[0].image_url if sorted_imgs else None
# Pricing for combined discount display:
# price = original_price * multiplier
# compare_price = original_compare (raw)
original_price = p.price
original_compare = p.compare_price if p.compare_price else p.price
item = ProductListItem(
id=p.id,
slug=p.slug,
name_ar=p.name_ar,
name_en=p.name_en,
price=original_price * multiplier,
compare_price=original_compare if (multiplier < 1.0 or p.compare_price) else None,
stock=p.stock,
category_id=p.category_id,
category=p.category,
rating=p.rating,
rating_count=p.rating_count,
is_featured=p.is_featured,
image_url=primary_image,
created_at=p.created_at,
quality_score=p.quality_score or 100,
deleted_at=p.deleted_at,
)
items.append(item.model_dump())
# Serialize dates
for item in items:
if isinstance(item.get("created_at"), datetime):
item["created_at"] = item["created_at"].isoformat()
if item.get("deleted_at") and isinstance(item["deleted_at"], datetime):
item["deleted_at"] = item["deleted_at"].isoformat()
total_pages = (total + page_size - 1) // page_size
return PaginatedResponse(
isSuccess=True,
value={
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
},
statusCode=200,
)
@router.get("/admin/deleted", response_model=PaginatedResponse)
async def list_deleted_products(
page: int = Query(1, ge=1),
page_size: int = Query(12, ge=1, le=48),
db: Session = Depends(get_db),
user_id: int = Depends(get_current_user),
):
query = db.query(Product).filter(Product.deleted_at != None) # noqa: E711
total = query.count()
offset = (page - 1) * page_size
products = (
query.options(joinedload(Product.category), joinedload(Product.images))
.order_by(desc(Product.deleted_at))
.offset(offset)
.limit(page_size)
.all()
)
items = []
for p in products:
item = ProductDetail.model_validate(p).model_dump()
if isinstance(item.get("created_at"), datetime):
item["created_at"] = item["created_at"].isoformat()
if item.get("deleted_at") and isinstance(item["deleted_at"], datetime):
item["deleted_at"] = item["deleted_at"].isoformat()
items.append(item)
total_pages = (total + page_size - 1) // page_size
return PaginatedResponse(
isSuccess=True,
value={
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
},
statusCode=200,
)
@router.get("/filters")
async def get_filters(category_id: Optional[int] = Query(None), db: Session = Depends(get_db)):
"""
Returns filter metadata for a category (price range, brands, dynamic attributes).
Supports parent categories by aggregating children. If category_id is None, returns global filters.
"""
all_cat_ids = None
cat_name_en = ""
if category_id is not None:
category = db.query(Category).filter(Category.id == category_id).first()
if not category:
raise HTTPException(status_code=404, detail="Category not found")
subcats = db.query(Category.id).filter(Category.parent_id == category_id).all()
all_cat_ids = [category_id] + [c[0] for c in subcats]
cat_name_en = (category.name_en or "").lower()
base_filter = [Product.is_active == True, Product.deleted_at == None]
if all_cat_ids is not None:
base_filter.append(Product.category_id.in_(all_cat_ids))
# 1. Price Range
price_stats = db.query(
func.min(Product.price).label("min_p"),
func.max(Product.price).label("max_p")
).filter(*base_filter).first()
# 2. Brands
brands_q1 = db.query(func.distinct(cast(Product.specs['brand_name'], String))).filter(*base_filter)
brands_q2 = db.query(func.distinct(cast(Product.specs['details_en']['Brand Name'], String))).filter(*base_filter)
brands = sorted(list(set([b[0] for b in brands_q1.all() if b[0]] + [b[0] for b in brands_q2.all() if b[0]])))
# 3. Dynamic Attributes per subcategory
attr_mappings = []
if cat_name_en:
# ── Electronics ──
if any(k in cat_name_en for k in ["mobile", "phone", "smartphone", "جوالات"]):
attr_mappings = [
{"key": "RAM", "label_ar": "الرام", "label_en": "RAM"},
{"key": "Internal Memory", "label_ar": "المساحة الداخلية", "label_en": "Storage"},
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
{"key": "Screen Size", "label_ar": "حجم الشاشة", "label_en": "Screen Size"},
{"key": "Network Type", "label_ar": "نوع الشبكة", "label_en": "Network"},
]
elif any(k in cat_name_en for k in ["laptop", "computer", "لابتوب"]):
attr_mappings = [
{"key": "Processor", "label_ar": "المعالج", "label_en": "Processor"},
{"key": "RAM", "label_ar": "الرام", "label_en": "RAM"},
{"key": "Hard Drive Capacity", "label_ar": "سعة التخزين", "label_en": "Storage"},
{"key": "Screen Size", "label_ar": "حجم الشاشة", "label_en": "Screen Size"},
{"key": "Graphics Card", "label_ar": "كرت الشاشة", "label_en": "Graphics Card"},
{"key": "Operating System", "label_ar": "نظام التشغيل", "label_en": "OS"},
]
elif any(k in cat_name_en for k in ["tablet", "لوحي"]):
attr_mappings = [
{"key": "RAM", "label_ar": "الرام", "label_en": "RAM"},
{"key": "Internal Memory", "label_ar": "المساحة", "label_en": "Storage"},
{"key": "Screen Size", "label_ar": "حجم الشاشة", "label_en": "Screen Size"},
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
]
elif any(k in cat_name_en for k in ["tv", "television", "تلفزيون"]):
attr_mappings = [
{"key": "Screen Size", "label_ar": "حجم الشاشة", "label_en": "Screen Size"},
{"key": "Resolution Type", "label_ar": "الدقة", "label_en": "Resolution"},
{"key": "Smart TV", "label_ar": "تلفزيون ذكي", "label_en": "Smart TV"},
{"key": "Display Type", "label_ar": "نوع الشاشة", "label_en": "Display Type"},
]
elif any(k in cat_name_en for k in ["camera", "كاميرا"]):
attr_mappings = [
{"key": "Resolution", "label_ar": "الدقة", "label_en": "Resolution"},
{"key": "Type", "label_ar": "النوع", "label_en": "Type"},
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
]
elif any(k in cat_name_en for k in ["printer", "طابع"]):
attr_mappings = [
{"key": "Print Technology", "label_ar": "تقنية الطباعة", "label_en": "Print Tech"},
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
{"key": "Connectivity", "label_ar": "الاتصال", "label_en": "Connectivity"},
]
# ── Accessories ──
elif any(k in cat_name_en for k in ["audio", "headphone", "سماعات", "صوت"]):
attr_mappings = [
{"key": "Connection Type", "label_ar": "نوع الاتصال", "label_en": "Connection"},
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
{"key": "Type", "label_ar": "النوع", "label_en": "Type"},
]
elif any(k in cat_name_en for k in ["watch", "ساعات"]):
attr_mappings = [
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
{"key": "Band Material", "label_ar": "مادة السوار", "label_en": "Band Material"},
{"key": "Display Type", "label_ar": "نوع الشاشة", "label_en": "Display"},
]
# ── Gaming ──
elif any(k in cat_name_en for k in ["gaming", "console", "ألعاب"]):
attr_mappings = [
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
{"key": "Storage Capacity", "label_ar": "سعة التخزين", "label_en": "Storage"},
{"key": "Type", "label_ar": "النوع", "label_en": "Type"},
]
# ── Home Appliances ──
elif any(k in cat_name_en for k in ["refrigerator", "ثلاج"]):
attr_mappings = [
{"key": "Capacity", "label_ar": "السعة", "label_en": "Capacity"},
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
{"key": "Door Type", "label_ar": "نوع الباب", "label_en": "Door Type"},
]
elif any(k in cat_name_en for k in ["wash", "غسال"]):
attr_mappings = [
{"key": "Capacity", "label_ar": "السعة", "label_en": "Capacity (kg)"},
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
{"key": "Load Type", "label_ar": "نوع التحميل", "label_en": "Load Type"},
]
elif any(k in cat_name_en for k in ["conditioner", "مكيف"]):
attr_mappings = [
{"key": "Capacity", "label_ar": "السعة (BTU)", "label_en": "Capacity (BTU)"},
{"key": "Type", "label_ar": "النوع", "label_en": "Type"},
{"key": "Energy Rating", "label_ar": "كفاءة الطاقة", "label_en": "Energy Rating"},
]
elif any(k in cat_name_en for k in ["small appliance", "منزلية صغيرة"]):
attr_mappings = [
{"key": "Type", "label_ar": "النوع", "label_en": "Type"},
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
{"key": "Wattage", "label_ar": "القدرة", "label_en": "Wattage"},
]
elif any(k in cat_name_en for k in ["large appliance", "منزلية كبيرة"]):
attr_mappings = [
{"key": "Capacity", "label_ar": "السعة", "label_en": "Capacity"},
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
{"key": "Type", "label_ar": "النوع", "label_en": "Type"},
]
# ── Parent categories (aggregate) ──
elif any(k in cat_name_en for k in ["electronic", "إلكتروني"]):
attr_mappings = [
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
]
elif any(k in cat_name_en for k in ["accessor", "ملحقات"]):
attr_mappings = [
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
{"key": "Connection Type", "label_ar": "نوع الاتصال", "label_en": "Connection"},
]
elif any(k in cat_name_en for k in ["home appliance", "المنزلية"]):
attr_mappings = [
{"key": "Capacity", "label_ar": "السعة", "label_en": "Capacity"},
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
]
# 4. Resolve attribute values from DB
attributes = []
for attr in attr_mappings:
key = attr["key"]
# Try direct spec key first, then details_en
values_query = db.query(func.distinct(cast(Product.specs[key], String))).filter(
*base_filter
).all()
vals = [v[0] for v in values_query if v[0]]
if not vals:
values_query = db.query(func.distinct(cast(Product.specs['details_en'][key], String))).filter(
*base_filter
).all()
vals = [v[0] for v in values_query if v[0]]
if vals:
attributes.append({
"key": key,
"label_ar": attr["label_ar"],
"label_en": attr["label_en"],
"options": sorted(list(set(vals)))
})
return PaginatedResponse(
isSuccess=True,
value={
"min_price": float(price_stats.min_p) if price_stats and price_stats.min_p else 0,
"max_price": float(price_stats.max_p) if price_stats and price_stats.max_p else 10000,
"brands": brands,
"attributes": attributes
},
statusCode=200
)
@router.get("/{identifier}", response_model=PaginatedResponse)
async def get_product(identifier: str, raw: bool = False, db: Session = Depends(get_db)):
query = db.query(Product).options(joinedload(Product.category), joinedload(Product.images))
if identifier.isdigit():
product = query.filter(Product.id == int(identifier)).first()
# If not found by ID, maybe the slug itself is numeric?
if not product:
product = query.filter(Product.slug == identifier).first()
else:
product = query.filter(Product.slug == identifier).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
from app.models.settings import StoreSettings
settings = db.query(StoreSettings).first()
global_discount = settings.global_discount if settings else 0
multiplier = (1 - global_discount / 100)
detail = ProductDetail.model_validate(product).model_dump()
# Only apply discount if NOT raw
if not raw:
# Normalize pricing for combined discount display:
# price = original_price * multiplier
# compare_price = original_compare (raw)
original_price = detail["price"]
original_compare = detail.get("compare_price") if detail.get("compare_price") else original_price
detail["price"] = original_price * multiplier
if multiplier < 1.0 or detail.get("compare_price"):
detail["compare_price"] = original_compare
# Apply to variants in specs
if detail.get("specs") and "variants" in detail["specs"]:
for v in detail["specs"]["variants"]:
if v.get("price"):
v_original_price = v["price"]
v["price"] = v_original_price * multiplier
# Optional: Could add compare_price to variants too, but mostly price is enough
# as the main product's compare_price is often used for the range.
if v.get("price_modifier"):
v["price_modifier"] *= multiplier
return PaginatedResponse(
isSuccess=True,
value=detail,
statusCode=200,
)
@router.post("", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
async def create_product(
request: ProductCreate,
user_id: int = Depends(get_current_user),
db: Session = Depends(get_db),
):
if not request.images or len(request.images) == 0:
raise HTTPException(status_code=400, detail="Main image is required / صورة رئيسية مطلوبة")
product = Product(
name_ar=request.name_ar,
name_en=request.name_en,
description_ar=request.description_ar,
description_en=request.description_en,
price=request.price,
compare_price=request.compare_price,
stock=request.stock,
category_id=request.category_id,
is_featured=request.is_featured,
is_active=request.is_active,
specs=request.specs,
)
db.add(product)
db.flush()
# Add images
for img in request.images:
db_img = ProductImage(
product_id=product.id,
image_url=img.image_url,
alt_text=img.alt_text,
sort_order=img.sort_order,
)
db.add(db_img)
db.commit()
db.refresh(product)
return AuthResponse(
isSuccess=True,
value={"product_id": product.id},
statusCode=201,
)
@router.put("/{product_id}", response_model=AuthResponse)
async def update_product(
product_id: int,
request: ProductUpdate,
user_id: int = Depends(get_current_user),
db: Session = Depends(get_db),
):
product = db.query(Product).filter(Product.id == product_id).first()
if not product or product.deleted_at is not None:
raise HTTPException(status_code=404, detail="Product not found")
update_data = request.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(product, field, value)
db.commit()
return AuthResponse(
isSuccess=True,
value={"product_id": product.id},
statusCode=200,
)
@router.delete("/{product_id}", response_model=AuthResponse)
async def delete_product(
product_id: int,
user_id: int = Depends(get_current_user),
db: Session = Depends(get_db),
):
product = db.query(Product).filter(Product.id == product_id).first()
if not product or product.deleted_at is not None:
raise HTTPException(status_code=404, detail="Product not found")
# Soft Delete
product.deleted_at = datetime.now(timezone.utc)
product.deleted_by = user_id
product.deletion_reason = "Manual deletion by admin"
db.commit()
return AuthResponse(
isSuccess=True,
value={"deleted": True, "soft_delete": True},
statusCode=200,
)
@router.delete("/{product_id}/hard", response_model=AuthResponse)
async def hard_delete_product(
product_id: int,
user_id: int = Depends(get_current_user),
db: Session = Depends(get_db),
):
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
db.delete(product)
db.commit()
return AuthResponse(
isSuccess=True,
value={"deleted": True, "hard_delete": True},
statusCode=200,
)
@router.post("/{product_id}/restore", response_model=AuthResponse)
async def restore_product(
product_id: int,
user_id: int = Depends(get_current_user),
db: Session = Depends(get_db),
):
product = db.query(Product).filter(Product.id == product_id).first()
if not product or product.deleted_at is None:
raise HTTPException(status_code=404, detail="Product not found or not deleted")
product.deleted_at = None
product.deleted_by = None
product.deletion_reason = None
db.commit()
return AuthResponse(
isSuccess=True,
value={"restored": True},
statusCode=200,
)
# End of products.py