540 lines
18 KiB
Python
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
|
|
]
|