Files
ai_web/backend/apps/products/api.py
2026-01-28 16:00:56 +08:00

539 lines
19 KiB
Python

"""
Products API routes for categories, websites, products and prices.
"""
from typing import List, Optional
import re
import time
from decimal import Decimal, InvalidOperation
import csv
import io
from ninja import Router, Query, File
from ninja.files import UploadedFile
from ninja_jwt.authentication import JWTAuth
from ninja.pagination import paginate, PageNumberPagination
from django.conf import settings
from django.db.models import Count, Min, Max, Q, Prefetch, F
from django.db import transaction, IntegrityError
from django.shortcuts import get_object_or_404
from django.views.decorators.cache import cache_page
from .models import Category, Website, Product, ProductPrice
from .schemas import (
CategoryOut, CategoryIn,
WebsiteOut, WebsiteIn, WebsiteFilter,
ProductOut, ProductIn, ProductWithPricesOut, ProductPriceOut, ProductPriceIn,
ProductFilter,
ProductSearchFilter,
ImportResultOut,
MyProductOut,
)
from apps.favorites.models import Favorite
router = Router()
category_router = Router()
website_router = Router()
# ==================== Category Routes ====================
@category_router.get("/", response=List[CategoryOut])
@cache_page(settings.CACHE_TTL_SECONDS)
def list_categories(request):
"""Get all categories."""
return Category.objects.all()
@category_router.get("/{slug}", response=CategoryOut)
@cache_page(settings.CACHE_TTL_SECONDS)
def get_category_by_slug(request, slug: str):
"""Get category by slug."""
return get_object_or_404(Category, slug=slug)
def require_admin(user):
"""Check if user is admin."""
from ninja.errors import HttpError
if not user or user.role != 'admin' or not user.is_active:
raise HttpError(403, "仅管理员可执行此操作")
def normalize_category_slug(name: str, slug: str) -> str:
"""Normalize category slug and ensure it's not empty."""
raw_slug = (slug or "").strip()
if raw_slug:
return raw_slug
base = re.sub(r"\s+", "-", name.strip().lower())
base = re.sub(r"[^a-z0-9-]", "", base)
if not base:
base = f"category-{int(time.time())}"
if Category.objects.filter(slug=base).exists():
suffix = 1
while Category.objects.filter(slug=f"{base}-{suffix}").exists():
suffix += 1
base = f"{base}-{suffix}"
return base
@category_router.post("/", response=CategoryOut, auth=JWTAuth())
def create_category(request, data: CategoryIn):
"""Create a new category."""
name = (data.name or "").strip()
if not name:
raise HttpError(400, "分类名称不能为空")
slug = normalize_category_slug(name, data.slug)
if len(slug) > 100:
raise HttpError(400, "分类标识过长")
try:
category = Category.objects.create(
name=name,
slug=slug,
description=data.description,
icon=data.icon,
parent_id=data.parent_id,
sort_order=data.sort_order or 0,
)
except IntegrityError:
raise HttpError(400, "分类标识已存在")
return category
# ==================== Website Routes ====================
@website_router.get("/", response=List[WebsiteOut])
@paginate(PageNumberPagination, page_size=20)
@cache_page(settings.CACHE_TTL_SECONDS)
def list_websites(request, filters: WebsiteFilter = Query(...)):
"""Get all websites with optional filters."""
queryset = Website.objects.all()
if filters.category_id:
queryset = queryset.filter(category_id=filters.category_id)
if filters.is_verified is not None:
queryset = queryset.filter(is_verified=filters.is_verified)
return queryset
@website_router.get("/{website_id}", response=WebsiteOut)
@cache_page(settings.CACHE_TTL_SECONDS)
def get_website(request, website_id: int):
"""Get website by ID."""
return get_object_or_404(Website, id=website_id)
@website_router.post("/", response=WebsiteOut, auth=JWTAuth())
def create_website(request, data: WebsiteIn):
"""Create a new website. Any authenticated user can create."""
website = Website.objects.create(**data.dict())
return website
# ==================== Product Routes ====================
@router.post("/import/", response=ImportResultOut, auth=JWTAuth())
def import_products_csv(request, file: UploadedFile = File(...)):
"""Import products/websites/prices from CSV."""
content = file.read()
try:
text = content.decode("utf-8")
except UnicodeDecodeError:
text = content.decode("utf-8-sig", errors="ignore")
reader = csv.DictReader(io.StringIO(text))
result = ImportResultOut()
def parse_bool(value: Optional[str], default=False) -> bool:
if value is None:
return default
val = str(value).strip().lower()
if val in ("1", "true", "yes", "y", "on"):
return True
if val in ("0", "false", "no", "n", "off"):
return False
return default
def parse_decimal(value: Optional[str], field: str, line_no: int) -> Optional[Decimal]:
if value in (None, ""):
return None
try:
return Decimal(str(value).strip())
except (InvalidOperation, ValueError):
result.errors.append(f"{line_no}行字段{field}格式错误")
return None
for idx, row in enumerate(reader, start=2):
if not row:
continue
product_name = (row.get("product_name") or "").strip()
category_slug = (row.get("category_slug") or "").strip()
category_name = (row.get("category_name") or "").strip()
website_name = (row.get("website_name") or "").strip()
website_url = (row.get("website_url") or "").strip()
price_value = (row.get("price") or "").strip()
product_url = (row.get("url") or "").strip()
if not product_name or not (category_slug or category_name) or not website_name or not website_url or not price_value or not product_url:
result.errors.append(f"{idx}行缺少必填字段")
continue
try:
with transaction.atomic():
category = None
if category_slug:
category, created = Category.objects.get_or_create(
slug=category_slug,
defaults={
"name": category_name or category_slug,
"description": row.get("category_desc") or None,
},
)
if created:
result.created_categories += 1
if not category:
category, created = Category.objects.get_or_create(
name=category_name,
defaults={
"slug": category_slug or category_name,
"description": row.get("category_desc") or None,
},
)
if created:
result.created_categories += 1
website, created = Website.objects.get_or_create(
name=website_name,
defaults={
"url": website_url,
"logo": row.get("website_logo") or None,
"description": row.get("website_desc") or None,
"category": category,
"is_verified": parse_bool(row.get("is_verified"), False),
},
)
if created:
result.created_websites += 1
else:
if website.url != website_url:
website.url = website_url
if row.get("website_logo"):
website.logo = row.get("website_logo")
if row.get("website_desc"):
website.description = row.get("website_desc")
if website.category_id != category.id:
website.category = category
website.save()
product, created = Product.objects.get_or_create(
name=product_name,
category=category,
defaults={
"description": row.get("product_desc") or None,
"image": row.get("product_image") or None,
},
)
if created:
result.created_products += 1
else:
updated = False
if row.get("product_desc") and product.description != row.get("product_desc"):
product.description = row.get("product_desc")
updated = True
if row.get("product_image") and product.image != row.get("product_image"):
product.image = row.get("product_image")
updated = True
if updated:
product.save()
price = parse_decimal(row.get("price"), "price", idx)
if price is None:
continue
original_price = parse_decimal(row.get("original_price"), "original_price", idx)
currency = (row.get("currency") or "CNY").strip() or "CNY"
in_stock = parse_bool(row.get("in_stock"), True)
price_record, created = ProductPrice.objects.update_or_create(
product=product,
website=website,
defaults={
"price": price,
"original_price": original_price,
"currency": currency,
"url": product_url,
"in_stock": in_stock,
},
)
if created:
result.created_prices += 1
else:
result.updated_prices += 1
except Exception:
result.errors.append(f"{idx}行处理失败")
continue
return result
@router.get("/recommendations/", response=List[ProductOut])
def recommend_products(request, limit: int = 12):
"""Get recommended products based on favorites or popularity."""
# 限制 limit 最大值
if limit < 1:
limit = 1
if limit > 100:
limit = 100
user = getattr(request, "auth", None)
# 只显示已审核通过的商品
base_queryset = Product.objects.select_related("category").filter(status='approved')
if user:
favorite_product_ids = list(
Favorite.objects.filter(user=user).values_list("product_id", flat=True)
)
category_ids = list(
Product.objects.filter(id__in=favorite_product_ids, status='approved')
.values_list("category_id", flat=True)
.distinct()
)
if category_ids:
base_queryset = base_queryset.filter(category_id__in=category_ids).exclude(
id__in=favorite_product_ids
)
queryset = (
base_queryset.annotate(favorites_count=Count("favorites", distinct=True))
.order_by("-favorites_count", "-created_at")[:limit]
)
return list(queryset)
@router.get("/", response=List[ProductOut])
@paginate(PageNumberPagination, page_size=20)
@cache_page(settings.CACHE_TTL_SECONDS)
def list_products(request, filters: ProductFilter = Query(...)):
"""Get all approved products with optional filters."""
# 只显示已审核通过的商品
queryset = Product.objects.select_related("category").filter(status='approved')
if filters.category_id:
queryset = queryset.filter(category_id=filters.category_id)
if filters.search:
queryset = queryset.filter(
Q(name__icontains=filters.search) |
Q(description__icontains=filters.search)
)
needs_price_stats = (
filters.min_price is not None
or filters.max_price is not None
or (filters.sort_by or "").lower() in ("price_asc", "price_desc")
)
if needs_price_stats:
queryset = queryset.annotate(lowest_price=Min("prices__price"))
if filters.min_price is not None:
queryset = queryset.filter(lowest_price__gte=filters.min_price)
if filters.max_price is not None:
queryset = queryset.filter(lowest_price__lte=filters.max_price)
sort_by = (filters.sort_by or "newest").lower()
if sort_by == "oldest":
queryset = queryset.order_by("created_at")
elif sort_by == "price_asc":
queryset = queryset.order_by(F("lowest_price").asc(nulls_last=True), "-created_at")
elif sort_by == "price_desc":
queryset = queryset.order_by(F("lowest_price").desc(nulls_last=True), "-created_at")
else:
queryset = queryset.order_by("-created_at")
return queryset
@router.get("/{product_id}", response=ProductOut)
@cache_page(settings.CACHE_TTL_SECONDS)
def get_product(request, product_id: int):
"""Get product by ID."""
return get_object_or_404(Product, id=product_id)
@router.get("/{product_id}/with-prices", response=ProductWithPricesOut)
@cache_page(settings.CACHE_TTL_SECONDS)
def get_product_with_prices(request, product_id: int):
"""Get product with all prices from different websites."""
product = get_object_or_404(Product, id=product_id)
prices = ProductPrice.objects.filter(product=product).select_related('website')
price_list = []
for pp in prices:
price_list.append(ProductPriceOut(
id=pp.id,
product_id=pp.product_id,
website_id=pp.website_id,
website_name=pp.website.name,
website_logo=pp.website.logo,
price=pp.price,
original_price=pp.original_price,
currency=pp.currency,
url=pp.url,
in_stock=pp.in_stock,
last_checked=pp.last_checked,
))
# Calculate price range
price_stats = prices.aggregate(
lowest=Min('price'),
highest=Max('price')
)
return ProductWithPricesOut(
id=product.id,
name=product.name,
description=product.description,
image=product.image,
category_id=product.category_id,
created_at=product.created_at,
updated_at=product.updated_at,
prices=price_list,
lowest_price=price_stats['lowest'],
highest_price=price_stats['highest'],
)
@router.get("/search/", response=List[ProductWithPricesOut])
@paginate(PageNumberPagination, page_size=20)
@cache_page(settings.CACHE_TTL_SECONDS)
def search_products(request, q: str, filters: ProductSearchFilter = Query(...)):
"""Search approved products by name or description."""
prices_prefetch = Prefetch(
"prices",
queryset=ProductPrice.objects.select_related("website"),
)
# 只搜索已审核通过的商品
products = (
Product.objects.select_related("category")
.filter(Q(name__icontains=q) | Q(description__icontains=q), status='approved')
)
if filters.category_id:
products = products.filter(category_id=filters.category_id)
needs_price_stats = (
filters.min_price is not None
or filters.max_price is not None
or (filters.sort_by or "").lower() in ("price_asc", "price_desc")
)
if needs_price_stats:
products = products.annotate(lowest_price=Min("prices__price"))
if filters.min_price is not None:
products = products.filter(lowest_price__gte=filters.min_price)
if filters.max_price is not None:
products = products.filter(lowest_price__lte=filters.max_price)
sort_by = (filters.sort_by or "newest").lower()
if sort_by == "oldest":
products = products.order_by("created_at")
elif sort_by == "price_asc":
products = products.order_by(F("lowest_price").asc(nulls_last=True), "-created_at")
elif sort_by == "price_desc":
products = products.order_by(F("lowest_price").desc(nulls_last=True), "-created_at")
else:
products = products.order_by("-created_at")
products = products.prefetch_related(prices_prefetch)
result = []
for product in products:
prices = list(product.prices.all())
price_list = [
ProductPriceOut(
id=pp.id,
product_id=pp.product_id,
website_id=pp.website_id,
website_name=pp.website.name,
website_logo=pp.website.logo,
price=pp.price,
original_price=pp.original_price,
currency=pp.currency,
url=pp.url,
in_stock=pp.in_stock,
last_checked=pp.last_checked,
)
for pp in prices
]
lowest_price = min((pp.price for pp in prices), default=None)
highest_price = max((pp.price for pp in prices), default=None)
result.append(ProductWithPricesOut(
id=product.id,
name=product.name,
description=product.description,
image=product.image,
category_id=product.category_id,
created_at=product.created_at,
updated_at=product.updated_at,
prices=price_list,
lowest_price=lowest_price,
highest_price=highest_price,
))
return result
@router.post("/", response=ProductOut, auth=JWTAuth())
def create_product(request, data: ProductIn):
"""Create a new product. Admin creates approved, others create pending."""
user = request.auth
is_admin = user and user.role == 'admin' and user.is_active
product = Product.objects.create(
name=data.name,
description=data.description,
image=data.image,
category_id=data.category_id,
status='approved' if is_admin else 'pending',
submitted_by=user,
)
return product
@router.get("/my/", response=List[MyProductOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=20)
def my_products(request, status: Optional[str] = None):
"""Get current user's submitted products."""
user = request.auth
queryset = Product.objects.filter(submitted_by=user).order_by('-created_at')
if status:
queryset = queryset.filter(status=status)
return queryset
@router.post("/prices/", response=ProductPriceOut, auth=JWTAuth())
def add_product_price(request, data: ProductPriceIn):
"""Add a price for a product. Admin or product owner can add."""
user = request.auth
is_admin = user and user.role == 'admin' and user.is_active
# 检查商品是否存在并验证权限
product = get_object_or_404(Product, id=data.product_id)
if not is_admin and product.submitted_by_id != user.id:
from ninja.errors import HttpError
raise HttpError(403, "只能为自己提交的商品添加价格")
price = ProductPrice.objects.create(**data.dict())
website = price.website
return ProductPriceOut(
id=price.id,
product_id=price.product_id,
website_id=price.website_id,
website_name=website.name,
website_logo=website.logo,
price=price.price,
original_price=price.original_price,
currency=price.currency,
url=price.url,
in_stock=price.in_stock,
last_checked=price.last_checked,
)