Files
MathApp/notifications/views.py
Zareian c874c186b1 see the real client IP behind ArvanCloud, and mark all notifications read in one call
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>
2026-09-24 05:03:27 +03:30

87 lines
4.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)