throttle hot read endpoints and cap unbounded list responses

progress/views.py
  - GuestAwareReadThrottle (same guest-by-IP pattern as the other apps);
    throttle_scope='read' on UserSummaryView, StreakCalendarView,
    LeaderboardView.
notifications/views.py
  - GuestAwareReadThrottle + throttle_scope='read' on NotificationListView.
  - list capped at NOTIFICATIONS_MAX=100 newest (Meta.ordering already
    newest-first); unreadCount still counted over the full set.
notifications/tests.py
  - 150 rows -> 100 returned newest-first, unreadCount = 75.
rag/views.py
  - ChunkListView: explicit order_by('-id') before the existing [:200] cap.
ai_tutor/views.py
  - ConversationMessagesView: return the newest 200 messages in
    chronological order instead of the whole conversation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 08:45:43 +03:30
parent 35218bf6a1
commit ff57adbc5f
5 changed files with 78 additions and 4 deletions

View File

@@ -511,7 +511,9 @@ class ConversationMessagesView(APIView):
@extend_schema(responses=ConversationMessagesResponseSerializer, tags=['AI Tutor (ai_tutor)']) @extend_schema(responses=ConversationMessagesResponseSerializer, tags=['AI Tutor (ai_tutor)'])
def get(self, request, conversation_id): def get(self, request, conversation_id):
conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=request.user.id) conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=request.user.id)
messages = conversation.messages.all() # آخرین ۲۰۰ پیام، به ترتیبِ زمانی (قدیمی→جدید). جلوی رشدِ نامحدودِ پاسخ
# برای یک مکالمه‌ی خیلی طولانی رو می‌گیره.
messages = reversed(list(conversation.messages.order_by('-id')[:200]))
data = { data = {
'conversationId': str(conversation.id), 'conversationId': str(conversation.id),

View File

@@ -80,3 +80,31 @@ class NotifyInvalidChannelTests(TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
notify(user_id=3, type='order_paid', title='t', channel='email') notify(user_id=3, type='order_paid', title='t', channel='email')
self.assertEqual(Notification.objects.count(), 0) self.assertEqual(Notification.objects.count(), 0)
from django.core.cache import cache
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
class NotificationListCapTests(TestCase):
def setUp(self):
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='dev-notif-cap', is_guest=True, name='n')
self.token = issue_access_token(self.user.id)
def test_returns_at_most_100_newest_with_correct_unread_count(self):
Notification.objects.bulk_create([
Notification(user_id=self.user.id, type='xp_earned', title=f't{i}', is_read=(i % 2 == 0))
for i in range(150)
])
r = self.client.get('/api/notifications', HTTP_AUTHORIZATION=f'Bearer {self.token}')
self.assertEqual(r.status_code, 200)
self.assertEqual(len(r.data['items']), 100)
self.assertEqual(r.data['unreadCount'], 75) # از کلِ ۱۵۰، نه از ۱۰۰ برگشتی
# جدیدترین اول (Meta.ordering = -created_at؛ اینجا هم‌زمان → -id غالب)
ids = [it['id'] if isinstance(it, dict) else it.id for it in r.data['items']]
self.assertEqual(ids, sorted(ids, reverse=True))

View File

@@ -2,23 +2,44 @@ from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
from .models import Notification from .models import Notification
from .serializers import MarkReadResponseSerializer, NotificationListResponseSerializer from .serializers import 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): class NotificationListView(APIView):
"""GET /api/notifications — فهرست اعلان‌های کاربر (جدیدترین اول)""" """GET /api/notifications — فهرست اعلان‌های کاربر (جدیدترین اول)"""
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
throttle_scope = 'read'
throttle_classes = [GuestAwareReadThrottle]
@extend_schema(responses=NotificationListResponseSerializer, tags=['Notifications (notifications)']) @extend_schema(responses=NotificationListResponseSerializer, tags=['Notifications (notifications)'])
def get(self, request): def get(self, request):
qs = Notification.objects.filter(user_id=request.user.id) qs = Notification.objects.filter(user_id=request.user.id) # Meta.ordering: جدیدترین اول
unread_count = qs.filter(is_read=False).count() unread_count = qs.filter(is_read=False).count()
data = {'items': list(qs), 'unreadCount': unread_count} data = {'items': list(qs[:NOTIFICATIONS_MAX]), 'unreadCount': unread_count}
return Response(NotificationListResponseSerializer(data).data) return Response(NotificationListResponseSerializer(data).data)

View File

@@ -2,9 +2,25 @@ from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
from .models import LeaderboardEntry, StreakRecord, UserHearts, UserStats from .models import LeaderboardEntry, StreakRecord, UserHearts, UserStats
class GuestAwareReadThrottle(ScopedRateThrottle):
"""همون الگوی GuestAwareContentThrottle: کاربرِ مهمان (هر deviceId یک pk
تازه) با IP کلید می‌خوره تا چرخشِ حساب سقفِ read رو دور نزنه."""
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}
from .serializers import ( from .serializers import (
LeaderboardResponseSerializer, LeaderboardResponseSerializer,
StreakCalendarResponseSerializer, StreakCalendarResponseSerializer,
@@ -17,6 +33,8 @@ class UserSummaryView(APIView):
"""GET /api/user/summary — پروفایل + آمار + استریک (ادغام‌شده)""" """GET /api/user/summary — پروفایل + آمار + استریک (ادغام‌شده)"""
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
throttle_scope = 'read'
throttle_classes = [GuestAwareReadThrottle]
@extend_schema(responses=UserSummaryResponseSerializer, tags=['Progress & Gamification (progress)']) @extend_schema(responses=UserSummaryResponseSerializer, tags=['Progress & Gamification (progress)'])
def get(self, request): def get(self, request):
@@ -45,6 +63,8 @@ class StreakCalendarView(APIView):
"""GET /api/user/streak/calendar — تقویم کامل روزهای فعال""" """GET /api/user/streak/calendar — تقویم کامل روزهای فعال"""
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
throttle_scope = 'read'
throttle_classes = [GuestAwareReadThrottle]
@extend_schema(responses=StreakCalendarResponseSerializer, tags=['Progress & Gamification (progress)']) @extend_schema(responses=StreakCalendarResponseSerializer, tags=['Progress & Gamification (progress)'])
def get(self, request): def get(self, request):
@@ -62,6 +82,9 @@ class StreakCalendarView(APIView):
class LeaderboardView(APIView): class LeaderboardView(APIView):
"""GET /api/leaderboard?period= — رتبه‌بندی هفتگی/ماهانه""" """GET /api/leaderboard?period= — رتبه‌بندی هفتگی/ماهانه"""
throttle_scope = 'read'
throttle_classes = [GuestAwareReadThrottle]
@extend_schema( @extend_schema(
parameters=[OpenApiParameter('period', OpenApiTypes.STR, required=False, parameters=[OpenApiParameter('period', OpenApiTypes.STR, required=False,
enum=['weekly', 'all_time'])], enum=['weekly', 'all_time'])],

View File

@@ -152,6 +152,6 @@ class ChunkListView(APIView):
qs = qs.filter(lesson_id=lesson_id) qs = qs.filter(lesson_id=lesson_id)
if subject: if subject:
qs = qs.filter(subject=subject) qs = qs.filter(subject=subject)
rows = list(qs[:200]) rows = list(qs.order_by('-id')[:200])
data = {'count': len(rows), 'results': ChunkSerializer(rows, many=True).data} data = {'count': len(rows), 'results': ChunkSerializer(rows, many=True).data}
return Response(ChunkListResponseSerializer(data).data) return Response(ChunkListResponseSerializer(data).data)