diff --git a/ai_tutor/views.py b/ai_tutor/views.py index 80abe87..02f4391 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -511,7 +511,9 @@ class ConversationMessagesView(APIView): @extend_schema(responses=ConversationMessagesResponseSerializer, tags=['AI Tutor (ai_tutor)']) def get(self, request, conversation_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 = { 'conversationId': str(conversation.id), diff --git a/notifications/tests.py b/notifications/tests.py index 7c3b315..3085644 100644 --- a/notifications/tests.py +++ b/notifications/tests.py @@ -80,3 +80,31 @@ class NotifyInvalidChannelTests(TestCase): with self.assertRaises(ValueError): notify(user_id=3, type='order_paid', title='t', channel='email') 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)) diff --git a/notifications/views.py b/notifications/views.py index 8812fac..ce1ccf2 100644 --- a/notifications/views.py +++ b/notifications/views.py @@ -2,23 +2,44 @@ 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 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) + qs = Notification.objects.filter(user_id=request.user.id) # Meta.ordering: جدیدترین اول 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) diff --git a/progress/views.py b/progress/views.py index 6bf66ab..9851bfb 100644 --- a/progress/views.py +++ b/progress/views.py @@ -2,9 +2,25 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, 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 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 ( LeaderboardResponseSerializer, StreakCalendarResponseSerializer, @@ -17,6 +33,8 @@ class UserSummaryView(APIView): """GET /api/user/summary — پروفایل + آمار + استریک (ادغام‌شده)""" permission_classes = [IsAuthenticated] + throttle_scope = 'read' + throttle_classes = [GuestAwareReadThrottle] @extend_schema(responses=UserSummaryResponseSerializer, tags=['Progress & Gamification (progress)']) def get(self, request): @@ -45,6 +63,8 @@ class StreakCalendarView(APIView): """GET /api/user/streak/calendar — تقویم کامل روزهای فعال""" permission_classes = [IsAuthenticated] + throttle_scope = 'read' + throttle_classes = [GuestAwareReadThrottle] @extend_schema(responses=StreakCalendarResponseSerializer, tags=['Progress & Gamification (progress)']) def get(self, request): @@ -62,6 +82,9 @@ class StreakCalendarView(APIView): class LeaderboardView(APIView): """GET /api/leaderboard?period= — رتبه‌بندی هفتگی/ماهانه""" + throttle_scope = 'read' + throttle_classes = [GuestAwareReadThrottle] + @extend_schema( parameters=[OpenApiParameter('period', OpenApiTypes.STR, required=False, enum=['weekly', 'all_time'])], diff --git a/rag/views.py b/rag/views.py index c5d9cd5..2fb1027 100644 --- a/rag/views.py +++ b/rag/views.py @@ -152,6 +152,6 @@ class ChunkListView(APIView): qs = qs.filter(lesson_id=lesson_id) if 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} return Response(ChunkListResponseSerializer(data).data)