Production is ArvanCloud -> nginx -> gunicorn, but NUM_PROXIES was 1, so DRF took ArvanCloud's edge IP as the client: every guest and every OTP request behind one edge shared a single throttle bucket (20 guest sessions an hour for everyone). Default is now 2, and /api/health reports clientIp so the proxy count can be checked from outside. PATCH /api/notifications/read-all replaces the frontend's one-PATCH-per-item loop, which spent the shared "read" budget 75 requests at a time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
87 lines
4.0 KiB
Python
87 lines
4.0 KiB
Python
from django.shortcuts import get_object_or_404
|
||
from drf_spectacular.utils import extend_schema
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from rest_framework.response import Response
|
||
from rest_framework.throttling import ScopedRateThrottle
|
||
from rest_framework.views import APIView
|
||
|
||
from .models import Notification
|
||
from .serializers import (
|
||
MarkAllReadResponseSerializer, MarkReadResponseSerializer, NotificationListResponseSerializer,
|
||
)
|
||
|
||
# سقفِ تعدادِ اعلانِ برگشتی؛ جلوی رشدِ نامحدودِ پاسخ برای کاربرِ قدیمی رو
|
||
# میگیره. صفحهبندیِ واقعی (cursor) وقتی فرانت infinite-scroll اضافه کنه.
|
||
NOTIFICATIONS_MAX = 100
|
||
|
||
|
||
class GuestAwareReadThrottle(ScopedRateThrottle):
|
||
"""همون الگوی GuestAware*Throttle: مهمان با IP کلید میخوره."""
|
||
|
||
def get_cache_key(self, request, view):
|
||
user = request.user
|
||
if user and user.is_authenticated and getattr(user, 'is_guest', False):
|
||
ident = f'guest-ip:{self.get_ident(request)}'
|
||
elif user and user.is_authenticated:
|
||
ident = user.pk
|
||
else:
|
||
ident = self.get_ident(request)
|
||
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
||
|
||
|
||
class NotificationListView(APIView):
|
||
"""GET /api/notifications — فهرست اعلانهای کاربر (جدیدترین اول)"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
throttle_scope = 'read'
|
||
throttle_classes = [GuestAwareReadThrottle]
|
||
|
||
@extend_schema(responses=NotificationListResponseSerializer, tags=['Notifications (notifications)'])
|
||
def get(self, request):
|
||
qs = Notification.objects.filter(user_id=request.user.id) # Meta.ordering: جدیدترین اول
|
||
unread_count = qs.filter(is_read=False).count()
|
||
|
||
data = {'items': list(qs[:NOTIFICATIONS_MAX]), 'unreadCount': unread_count}
|
||
return Response(NotificationListResponseSerializer(data).data)
|
||
|
||
|
||
class NotificationMarkReadView(APIView):
|
||
"""PATCH /api/notifications/{id}/read — علامتگذاری یک اعلان بهعنوان خواندهشده"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
# request=None لازم است: بدونِ آن spectacular نمیتواند بدنهی PATCH را حدس
|
||
# بزند و **کلِ ویو را از اسکیما کنار میگذارد** — یعنی این مسیر در قراردادی
|
||
# که فرانت از آن کلاینت میسازد اصلاً دیده نمیشود.
|
||
@extend_schema(request=None, responses=MarkReadResponseSerializer,
|
||
tags=['Notifications (notifications)'])
|
||
def patch(self, request, id):
|
||
notification = get_object_or_404(Notification, pk=id, user_id=request.user.id)
|
||
if not notification.is_read:
|
||
notification.is_read = True
|
||
notification.save(update_fields=['is_read'])
|
||
|
||
return Response(MarkReadResponseSerializer({'success': True}).data)
|
||
|
||
|
||
class NotificationMarkAllReadView(APIView):
|
||
"""
|
||
PATCH /api/notifications/read-all — همهی اعلانهای کاربر خواندهشده.
|
||
|
||
دکمهی «همه خوانده شد» پیش از این برای هر اعلان یک درخواستِ جدا میزد؛
|
||
حسابی با ۷۵ اعلان یعنی ۷۵ درخواستِ همزمان که سهمِ throttleِ read را
|
||
(همان که /home و بقیه مصرف میکنند) یکجا میخورد.
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
throttle_scope = 'read'
|
||
throttle_classes = [GuestAwareReadThrottle]
|
||
|
||
@extend_schema(request=None, responses=MarkAllReadResponseSerializer,
|
||
tags=['Notifications (notifications)'])
|
||
def patch(self, request):
|
||
updated = Notification.objects.filter(
|
||
user_id=request.user.id, is_read=False,
|
||
).update(is_read=True)
|
||
return Response(MarkAllReadResponseSerializer({'success': True, 'updated': updated}).data)
|