2026-01-27 18:15:43 +08:00
|
|
|
"""
|
|
|
|
|
Admin API routes for managing core data.
|
|
|
|
|
"""
|
|
|
|
|
from typing import List, Optional
|
2026-01-28 16:00:56 +08:00
|
|
|
from datetime import datetime
|
|
|
|
|
from django.utils import timezone
|
2026-01-27 18:15:43 +08:00
|
|
|
from ninja import Router, Schema
|
|
|
|
|
from ninja.errors import HttpError
|
|
|
|
|
from ninja_jwt.authentication import JWTAuth
|
2026-01-28 16:00:56 +08:00
|
|
|
from ninja.pagination import paginate, PageNumberPagination
|
2026-01-27 18:15:43 +08:00
|
|
|
|
|
|
|
|
from apps.users.models import User
|
2026-02-04 15:25:04 +08:00
|
|
|
from apps.products.models import Product, Website, Category, ComparisonTag, ComparisonTagItem, ProductPrice, ProductPriceHistory
|
2026-01-27 18:15:43 +08:00
|
|
|
from apps.bounties.models import Bounty, BountyDispute, PaymentEvent
|
|
|
|
|
|
|
|
|
|
router = Router()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_admin(user):
|
2026-01-28 16:00:56 +08:00
|
|
|
if not user or user.role != 'admin' or not user.is_active:
|
2026-01-27 18:15:43 +08:00
|
|
|
raise HttpError(403, "仅管理员可访问")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UserOut(Schema):
|
|
|
|
|
id: int
|
|
|
|
|
open_id: str
|
|
|
|
|
name: Optional[str] = None
|
|
|
|
|
role: str
|
|
|
|
|
is_active: bool
|
2026-01-28 16:00:56 +08:00
|
|
|
created_at: datetime
|
2026-01-27 18:15:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class UserUpdateIn(Schema):
|
|
|
|
|
role: Optional[str] = None
|
|
|
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SimpleOut(Schema):
|
|
|
|
|
id: int
|
|
|
|
|
name: str
|
|
|
|
|
|
|
|
|
|
|
2026-02-04 15:25:04 +08:00
|
|
|
class MessageOut(Schema):
|
|
|
|
|
message: str
|
|
|
|
|
|
|
|
|
|
|
2026-01-27 18:15:43 +08:00
|
|
|
class BountyAdminOut(Schema):
|
|
|
|
|
id: int
|
|
|
|
|
title: str
|
|
|
|
|
status: str
|
|
|
|
|
reward: str
|
|
|
|
|
publisher_id: int
|
|
|
|
|
acceptor_id: Optional[int] = None
|
|
|
|
|
is_escrowed: bool
|
|
|
|
|
is_paid: bool
|
2026-01-28 16:00:56 +08:00
|
|
|
created_at: datetime
|
|
|
|
|
|
|
|
|
|
class Config:
|
|
|
|
|
from_attributes = True
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def resolve_reward(obj):
|
|
|
|
|
return str(obj.reward)
|
2026-01-27 18:15:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class PaymentEventOut(Schema):
|
|
|
|
|
id: int
|
|
|
|
|
event_id: str
|
|
|
|
|
event_type: str
|
|
|
|
|
bounty_id: Optional[int] = None
|
|
|
|
|
success: bool
|
2026-01-28 16:00:56 +08:00
|
|
|
processed_at: datetime
|
|
|
|
|
|
|
|
|
|
class Config:
|
|
|
|
|
from_attributes = True
|
2026-01-27 18:15:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class DisputeOut(Schema):
|
|
|
|
|
id: int
|
|
|
|
|
bounty_id: int
|
|
|
|
|
initiator_id: int
|
|
|
|
|
status: str
|
2026-01-28 16:00:56 +08:00
|
|
|
created_at: datetime
|
|
|
|
|
|
|
|
|
|
class Config:
|
|
|
|
|
from_attributes = True
|
2026-01-27 18:15:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/users/", response=List[UserOut], auth=JWTAuth())
|
2026-01-28 16:00:56 +08:00
|
|
|
@paginate(PageNumberPagination, page_size=20)
|
2026-01-27 18:15:43 +08:00
|
|
|
def list_users(request):
|
|
|
|
|
require_admin(request.auth)
|
2026-01-28 16:00:56 +08:00
|
|
|
return User.objects.all().order_by('-created_at')
|
2026-01-27 18:15:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/users/{user_id}", response=UserOut, auth=JWTAuth())
|
|
|
|
|
def update_user(request, user_id: int, data: UserUpdateIn):
|
|
|
|
|
require_admin(request.auth)
|
2026-01-28 16:00:56 +08:00
|
|
|
try:
|
|
|
|
|
user = User.objects.get(id=user_id)
|
|
|
|
|
except User.DoesNotExist:
|
|
|
|
|
raise HttpError(404, "用户不存在")
|
2026-01-27 18:15:43 +08:00
|
|
|
update_data = data.dict(exclude_unset=True)
|
|
|
|
|
for key, value in update_data.items():
|
|
|
|
|
setattr(user, key, value)
|
|
|
|
|
user.save()
|
2026-01-28 16:00:56 +08:00
|
|
|
return user
|
2026-01-27 18:15:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/categories/", response=List[SimpleOut], auth=JWTAuth())
|
2026-01-28 16:00:56 +08:00
|
|
|
@paginate(PageNumberPagination, page_size=50)
|
2026-01-27 18:15:43 +08:00
|
|
|
def list_categories(request):
|
|
|
|
|
require_admin(request.auth)
|
2026-01-28 16:00:56 +08:00
|
|
|
return Category.objects.values('id', 'name').order_by('name')
|
2026-01-27 18:15:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/websites/", response=List[SimpleOut], auth=JWTAuth())
|
2026-01-28 16:00:56 +08:00
|
|
|
@paginate(PageNumberPagination, page_size=50)
|
2026-01-27 18:15:43 +08:00
|
|
|
def list_websites(request):
|
|
|
|
|
require_admin(request.auth)
|
2026-01-28 16:00:56 +08:00
|
|
|
return Website.objects.values('id', 'name').order_by('name')
|
2026-01-27 18:15:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/products/", response=List[SimpleOut], auth=JWTAuth())
|
2026-01-28 16:00:56 +08:00
|
|
|
@paginate(PageNumberPagination, page_size=50)
|
2026-01-27 18:15:43 +08:00
|
|
|
def list_products(request):
|
|
|
|
|
require_admin(request.auth)
|
2026-01-28 16:00:56 +08:00
|
|
|
return Product.objects.values('id', 'name').order_by('-created_at')
|
2026-01-27 18:15:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/bounties/", response=List[BountyAdminOut], auth=JWTAuth())
|
2026-01-28 16:00:56 +08:00
|
|
|
@paginate(PageNumberPagination, page_size=20)
|
2026-01-27 18:15:43 +08:00
|
|
|
def list_bounties(request, status: Optional[str] = None):
|
|
|
|
|
require_admin(request.auth)
|
2026-01-28 16:00:56 +08:00
|
|
|
queryset = Bounty.objects.select_related("publisher", "acceptor").all().order_by('-created_at')
|
2026-01-27 18:15:43 +08:00
|
|
|
if status:
|
|
|
|
|
queryset = queryset.filter(status=status)
|
2026-01-28 16:00:56 +08:00
|
|
|
return queryset
|
2026-01-27 18:15:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/disputes/", response=List[DisputeOut], auth=JWTAuth())
|
2026-01-28 16:00:56 +08:00
|
|
|
@paginate(PageNumberPagination, page_size=20)
|
2026-01-27 18:15:43 +08:00
|
|
|
def list_disputes(request, status: Optional[str] = None):
|
|
|
|
|
require_admin(request.auth)
|
|
|
|
|
disputes = BountyDispute.objects.all().order_by('-created_at')
|
|
|
|
|
if status:
|
|
|
|
|
disputes = disputes.filter(status=status)
|
2026-01-28 16:00:56 +08:00
|
|
|
return disputes
|
2026-01-27 18:15:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/payments/", response=List[PaymentEventOut], auth=JWTAuth())
|
2026-01-28 16:00:56 +08:00
|
|
|
@paginate(PageNumberPagination, page_size=20)
|
2026-01-27 18:15:43 +08:00
|
|
|
def list_payment_events(request):
|
|
|
|
|
require_admin(request.auth)
|
2026-01-28 16:00:56 +08:00
|
|
|
return PaymentEvent.objects.all().order_by('-processed_at')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ==================== Product Review ====================
|
|
|
|
|
|
|
|
|
|
class ProductAdminOut(Schema):
|
|
|
|
|
"""Product admin output schema with all fields."""
|
|
|
|
|
id: int
|
|
|
|
|
name: str
|
|
|
|
|
description: Optional[str] = None
|
|
|
|
|
image: Optional[str] = None
|
2026-01-29 13:18:59 +08:00
|
|
|
images: List[str] = []
|
2026-01-28 16:00:56 +08:00
|
|
|
category_id: int
|
|
|
|
|
category_name: Optional[str] = None
|
|
|
|
|
status: str
|
|
|
|
|
submitted_by_id: Optional[int] = None
|
|
|
|
|
submitted_by_name: Optional[str] = None
|
|
|
|
|
reject_reason: Optional[str] = None
|
|
|
|
|
reviewed_at: Optional[datetime] = None
|
|
|
|
|
created_at: datetime
|
|
|
|
|
updated_at: datetime
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def resolve_category_name(obj):
|
|
|
|
|
return obj.category.name if obj.category else None
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def resolve_submitted_by_name(obj):
|
|
|
|
|
if obj.submitted_by:
|
|
|
|
|
return obj.submitted_by.name or obj.submitted_by.open_id
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProductReviewIn(Schema):
|
|
|
|
|
"""Product review input schema."""
|
|
|
|
|
approved: bool
|
|
|
|
|
reject_reason: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
2026-01-29 13:18:59 +08:00
|
|
|
class ProductImagesIn(Schema):
|
|
|
|
|
"""Product images update input schema."""
|
|
|
|
|
images: List[str] = []
|
|
|
|
|
image: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
2026-02-04 15:25:04 +08:00
|
|
|
class ComparisonTagAdminOut(Schema):
|
|
|
|
|
id: int
|
|
|
|
|
name: str
|
|
|
|
|
slug: str
|
|
|
|
|
description: Optional[str] = None
|
|
|
|
|
cover_image: Optional[str] = None
|
|
|
|
|
icon: Optional[str] = None
|
|
|
|
|
sort_order: int
|
|
|
|
|
is_active: bool
|
|
|
|
|
product_count: int = 0
|
|
|
|
|
created_at: datetime
|
|
|
|
|
updated_at: datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ComparisonTagIn(Schema):
|
|
|
|
|
name: str
|
|
|
|
|
slug: str
|
|
|
|
|
description: Optional[str] = None
|
|
|
|
|
cover_image: Optional[str] = None
|
|
|
|
|
icon: Optional[str] = None
|
|
|
|
|
sort_order: int = 0
|
|
|
|
|
is_active: bool = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ComparisonTagUpdate(Schema):
|
|
|
|
|
name: Optional[str] = None
|
|
|
|
|
slug: Optional[str] = None
|
|
|
|
|
description: Optional[str] = None
|
|
|
|
|
cover_image: Optional[str] = None
|
|
|
|
|
icon: Optional[str] = None
|
|
|
|
|
sort_order: Optional[int] = None
|
|
|
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ComparisonTagItemOut(Schema):
|
|
|
|
|
id: int
|
|
|
|
|
tag_id: int
|
|
|
|
|
product_id: int
|
|
|
|
|
product_name: str
|
|
|
|
|
product_image: Optional[str] = None
|
|
|
|
|
product_status: str
|
|
|
|
|
sort_order: int
|
|
|
|
|
is_pinned: bool
|
|
|
|
|
created_at: datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ComparisonTagItemIn(Schema):
|
|
|
|
|
product_ids: List[int]
|
|
|
|
|
sort_order: int = 0
|
|
|
|
|
is_pinned: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ComparisonTagItemUpdate(Schema):
|
|
|
|
|
sort_order: Optional[int] = None
|
|
|
|
|
is_pinned: Optional[bool] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BackfillHistoryIn(Schema):
|
|
|
|
|
product_ids: Optional[List[int]] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BackfillHistoryOut(Schema):
|
|
|
|
|
created: int
|
|
|
|
|
skipped: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RecordHistoryIn(Schema):
|
|
|
|
|
product_ids: Optional[List[int]] = None
|
|
|
|
|
force: bool = False
|
|
|
|
|
|
|
|
|
|
|
2026-01-28 16:00:56 +08:00
|
|
|
@router.get("/products/pending/", response=List[ProductAdminOut], auth=JWTAuth())
|
|
|
|
|
@paginate(PageNumberPagination, page_size=20)
|
|
|
|
|
def list_pending_products(request):
|
|
|
|
|
"""List all pending products for review."""
|
|
|
|
|
require_admin(request.auth)
|
|
|
|
|
return Product.objects.select_related("category", "submitted_by").filter(
|
|
|
|
|
status='pending'
|
|
|
|
|
).order_by('-created_at')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/products/all/", response=List[ProductAdminOut], auth=JWTAuth())
|
|
|
|
|
@paginate(PageNumberPagination, page_size=20)
|
|
|
|
|
def list_all_products(request, status: Optional[str] = None):
|
|
|
|
|
"""List all products with optional status filter."""
|
|
|
|
|
require_admin(request.auth)
|
|
|
|
|
queryset = Product.objects.select_related("category", "submitted_by").order_by('-created_at')
|
|
|
|
|
if status:
|
|
|
|
|
queryset = queryset.filter(status=status)
|
|
|
|
|
return queryset
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/products/{product_id}/review/", response=ProductAdminOut, auth=JWTAuth())
|
|
|
|
|
def review_product(request, product_id: int, data: ProductReviewIn):
|
|
|
|
|
"""Approve or reject a product."""
|
|
|
|
|
require_admin(request.auth)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
product = Product.objects.select_related("category", "submitted_by").get(id=product_id)
|
|
|
|
|
except Product.DoesNotExist:
|
|
|
|
|
raise HttpError(404, "商品不存在")
|
|
|
|
|
|
|
|
|
|
if product.status != 'pending':
|
|
|
|
|
raise HttpError(400, "只能审核待审核状态的商品")
|
|
|
|
|
|
|
|
|
|
if data.approved:
|
|
|
|
|
product.status = 'approved'
|
|
|
|
|
product.reject_reason = None
|
|
|
|
|
else:
|
|
|
|
|
if not data.reject_reason:
|
|
|
|
|
raise HttpError(400, "拒绝时需要提供原因")
|
|
|
|
|
product.status = 'rejected'
|
|
|
|
|
product.reject_reason = data.reject_reason
|
|
|
|
|
|
|
|
|
|
product.reviewed_at = timezone.now()
|
|
|
|
|
product.save()
|
|
|
|
|
|
|
|
|
|
return product
|
2026-01-29 13:18:59 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/products/{product_id}/images/", response=ProductAdminOut, auth=JWTAuth())
|
|
|
|
|
def update_product_images(request, product_id: int, data: ProductImagesIn):
|
|
|
|
|
require_admin(request.auth)
|
|
|
|
|
try:
|
|
|
|
|
product = Product.objects.select_related("category", "submitted_by").get(id=product_id)
|
|
|
|
|
except Product.DoesNotExist:
|
|
|
|
|
raise HttpError(404, "商品不存在")
|
|
|
|
|
|
|
|
|
|
max_images = 6
|
|
|
|
|
images = [url.strip() for url in (data.images or []) if url and url.strip()]
|
|
|
|
|
image = (data.image or "").strip() or (images[0] if images else None)
|
|
|
|
|
if image and image not in images:
|
|
|
|
|
images.insert(0, image)
|
|
|
|
|
if len(images) > max_images:
|
|
|
|
|
raise HttpError(400, f"最多上传{max_images}张图片")
|
|
|
|
|
|
|
|
|
|
product.image = image or None
|
|
|
|
|
product.images = images
|
|
|
|
|
product.save(update_fields=["image", "images", "updated_at"])
|
|
|
|
|
return product
|
2026-02-04 15:25:04 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ==================== Comparison Tag Management ====================
|
|
|
|
|
|
|
|
|
|
@router.get("/compare-tags/", response=List[ComparisonTagAdminOut], auth=JWTAuth())
|
|
|
|
|
def list_compare_tags(request):
|
|
|
|
|
require_admin(request.auth)
|
|
|
|
|
tags = ComparisonTag.objects.all().order_by("sort_order", "id")
|
|
|
|
|
return [
|
|
|
|
|
ComparisonTagAdminOut(
|
|
|
|
|
id=tag.id,
|
|
|
|
|
name=tag.name,
|
|
|
|
|
slug=tag.slug,
|
|
|
|
|
description=tag.description,
|
|
|
|
|
cover_image=tag.cover_image,
|
|
|
|
|
icon=tag.icon,
|
|
|
|
|
sort_order=tag.sort_order,
|
|
|
|
|
is_active=tag.is_active,
|
|
|
|
|
product_count=tag.items.count(),
|
|
|
|
|
created_at=tag.created_at,
|
|
|
|
|
updated_at=tag.updated_at,
|
|
|
|
|
)
|
|
|
|
|
for tag in tags
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/compare-tags/", response=ComparisonTagAdminOut, auth=JWTAuth())
|
|
|
|
|
def create_compare_tag(request, data: ComparisonTagIn):
|
|
|
|
|
require_admin(request.auth)
|
|
|
|
|
if ComparisonTag.objects.filter(slug=data.slug).exists():
|
|
|
|
|
raise HttpError(400, "标签标识已存在")
|
|
|
|
|
tag = ComparisonTag.objects.create(**data.dict())
|
|
|
|
|
return ComparisonTagAdminOut(
|
|
|
|
|
id=tag.id,
|
|
|
|
|
name=tag.name,
|
|
|
|
|
slug=tag.slug,
|
|
|
|
|
description=tag.description,
|
|
|
|
|
cover_image=tag.cover_image,
|
|
|
|
|
icon=tag.icon,
|
|
|
|
|
sort_order=tag.sort_order,
|
|
|
|
|
is_active=tag.is_active,
|
|
|
|
|
product_count=0,
|
|
|
|
|
created_at=tag.created_at,
|
|
|
|
|
updated_at=tag.updated_at,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/compare-tags/{tag_id}", response=ComparisonTagAdminOut, auth=JWTAuth())
|
|
|
|
|
def update_compare_tag(request, tag_id: int, data: ComparisonTagUpdate):
|
|
|
|
|
require_admin(request.auth)
|
|
|
|
|
try:
|
|
|
|
|
tag = ComparisonTag.objects.get(id=tag_id)
|
|
|
|
|
except ComparisonTag.DoesNotExist:
|
|
|
|
|
raise HttpError(404, "标签不存在")
|
|
|
|
|
update_data = data.dict(exclude_unset=True)
|
|
|
|
|
if "slug" in update_data:
|
|
|
|
|
if ComparisonTag.objects.filter(slug=update_data["slug"]).exclude(id=tag_id).exists():
|
|
|
|
|
raise HttpError(400, "标签标识已存在")
|
|
|
|
|
for key, value in update_data.items():
|
|
|
|
|
setattr(tag, key, value)
|
|
|
|
|
tag.save()
|
|
|
|
|
return ComparisonTagAdminOut(
|
|
|
|
|
id=tag.id,
|
|
|
|
|
name=tag.name,
|
|
|
|
|
slug=tag.slug,
|
|
|
|
|
description=tag.description,
|
|
|
|
|
cover_image=tag.cover_image,
|
|
|
|
|
icon=tag.icon,
|
|
|
|
|
sort_order=tag.sort_order,
|
|
|
|
|
is_active=tag.is_active,
|
|
|
|
|
product_count=tag.items.count(),
|
|
|
|
|
created_at=tag.created_at,
|
|
|
|
|
updated_at=tag.updated_at,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/compare-tags/{tag_id}", response=MessageOut, auth=JWTAuth())
|
|
|
|
|
def delete_compare_tag(request, tag_id: int):
|
|
|
|
|
require_admin(request.auth)
|
|
|
|
|
deleted, _ = ComparisonTag.objects.filter(id=tag_id).delete()
|
|
|
|
|
if not deleted:
|
|
|
|
|
raise HttpError(404, "标签不存在")
|
|
|
|
|
return MessageOut(message="标签已删除")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/compare-tags/{tag_id}/items/", response=List[ComparisonTagItemOut], auth=JWTAuth())
|
|
|
|
|
def list_compare_tag_items(request, tag_id: int):
|
|
|
|
|
require_admin(request.auth)
|
|
|
|
|
items = (
|
|
|
|
|
ComparisonTagItem.objects.select_related("product")
|
|
|
|
|
.filter(tag_id=tag_id)
|
|
|
|
|
.order_by("-is_pinned", "sort_order", "id")
|
|
|
|
|
)
|
|
|
|
|
return [
|
|
|
|
|
ComparisonTagItemOut(
|
|
|
|
|
id=item.id,
|
|
|
|
|
tag_id=item.tag_id,
|
|
|
|
|
product_id=item.product_id,
|
|
|
|
|
product_name=item.product.name,
|
|
|
|
|
product_image=item.product.image,
|
|
|
|
|
product_status=item.product.status,
|
|
|
|
|
sort_order=item.sort_order,
|
|
|
|
|
is_pinned=item.is_pinned,
|
|
|
|
|
created_at=item.created_at,
|
|
|
|
|
)
|
|
|
|
|
for item in items
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/compare-tags/{tag_id}/items/", response=List[ComparisonTagItemOut], auth=JWTAuth())
|
|
|
|
|
def add_compare_tag_items(request, tag_id: int, data: ComparisonTagItemIn):
|
|
|
|
|
require_admin(request.auth)
|
|
|
|
|
try:
|
|
|
|
|
tag = ComparisonTag.objects.get(id=tag_id)
|
|
|
|
|
except ComparisonTag.DoesNotExist:
|
|
|
|
|
raise HttpError(404, "标签不存在")
|
|
|
|
|
product_ids = list(dict.fromkeys(data.product_ids or []))
|
|
|
|
|
if not product_ids:
|
|
|
|
|
raise HttpError(400, "请选择商品")
|
|
|
|
|
existing = set(
|
|
|
|
|
ComparisonTagItem.objects.filter(tag=tag, product_id__in=product_ids).values_list("product_id", flat=True)
|
|
|
|
|
)
|
|
|
|
|
items = []
|
|
|
|
|
for product_id in product_ids:
|
|
|
|
|
if product_id in existing:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
product = Product.objects.get(id=product_id)
|
|
|
|
|
except Product.DoesNotExist:
|
|
|
|
|
continue
|
|
|
|
|
items.append(
|
|
|
|
|
ComparisonTagItem(
|
|
|
|
|
tag=tag,
|
|
|
|
|
product=product,
|
|
|
|
|
sort_order=data.sort_order,
|
|
|
|
|
is_pinned=data.is_pinned,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if items:
|
|
|
|
|
ComparisonTagItem.objects.bulk_create(items)
|
|
|
|
|
|
|
|
|
|
return list_compare_tag_items(request, tag_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/compare-tags/{tag_id}/items/{item_id}", response=ComparisonTagItemOut, auth=JWTAuth())
|
|
|
|
|
def update_compare_tag_item(request, tag_id: int, item_id: int, data: ComparisonTagItemUpdate):
|
|
|
|
|
require_admin(request.auth)
|
|
|
|
|
try:
|
|
|
|
|
item = ComparisonTagItem.objects.select_related("product").get(id=item_id, tag_id=tag_id)
|
|
|
|
|
except ComparisonTagItem.DoesNotExist:
|
|
|
|
|
raise HttpError(404, "标签商品不存在")
|
|
|
|
|
update_data = data.dict(exclude_unset=True)
|
|
|
|
|
for key, value in update_data.items():
|
|
|
|
|
setattr(item, key, value)
|
|
|
|
|
item.save()
|
|
|
|
|
return ComparisonTagItemOut(
|
|
|
|
|
id=item.id,
|
|
|
|
|
tag_id=item.tag_id,
|
|
|
|
|
product_id=item.product_id,
|
|
|
|
|
product_name=item.product.name,
|
|
|
|
|
product_image=item.product.image,
|
|
|
|
|
product_status=item.product.status,
|
|
|
|
|
sort_order=item.sort_order,
|
|
|
|
|
is_pinned=item.is_pinned,
|
|
|
|
|
created_at=item.created_at,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/compare-tags/{tag_id}/items/{item_id}", response=MessageOut, auth=JWTAuth())
|
|
|
|
|
def delete_compare_tag_item(request, tag_id: int, item_id: int):
|
|
|
|
|
require_admin(request.auth)
|
|
|
|
|
deleted, _ = ComparisonTagItem.objects.filter(id=item_id, tag_id=tag_id).delete()
|
|
|
|
|
if not deleted:
|
|
|
|
|
raise HttpError(404, "标签商品不存在")
|
|
|
|
|
return MessageOut(message="标签商品已移除")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _record_price_history(product_ids: Optional[List[int]] = None, force: bool = False):
|
|
|
|
|
queryset = ProductPrice.objects.select_related("product", "website").all()
|
|
|
|
|
if product_ids:
|
|
|
|
|
queryset = queryset.filter(product_id__in=product_ids)
|
|
|
|
|
created = 0
|
|
|
|
|
skipped = 0
|
|
|
|
|
for price in queryset:
|
|
|
|
|
latest = ProductPriceHistory.objects.filter(
|
|
|
|
|
product_id=price.product_id,
|
|
|
|
|
website_id=price.website_id,
|
|
|
|
|
).order_by("-recorded_at").first()
|
|
|
|
|
if force or (not latest or latest.price != price.price):
|
|
|
|
|
ProductPriceHistory.objects.create(
|
|
|
|
|
product_id=price.product_id,
|
|
|
|
|
website_id=price.website_id,
|
|
|
|
|
price=price.price,
|
|
|
|
|
)
|
|
|
|
|
created += 1
|
|
|
|
|
else:
|
|
|
|
|
skipped += 1
|
|
|
|
|
return created, skipped
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/price-history/backfill/", response=BackfillHistoryOut, auth=JWTAuth())
|
|
|
|
|
def backfill_price_history(request, data: BackfillHistoryIn):
|
|
|
|
|
require_admin(request.auth)
|
|
|
|
|
created, skipped = _record_price_history(data.product_ids, force=False)
|
|
|
|
|
return BackfillHistoryOut(created=created, skipped=skipped)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/compare-tags/{tag_id}/backfill-history/", response=BackfillHistoryOut, auth=JWTAuth())
|
|
|
|
|
def backfill_tag_price_history(request, tag_id: int):
|
|
|
|
|
require_admin(request.auth)
|
|
|
|
|
product_ids = list(
|
|
|
|
|
ComparisonTagItem.objects.filter(tag_id=tag_id).values_list("product_id", flat=True)
|
|
|
|
|
)
|
|
|
|
|
created, skipped = _record_price_history(product_ids, force=False)
|
|
|
|
|
return BackfillHistoryOut(created=created, skipped=skipped)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/price-history/record/", response=BackfillHistoryOut, auth=JWTAuth())
|
|
|
|
|
def record_price_history(request, data: RecordHistoryIn):
|
|
|
|
|
require_admin(request.auth)
|
|
|
|
|
created, skipped = _record_price_history(data.product_ids, force=data.force)
|
|
|
|
|
return BackfillHistoryOut(created=created, skipped=skipped)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/compare-tags/{tag_id}/record-history/", response=BackfillHistoryOut, auth=JWTAuth())
|
|
|
|
|
def record_tag_price_history(request, tag_id: int, data: RecordHistoryIn):
|
|
|
|
|
require_admin(request.auth)
|
|
|
|
|
product_ids = list(
|
|
|
|
|
ComparisonTagItem.objects.filter(tag_id=tag_id).values_list("product_id", flat=True)
|
|
|
|
|
)
|
|
|
|
|
created, skipped = _record_price_history(product_ids, force=data.force)
|
|
|
|
|
return BackfillHistoryOut(created=created, skipped=skipped)
|