""" Admin API routes for managing core data. """ from typing import List, Optional from datetime import datetime from django.utils import timezone from ninja import Router, Schema from ninja.errors import HttpError from ninja_jwt.authentication import JWTAuth from ninja.pagination import paginate, PageNumberPagination from apps.users.models import User from apps.products.models import Product, Website, Category, ComparisonTag, ComparisonTagItem, ProductPrice, ProductPriceHistory from apps.bounties.models import Bounty, BountyDispute, PaymentEvent router = Router() def require_admin(user): if not user or user.role != 'admin' or not user.is_active: raise HttpError(403, "仅管理员可访问") class UserOut(Schema): id: int open_id: str name: Optional[str] = None role: str is_active: bool created_at: datetime class UserUpdateIn(Schema): role: Optional[str] = None is_active: Optional[bool] = None class SimpleOut(Schema): id: int name: str class MessageOut(Schema): message: str 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 created_at: datetime class Config: from_attributes = True @staticmethod def resolve_reward(obj): return str(obj.reward) class PaymentEventOut(Schema): id: int event_id: str event_type: str bounty_id: Optional[int] = None success: bool processed_at: datetime class Config: from_attributes = True class DisputeOut(Schema): id: int bounty_id: int initiator_id: int status: str created_at: datetime class Config: from_attributes = True @router.get("/users/", response=List[UserOut], auth=JWTAuth()) @paginate(PageNumberPagination, page_size=20) def list_users(request): require_admin(request.auth) return User.objects.all().order_by('-created_at') @router.patch("/users/{user_id}", response=UserOut, auth=JWTAuth()) def update_user(request, user_id: int, data: UserUpdateIn): require_admin(request.auth) try: user = User.objects.get(id=user_id) except User.DoesNotExist: raise HttpError(404, "用户不存在") update_data = data.dict(exclude_unset=True) for key, value in update_data.items(): setattr(user, key, value) user.save() return user @router.get("/categories/", response=List[SimpleOut], auth=JWTAuth()) @paginate(PageNumberPagination, page_size=50) def list_categories(request): require_admin(request.auth) return Category.objects.values('id', 'name').order_by('name') @router.get("/websites/", response=List[SimpleOut], auth=JWTAuth()) @paginate(PageNumberPagination, page_size=50) def list_websites(request): require_admin(request.auth) return Website.objects.values('id', 'name').order_by('name') @router.get("/products/", response=List[SimpleOut], auth=JWTAuth()) @paginate(PageNumberPagination, page_size=50) def list_products(request): require_admin(request.auth) return Product.objects.values('id', 'name').order_by('-created_at') @router.get("/bounties/", response=List[BountyAdminOut], auth=JWTAuth()) @paginate(PageNumberPagination, page_size=20) def list_bounties(request, status: Optional[str] = None): require_admin(request.auth) queryset = Bounty.objects.select_related("publisher", "acceptor").all().order_by('-created_at') if status: queryset = queryset.filter(status=status) return queryset @router.get("/disputes/", response=List[DisputeOut], auth=JWTAuth()) @paginate(PageNumberPagination, page_size=20) 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) return disputes @router.get("/payments/", response=List[PaymentEventOut], auth=JWTAuth()) @paginate(PageNumberPagination, page_size=20) def list_payment_events(request): require_admin(request.auth) 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 images: List[str] = [] 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 class ProductImagesIn(Schema): """Product images update input schema.""" images: List[str] = [] image: Optional[str] = None 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 @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 @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 # ==================== 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)