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

540 lines
18 KiB
Python

"""
Favorites API routes for collections, tags and price monitoring.
"""
from typing import List, Optional
import csv
from decimal import Decimal
from ninja import Router
from ninja.errors import HttpError
from ninja_jwt.authentication import JWTAuth
from ninja.pagination import paginate, PageNumberPagination
from django.shortcuts import get_object_or_404
from django.http import HttpResponse
from django.utils import timezone
from django.db import transaction
from .models import Favorite, FavoriteTag, FavoriteTagMapping, PriceMonitor, PriceHistory
from .schemas import (
FavoriteOut, FavoriteIn,
FavoriteTagOut, FavoriteTagIn, FavoriteTagUpdate, FavoriteTagMappingIn,
PriceMonitorOut, PriceMonitorIn, PriceMonitorUpdate,
PriceHistoryOut, RecordPriceIn,
MessageOut,
)
from apps.products.models import Product, Website, ProductPrice
from apps.notifications.models import Notification
from apps.notifications.utils import should_notify
router = Router()
def serialize_favorite(favorite):
"""Serialize favorite with related data."""
tags = [
FavoriteTagOut(
id=mapping.tag.id,
user_id=mapping.tag.user_id,
name=mapping.tag.name,
color=mapping.tag.color,
description=mapping.tag.description,
created_at=mapping.tag.created_at,
)
for mapping in favorite.tag_mappings.select_related('tag').all()
]
return FavoriteOut(
id=favorite.id,
user_id=favorite.user_id,
product_id=favorite.product_id,
product_name=favorite.product.name if favorite.product else None,
product_image=favorite.product.image if favorite.product else None,
website_id=favorite.website_id,
website_name=favorite.website.name if favorite.website else None,
website_logo=favorite.website.logo if favorite.website else None,
tags=tags,
created_at=favorite.created_at,
)
def record_price_for_monitor(monitor: PriceMonitor, price: Decimal):
"""Record price history and update monitor stats."""
price_change = None
percent_change = None
if monitor.current_price:
price_change = price - monitor.current_price
if monitor.current_price > 0:
percent_change = (price_change / monitor.current_price) * 100
history = PriceHistory.objects.create(
monitor=monitor,
price=price,
price_change=price_change,
percent_change=percent_change,
)
monitor.current_price = price
if monitor.lowest_price is None or price < monitor.lowest_price:
monitor.lowest_price = price
if monitor.highest_price is None or price > monitor.highest_price:
monitor.highest_price = price
should_alert = (
monitor.notify_enabled and
monitor.notify_on_target and
monitor.target_price is not None and
price <= monitor.target_price and
(monitor.last_notified_price is None or price < monitor.last_notified_price)
)
if should_alert and should_notify(monitor.user, Notification.Type.PRICE_ALERT):
Notification.objects.create(
user=monitor.user,
type=Notification.Type.PRICE_ALERT,
title="价格已到达目标",
content=f"您关注的商品价格已降至 {price}",
related_id=monitor.favorite_id,
related_type="favorite",
)
monitor.last_notified_price = price
monitor.save()
return history
# ==================== Favorite Routes ====================
@router.get("/", response=List[FavoriteOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=20)
def list_favorites(request, tag_id: Optional[int] = None):
"""Get current user's favorites."""
queryset = Favorite.objects.select_related('product', 'website').filter(
user=request.auth
).prefetch_related('tag_mappings', 'tag_mappings__tag')
if tag_id:
queryset = queryset.filter(tag_mappings__tag_id=tag_id).distinct()
return [serialize_favorite(f) for f in queryset]
@router.get("/export/", auth=JWTAuth())
def export_favorites_csv(request):
"""Export current user's favorites to CSV."""
favorites = list(
Favorite.objects.select_related("product", "website")
.filter(user=request.auth)
.prefetch_related("tag_mappings", "tag_mappings__tag")
)
if not favorites:
response = HttpResponse(content_type="text/csv; charset=utf-8")
response["Content-Disposition"] = 'attachment; filename="favorites.csv"'
response.write("\ufeff")
writer = csv.writer(response)
writer.writerow(
["product_id", "product_name", "website_id", "website_name", "price", "currency", "last_checked", "tags", "created_at"]
)
return response
product_ids = {f.product_id for f in favorites}
website_ids = {f.website_id for f in favorites}
price_map = {}
for row in ProductPrice.objects.filter(
product_id__in=product_ids, website_id__in=website_ids
).values("product_id", "website_id", "price", "currency", "last_checked"):
key = (row["product_id"], row["website_id"])
existing = price_map.get(key)
if not existing or row["last_checked"] > existing["last_checked"]:
price_map[key] = row
response = HttpResponse(content_type="text/csv; charset=utf-8")
filename = f'favorites_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv'
response["Content-Disposition"] = f'attachment; filename="{filename}"'
response.write("\ufeff")
writer = csv.writer(response)
writer.writerow(
["product_id", "product_name", "website_id", "website_name", "price", "currency", "last_checked", "tags", "created_at"]
)
for favorite in favorites:
tags = ",".join([m.tag.name for m in favorite.tag_mappings.all()])
price = price_map.get((favorite.product_id, favorite.website_id))
writer.writerow(
[
favorite.product_id,
favorite.product.name if favorite.product else "",
favorite.website_id,
favorite.website.name if favorite.website else "",
price["price"] if price else "",
price["currency"] if price else "",
price["last_checked"].isoformat() if price else "",
tags,
favorite.created_at.isoformat(),
]
)
return response
@router.get("/{favorite_id}", response=FavoriteOut, auth=JWTAuth())
def get_favorite(request, favorite_id: int):
"""Get a specific favorite."""
favorite = get_object_or_404(
Favorite.objects.select_related('product', 'website').prefetch_related(
'tag_mappings', 'tag_mappings__tag'
),
id=favorite_id,
user=request.auth
)
return serialize_favorite(favorite)
@router.get("/check/", auth=JWTAuth())
def is_favorited(request, product_id: int, website_id: int):
"""Check if a product is favorited."""
favorite_id = Favorite.objects.filter(
user=request.auth,
product_id=product_id,
website_id=website_id
).values_list("id", flat=True).first()
return {"is_favorited": bool(favorite_id), "favorite_id": favorite_id}
@router.post("/", response=FavoriteOut, auth=JWTAuth())
def add_favorite(request, data: FavoriteIn):
"""Add a product to favorites."""
# Check if already favorited
existing = Favorite.objects.filter(
user=request.auth,
product_id=data.product_id,
website_id=data.website_id
).first()
if existing:
return serialize_favorite(existing)
favorite = Favorite.objects.create(
user=request.auth,
product_id=data.product_id,
website_id=data.website_id,
)
# Refresh with relations
favorite = Favorite.objects.select_related('product', 'website').prefetch_related(
'tag_mappings', 'tag_mappings__tag'
).get(id=favorite.id)
return serialize_favorite(favorite)
@router.delete("/{favorite_id}", response=MessageOut, auth=JWTAuth())
def remove_favorite(request, favorite_id: int):
"""Remove a product from favorites."""
favorite = get_object_or_404(Favorite, id=favorite_id, user=request.auth)
favorite.delete()
return MessageOut(message="已取消收藏", success=True)
# ==================== Tag Routes ====================
@router.get("/tags/", response=List[FavoriteTagOut], auth=JWTAuth())
def list_tags(request):
"""Get current user's tags."""
tags = FavoriteTag.objects.filter(user=request.auth)
return [
FavoriteTagOut(
id=t.id,
user_id=t.user_id,
name=t.name,
color=t.color,
description=t.description,
created_at=t.created_at,
)
for t in tags
]
@router.post("/tags/", response=FavoriteTagOut, auth=JWTAuth())
def create_tag(request, data: FavoriteTagIn):
"""Create a new tag."""
# Check if tag with same name exists
if FavoriteTag.objects.filter(user=request.auth, name=data.name).exists():
raise HttpError(400, "Tag with this name already exists")
tag = FavoriteTag.objects.create(
user=request.auth,
**data.dict()
)
return FavoriteTagOut(
id=tag.id,
user_id=tag.user_id,
name=tag.name,
color=tag.color,
description=tag.description,
created_at=tag.created_at,
)
@router.patch("/tags/{tag_id}", response=FavoriteTagOut, auth=JWTAuth())
def update_tag(request, tag_id: int, data: FavoriteTagUpdate):
"""Update a tag."""
tag = get_object_or_404(FavoriteTag, id=tag_id, user=request.auth)
update_data = data.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(tag, key, value)
tag.save()
return FavoriteTagOut(
id=tag.id,
user_id=tag.user_id,
name=tag.name,
color=tag.color,
description=tag.description,
created_at=tag.created_at,
)
@router.delete("/tags/{tag_id}", response=MessageOut, auth=JWTAuth())
def delete_tag(request, tag_id: int):
"""Delete a tag."""
tag = get_object_or_404(FavoriteTag, id=tag_id, user=request.auth)
tag.delete()
return MessageOut(message="标签已删除", success=True)
@router.post("/{favorite_id}/tags/", response=MessageOut, auth=JWTAuth())
def add_tag_to_favorite(request, favorite_id: int, data: FavoriteTagMappingIn):
"""Add a tag to a favorite."""
favorite = get_object_or_404(Favorite, id=favorite_id, user=request.auth)
tag = get_object_or_404(FavoriteTag, id=data.tag_id, user=request.auth)
FavoriteTagMapping.objects.get_or_create(favorite=favorite, tag=tag)
return MessageOut(message="标签已添加", success=True)
@router.delete("/{favorite_id}/tags/{tag_id}", response=MessageOut, auth=JWTAuth())
def remove_tag_from_favorite(request, favorite_id: int, tag_id: int):
"""Remove a tag from a favorite."""
mapping = get_object_or_404(
FavoriteTagMapping,
favorite_id=favorite_id,
tag_id=tag_id,
favorite__user=request.auth
)
mapping.delete()
return MessageOut(message="标签已移除", success=True)
# ==================== Price Monitor Routes ====================
@router.get("/{favorite_id}/monitor/", response=Optional[PriceMonitorOut], auth=JWTAuth())
def get_price_monitor(request, favorite_id: int):
"""Get price monitor for a favorite."""
favorite = get_object_or_404(Favorite, id=favorite_id, user=request.auth)
try:
monitor = PriceMonitor.objects.get(favorite=favorite)
return PriceMonitorOut(
id=monitor.id,
favorite_id=monitor.favorite_id,
user_id=monitor.user_id,
current_price=monitor.current_price,
target_price=monitor.target_price,
lowest_price=monitor.lowest_price,
highest_price=monitor.highest_price,
notify_enabled=monitor.notify_enabled,
notify_on_target=monitor.notify_on_target,
last_notified_price=monitor.last_notified_price,
is_active=monitor.is_active,
created_at=monitor.created_at,
updated_at=monitor.updated_at,
)
except PriceMonitor.DoesNotExist:
return None
@router.post("/{favorite_id}/monitor/", response=PriceMonitorOut, auth=JWTAuth())
def create_price_monitor(request, favorite_id: int, data: PriceMonitorIn):
"""Create or update price monitor for a favorite."""
favorite = get_object_or_404(Favorite, id=favorite_id, user=request.auth)
monitor, created = PriceMonitor.objects.update_or_create(
favorite=favorite,
defaults={
'user': request.auth,
'target_price': data.target_price,
'is_active': data.is_active,
'notify_enabled': data.notify_enabled,
'notify_on_target': data.notify_on_target,
}
)
return PriceMonitorOut(
id=monitor.id,
favorite_id=monitor.favorite_id,
user_id=monitor.user_id,
current_price=monitor.current_price,
target_price=monitor.target_price,
lowest_price=monitor.lowest_price,
highest_price=monitor.highest_price,
notify_enabled=monitor.notify_enabled,
notify_on_target=monitor.notify_on_target,
last_notified_price=monitor.last_notified_price,
is_active=monitor.is_active,
created_at=monitor.created_at,
updated_at=monitor.updated_at,
)
@router.patch("/{favorite_id}/monitor/", response=PriceMonitorOut, auth=JWTAuth())
def update_price_monitor(request, favorite_id: int, data: PriceMonitorUpdate):
"""Update price monitor for a favorite."""
monitor = get_object_or_404(
PriceMonitor,
favorite_id=favorite_id,
user=request.auth
)
update_data = data.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(monitor, key, value)
monitor.save()
return PriceMonitorOut(
id=monitor.id,
favorite_id=monitor.favorite_id,
user_id=monitor.user_id,
current_price=monitor.current_price,
target_price=monitor.target_price,
lowest_price=monitor.lowest_price,
highest_price=monitor.highest_price,
notify_enabled=monitor.notify_enabled,
notify_on_target=monitor.notify_on_target,
last_notified_price=monitor.last_notified_price,
is_active=monitor.is_active,
created_at=monitor.created_at,
updated_at=monitor.updated_at,
)
@router.delete("/{favorite_id}/monitor/", response=MessageOut, auth=JWTAuth())
def delete_price_monitor(request, favorite_id: int):
"""Delete price monitor for a favorite."""
monitor = get_object_or_404(
PriceMonitor,
favorite_id=favorite_id,
user=request.auth
)
monitor.delete()
return MessageOut(message="价格监控已删除", success=True)
@router.get("/{favorite_id}/monitor/history/", response=List[PriceHistoryOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=50)
def get_price_history(request, favorite_id: int):
"""Get price history for a favorite's monitor."""
monitor = get_object_or_404(
PriceMonitor,
favorite_id=favorite_id,
user=request.auth
)
history = PriceHistory.objects.filter(monitor=monitor)
return [
PriceHistoryOut(
id=h.id,
monitor_id=h.monitor_id,
price=h.price,
price_change=h.price_change,
percent_change=h.percent_change,
recorded_at=h.recorded_at,
)
for h in history
]
@router.post("/{favorite_id}/monitor/record/", response=PriceHistoryOut, auth=JWTAuth())
def record_price(request, favorite_id: int, data: RecordPriceIn):
"""Record a new price for monitoring."""
monitor = get_object_or_404(
PriceMonitor,
favorite_id=favorite_id,
user=request.auth
)
history = record_price_for_monitor(monitor, data.price)
return PriceHistoryOut(
id=history.id,
monitor_id=history.monitor_id,
price=history.price,
price_change=history.price_change,
percent_change=history.percent_change,
recorded_at=history.recorded_at,
)
@router.post("/{favorite_id}/monitor/refresh/", response=PriceMonitorOut, auth=JWTAuth())
def refresh_price_monitor(request, favorite_id: int):
"""Refresh monitor price from product price data."""
favorite = get_object_or_404(Favorite, id=favorite_id, user=request.auth)
monitor = get_object_or_404(PriceMonitor, favorite=favorite)
price_record = ProductPrice.objects.filter(
product_id=favorite.product_id,
website_id=favorite.website_id
).order_by('-last_checked').first()
if not price_record:
raise HttpError(404, "未找到商品价格数据")
record_price_for_monitor(monitor, price_record.price)
return PriceMonitorOut(
id=monitor.id,
favorite_id=monitor.favorite_id,
user_id=monitor.user_id,
current_price=monitor.current_price,
target_price=monitor.target_price,
lowest_price=monitor.lowest_price,
highest_price=monitor.highest_price,
notify_enabled=monitor.notify_enabled,
notify_on_target=monitor.notify_on_target,
last_notified_price=monitor.last_notified_price,
is_active=monitor.is_active,
created_at=monitor.created_at,
updated_at=monitor.updated_at,
)
# ==================== All Monitors Route ====================
@router.get("/monitors/all/", response=List[PriceMonitorOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=20)
def list_all_monitors(request):
"""Get all price monitors for current user."""
monitors = PriceMonitor.objects.filter(user=request.auth)
return [
PriceMonitorOut(
id=m.id,
favorite_id=m.favorite_id,
user_id=m.user_id,
current_price=m.current_price,
target_price=m.target_price,
lowest_price=m.lowest_price,
highest_price=m.highest_price,
notify_enabled=m.notify_enabled,
notify_on_target=m.notify_on_target,
last_notified_price=m.last_notified_price,
is_active=m.is_active,
created_at=m.created_at,
updated_at=m.updated_at,
)
for m in monitors
]