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>
This commit is contained in:
2026-09-24 05:03:27 +03:30
parent 147af3ac10
commit c874c186b1
8 changed files with 83 additions and 4 deletions

View File

@@ -24,3 +24,8 @@ class NotificationListResponseSerializer(serializers.Serializer):
class MarkReadResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
class MarkAllReadResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
updated = serializers.IntegerField()

View File

@@ -146,3 +146,29 @@ class NotificationPayloadShapeTests(TestCase):
r = self.client.get('/api/notifications', HTTP_AUTHORIZATION=f'Bearer {self.token}')
self.assertEqual(r.data['items'][0]['body'], '')
class MarkAllReadTests(TestCase):
"""دکمه‌ی «همه خوانده شد» با یک درخواست، نه یکی برای هر اعلان."""
def setUp(self):
from django.core.cache import cache
cache.clear()
from accounts.authentication import issue_access_token
from accounts.models import User
self.me = User.objects.create(phone='09120000021')
self.other = User.objects.create(phone='09120000022')
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.me.id)}'}
for user in (self.me, self.me, self.other):
Notification.objects.create(user_id=user.id, type='xp_earned', title='x')
def test_marks_only_my_unread_notifications(self):
response = self.client.patch('/api/notifications/read-all', **self.auth)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {'success': True, 'updated': 2})
self.assertFalse(Notification.objects.filter(user_id=self.me.id, is_read=False).exists())
self.assertTrue(Notification.objects.filter(user_id=self.other.id, is_read=False).exists())
def test_requires_sign_in(self):
self.assertEqual(self.client.patch('/api/notifications/read-all').status_code, 401)

View File

@@ -1,8 +1,9 @@
from django.urls import path
from .views import NotificationListView, NotificationMarkReadView
from .views import NotificationListView, NotificationMarkAllReadView, NotificationMarkReadView
urlpatterns = [
path('notifications', NotificationListView.as_view(), name='notifications-list'),
path('notifications/read-all', NotificationMarkAllReadView.as_view(), name='notifications-mark-all-read'),
path('notifications/<int:id>/read', NotificationMarkReadView.as_view(), name='notifications-mark-read'),
]

View File

@@ -6,7 +6,9 @@ from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from .models import Notification
from .serializers import MarkReadResponseSerializer, NotificationListResponseSerializer
from .serializers import (
MarkAllReadResponseSerializer, MarkReadResponseSerializer, NotificationListResponseSerializer,
)
# سقفِ تعدادِ اعلانِ برگشتی؛ جلوی رشدِ نامحدودِ پاسخ برای کاربرِ قدیمی رو
# می‌گیره. صفحه‌بندیِ واقعی (cursor) وقتی فرانت infinite-scroll اضافه کنه.
@@ -60,3 +62,25 @@ class NotificationMarkReadView(APIView):
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)