make streaks and the leaderboard live, and stop three AI chats from freezing the API

Found in a deeper pass over what the frontend's flow actually touches:

- Nothing in the app ever wrote StreakRecord: "days in a row" was 0 forever
  for every real user. Answers and lesson/island completions now record the
  day (Tehran time); a broken chain shows 0. A streak error never fails the
  answer itself.
- /api/leaderboard only changed when someone ran rebuild_leaderboard by
  hand. It is now computed live (60s cache); weekly counts island XP too.
- gunicorn ran 3 sync workers with a 30s timeout, so every "ask Pi" stream
  pinned a whole worker (~10s, GapGPT buffers the answer) and three at once
  queued the entire API. gunicorn.conf.py (read automatically from the
  working directory) switches to gthread, 8 threads, 120s timeout.
- Every /api/ response now says Cache-Control: private, no-store, so turning
  on caching in the ArvanCloud panel can never serve one student another's data.
- /api/user/summary named no active plan ever ("subscription app not
  implemented yet" had long stopped being true).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 06:39:50 +03:30
parent c874c186b1
commit 701d8badcc
11 changed files with 375 additions and 27 deletions

49
progress/leaderboard.py Normal file
View File

@@ -0,0 +1,49 @@
"""
امتیازهای جدولِ رتبه‌بندی، مستقیم از فعالیتِ واقعی.
تا ۲۰۲۶-۰۹-۲۴ `GET /api/leaderboard` فقط جدولِ LeaderboardEntry را می‌خواند که
تنها با اجرای دستیِ `rebuild_leaderboard` پر می‌شد؛ XPی که کاربرِ تازه می‌گرفت
هیچ‌وقت در جدول دیده نمی‌شد. حالا view همین تابع را صدا می‌زند و نتیجه را
کوتاه‌مدت کش می‌کند، و دستورِ rebuild هم از همین تابع می‌خواند تا دو منبعِ
عدد از هم جدا نیفتند.
- `all_time`: همان `UserStats.xp` که در `/api/user/summary` هم دیده می‌شود.
- `weekly`: XPی درس‌ها و جزیره‌هایی که در `days` روزِ اخیر تمام شده‌اند —
همان دو جایی که `UserStats.xp` زیاد می‌شود. `UserAnswer.xp_earned` عمداً
شمرده نمی‌شود چون به `UserStats.xp` اضافه نمی‌شود.
"""
from collections import defaultdict
from django.db.models import Sum
from django.utils import timezone
PERIODS = ('weekly', 'all_time')
def compute_scores(period, days=7, limit=50, include_zero=False):
"""[(user_id, xp), ...] مرتب: XPی بیشتر جلوتر، در تساوی user_id کوچک‌تر.
پیش‌فرض فقط XPی مثبت؛ include_zero برای دستورِ rebuild است."""
if period == 'all_time':
from progress.models import UserStats
qs = UserStats.objects.all() if include_zero else UserStats.objects.filter(xp__gt=0)
rows = list(
qs.order_by('-xp', 'user_id')
.values_list('user_id', 'xp')[:limit]
)
return [(uid, xp) for uid, xp in rows]
if period != 'weekly':
return []
from content.models import UserLessonCompletion, UserNodeCompletion
since = timezone.now() - timezone.timedelta(days=days)
totals = defaultdict(int)
for model in (UserLessonCompletion, UserNodeCompletion):
for row in (
model.objects.filter(created_at__gte=since)
.values('user_id').annotate(total=Sum('xp_earned'))
):
totals[row['user_id']] += row['total'] or 0
scores = [(uid, xp) for uid, xp in totals.items() if include_zero or xp > 0]
scores.sort(key=lambda p: (-p[1], p[0]))
return scores[:limit]

View File

@@ -10,8 +10,11 @@ seed_test_data بود، یعنی `GET /api/leaderboard` روی هر سرورِ
- `all_time` از `UserStats.xp` خوانده می‌شود، چون همان عددی است که خودِ اپ
در طولِ عمرِ کاربر بالا می‌برد و در `/api/user/summary` هم نشان داده می‌شود.
- `weekly` از جمعِ `content.UserLessonCompletion.xp_earned` در بازه‌ی اخیر
ساخته می‌شود.
- `weekly` از جمعِ XPی درس‌ها و جزیره‌های تمام‌شده در بازه‌ی اخیر ساخته
می‌شود (`UserLessonCompletion` و `UserNodeCompletion`).
از ۲۰۲۶-۰۹-۲۴ خودِ `GET /api/leaderboard` زنده از همین منبع می‌خواند؛ این
دستور فقط جدولِ LeaderboardEntry را برای دیدن در ادمین پر می‌کند.
⚠️ چرا `UserAnswer.xp_earned` شمرده **نمی‌شود**: با اینکه آن فیلد پر می‌شود،
`QuestionAnswerView` هیچ‌وقت آن را به `UserStats.xp` اضافه نمی‌کند — تنها
@@ -30,8 +33,6 @@ idempotent است: ردیف‌های همان بازه اول پاک و دوبا
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Sum
from django.utils import timezone
PERIODS = ('weekly', 'all_time')
@@ -63,23 +64,12 @@ class Command(BaseCommand):
# -- جمع‌آوریِ امتیازها ---------------------------------------------------
def _collect(self, period, days):
if period == 'all_time':
from progress.models import UserStats
# values_list مادی می‌شود (list)، نه iterator — طبق درسِ
# ExecuteCampaignRealConcurrencyTests: کرسرِ بازِ خواندن روی SQLite
# در حلقه‌ای که می‌نویسد، جدول را برای هر نویسنده‌ی هم‌زمان قفل می‌کند.
return list(UserStats.objects.values_list('user_id', 'xp'))
from content.models import UserLessonCompletion
since = timezone.now() - timezone.timedelta(days=days)
rows = (
UserLessonCompletion.objects
.filter(created_at__gte=since)
.values('user_id')
.annotate(total=Sum('xp_earned'))
)
return [(r['user_id'], r['total'] or 0) for r in rows]
def _collect(self, period, days, include_zero=False):
# همان منبعی که GET /api/leaderboard زنده می‌خواند (progress/leaderboard.py)
# تا عددِ جدولِ ادمین و عددِ اپ از هم جدا نیفتند. limit بزرگ است چون
# سقف را خودِ دستور با --limit اعمال می‌کند.
from progress.leaderboard import compute_scores
return compute_scores(period, days=days, limit=10**9, include_zero=include_zero)
# -- نوشتن ---------------------------------------------------------------
@@ -87,7 +77,7 @@ class Command(BaseCommand):
from progress.models import LeaderboardEntry
days = opts['days']
scores = self._collect(period, days)
scores = self._collect(period, days, include_zero=opts['include_zero'])
if not opts['include_zero']:
scores = [(uid, xp) for uid, xp in scores if xp > 0]

59
progress/services.py Normal file
View File

@@ -0,0 +1,59 @@
"""
استریک: روزهای پیاپیِ فعالیت.
تا ۲۰۲۶-۰۹-۲۴ هیچ‌جای کد StreakRecord یا ActiveDay نمی‌نوشت — فقط
seed_test_data. یعنی «روز پیاپی» روی صفحه‌ی خانه برای هر کاربرِ واقعی برای
همیشه صفر بود و فقط کاربرهای دمو عدد داشتند.
«فعالیت» یعنی پاسخ به سوال یا تمام کردنِ درس/جزیره. هر روز (به وقتِ تهران،
TIME_ZONE) یک بار شمرده می‌شود؛ صدا زدنِ مکرر در یک روز بی‌اثر است.
"""
from datetime import timedelta
from django.db import transaction
from django.utils import timezone
from .models import ActiveDay, StreakRecord
def record_activity(user_id, today=None):
"""امروز را روزِ فعال ثبت می‌کند و استریک را جلو می‌برد. idempotent در یک روز."""
today = today or timezone.localdate()
with transaction.atomic():
StreakRecord.objects.get_or_create(user_id=user_id)
streak = StreakRecord.objects.select_for_update().get(user_id=user_id)
last = (
ActiveDay.objects.filter(streak=streak)
.order_by('-date').values_list('date', flat=True).first()
)
if last == today:
return streak
if last == today - timedelta(days=1):
streak.current_streak += 1
else:
streak.current_streak = 1
streak.longest_streak = max(streak.longest_streak, streak.current_streak)
streak.save(update_fields=['current_streak', 'longest_streak'])
ActiveDay.objects.get_or_create(streak=streak, date=today)
return streak
def effective_current_streak(streak, today=None):
"""
استریکی که الان باید نشان داده شود.
current_streak فقط در لحظه‌ی فعالیت به‌روز می‌شود؛ کاربری که دو روز
نیامده هنوز عددِ قبلی را در دیتابیس دارد. اگر آخرین روزِ فعال نه امروز
است نه دیروز، زنجیره شکسته و عددِ واقعی صفر است.
"""
if streak is None:
return 0
today = today or timezone.localdate()
last = (
streak.active_days.order_by('-date').values_list('date', flat=True).first()
)
if last is None or last < today - timedelta(days=1):
return 0
return streak.current_streak

View File

@@ -0,0 +1,131 @@
"""
استریک و جدولِ رتبه‌بندی از فعالیتِ واقعی زنده می‌شوند.
تا ۲۰۲۶-۰۹-۲۴ هیچ‌جای اپ StreakRecord نمی‌نوشت («روز پیاپی» برای هر کاربرِ
واقعی صفر می‌ماند) و جدولِ رتبه‌بندی فقط با اجرای دستیِ rebuild_leaderboard
عوض می‌شد.
"""
from datetime import date
from unittest import mock
from django.core.cache import cache
from django.test import TestCase
from accounts.authentication import issue_access_token
from accounts.models import User
from content.models import Choice, Grade, Lesson, Node, Question, Unit
from progress.models import StreakRecord
from progress.services import effective_current_streak, record_activity
class StreakTests(TestCase):
def test_consecutive_days_grow_the_streak(self):
record_activity(1, today=date(2026, 9, 20))
record_activity(1, today=date(2026, 9, 21))
streak = record_activity(1, today=date(2026, 9, 22))
self.assertEqual(streak.current_streak, 3)
self.assertEqual(streak.longest_streak, 3)
def test_same_day_counts_once(self):
record_activity(1, today=date(2026, 9, 20))
streak = record_activity(1, today=date(2026, 9, 20))
self.assertEqual(streak.current_streak, 1)
self.assertEqual(streak.active_days.count(), 1)
def test_a_missed_day_restarts_it_but_keeps_the_longest(self):
for day in (20, 21, 22):
record_activity(1, today=date(2026, 9, day))
streak = record_activity(1, today=date(2026, 9, 25))
self.assertEqual(streak.current_streak, 1)
self.assertEqual(streak.longest_streak, 3)
def test_shown_streak_is_zero_once_the_chain_breaks(self):
"""عددِ ذخیره‌شده فقط سرِ فعالیت عوض می‌شود؛ نمایش باید شکستن را ببیند."""
streak = record_activity(1, today=date(2026, 9, 20))
self.assertEqual(effective_current_streak(streak, today=date(2026, 9, 21)), 1)
self.assertEqual(effective_current_streak(streak, today=date(2026, 9, 23)), 0)
self.assertEqual(effective_current_streak(None), 0)
class ActivityFeedsStreakAndLeaderboardTests(TestCase):
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
self.node = Node.objects.create(unit=unit, title='جزیره', order=1)
for i in range(4):
lesson = Lesson.objects.create(node=self.node, title=f'درس {i}')
q = Question.objects.create(lesson=lesson, type='multiple_choice', prompt=f'س {i}')
self.correct = Choice.objects.create(question=q, value='الف', is_correct=True)
Choice.objects.create(question=q, value='ب', is_correct=False)
self.question = q
self.user = User.objects.create(phone='09120000031', name='سارا', grade_id=grade.id)
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
def test_answering_a_question_starts_the_streak(self):
self.client.post(
f'/api/questions/{self.question.pk}/answer',
{'selectedOptionId': self.correct.pk}, content_type='application/json', **self.auth,
)
summary = self.client.get('/api/user/summary', **self.auth).json()
self.assertEqual(summary['streak']['current'], 1)
calendar = self.client.get('/api/user/streak/calendar', **self.auth).json()
self.assertEqual(len(calendar['activeDays']), 1)
def test_a_finished_island_shows_up_on_the_leaderboard(self):
self.client.post(
f'/api/nodes/{self.node.pk}/quiz/complete',
{'correctCount': 3, 'totalQuestions': 4},
content_type='application/json', **self.auth,
)
for period in ('weekly', 'all_time'):
entries = self.client.get(f'/api/leaderboard?period={period}').json()['entries']
self.assertEqual(entries[0]['userId'], str(self.user.id), period)
self.assertEqual(entries[0]['name'], 'سارا')
self.assertEqual(entries[0]['xpInPeriod'], 30, period)
def test_streak_failure_never_breaks_the_answer(self):
with mock.patch('progress.services.record_activity', side_effect=RuntimeError('boom')):
response = self.client.post(
f'/api/questions/{self.question.pk}/answer',
{'selectedOptionId': self.correct.pk}, content_type='application/json', **self.auth,
)
self.assertEqual(response.status_code, 200)
self.assertFalse(StreakRecord.objects.filter(user_id=self.user.id).exists())
class ApiResponsesAreNeverCachedTests(TestCase):
def test_api_responses_say_no_store(self):
response = self.client.get('/api/grades')
self.assertIn('no-store', response['Cache-Control'])
def test_a_view_that_sets_its_own_cache_control_keeps_it(self):
from django.http import HttpResponse
from config.middleware import APINoStoreMiddleware
def view(request):
response = HttpResponse()
response['Cache-Control'] = 'no-cache'
return response
request = mock.Mock(path='/api/ai/ask')
self.assertEqual(APINoStoreMiddleware(view)(request)['Cache-Control'], 'no-cache')
class SummaryShowsTheActivePlanTests(TestCase):
def test_active_subscription_is_named(self):
from subscription.models import Plan, Subscription
user = User.objects.create(phone='09120000032')
auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(user.id)}'}
self.assertIsNone(self.client.get('/api/user/summary', **auth).json()['subscription'])
plan = Plan.objects.create(title='طلایی', price=1000)
Subscription.objects.create(user_id=user.id, plan=plan, status='active')
self.assertEqual(self.client.get('/api/user/summary', **auth).json()['subscription'], 'طلایی')

View File

@@ -8,6 +8,7 @@ from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from .models import Character, LeaderboardEntry, StreakRecord, UserHearts, UserStats
from .services import effective_current_streak
class GuestAwareReadThrottle(ScopedRateThrottle):
@@ -34,6 +35,20 @@ from .serializers import (
)
def _active_plan_title(user_id):
from django.db.models import Q
from django.utils import timezone
from subscription.models import Subscription
sub = (
Subscription.objects.select_related('plan')
.filter(user_id=user_id, status='active')
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now()))
.first()
)
return sub.plan.title if sub and sub.plan else None
class UserSummaryView(APIView):
"""GET /api/user/summary — پروفایل + آمار + استریک (ادغام‌شده)"""
@@ -55,11 +70,12 @@ class UserSummaryView(APIView):
'xp': stats.xp if stats else 0,
'coins': stats.coins if stats else 0,
'streak': {
'current': streak.current_streak if streak else 0,
'current': effective_current_streak(streak),
'longest': streak.longest_streak if streak else 0,
},
# اپ subscription هنوز در این فاز پیاده‌سازی نشده؛ فعلاً null.
'subscription': None,
# عنوانِ پلنِ اشتراکِ فعال، یا null. تا ۲۰۲۶-۰۹-۲۴ همیشه null بود
# (از زمانی که اپِ subscription هنوز وجود نداشت).
'subscription': _active_plan_title(user.id),
}
return Response(UserSummaryResponseSerializer(data).data)
@@ -77,13 +93,18 @@ class StreakCalendarView(APIView):
streak = StreakRecord.objects.filter(user_id=user.id).first()
data = {
'currentStreak': streak.current_streak if streak else 0,
'currentStreak': effective_current_streak(streak),
'longestStreak': streak.longest_streak if streak else 0,
'activeDays': list(streak.active_days.values_list('date', flat=True)) if streak else [],
}
return Response(StreakCalendarResponseSerializer(data).data)
# کوتاه تا XPی تازه زود دیده شود، بلند تا هزار کاربرِ هم‌زمان هر بار جدول را
# از نو حساب نکنند.
LEADERBOARD_CACHE_TTL = 60
class LeaderboardView(APIView):
"""GET /api/leaderboard?period= — رتبه‌بندی هفتگی/ماهانه"""
@@ -98,7 +119,20 @@ class LeaderboardView(APIView):
)
def get(self, request):
period = request.query_params.get('period', 'weekly')
entries = LeaderboardEntry.objects.filter(period=period).order_by('rank')[:50]
# زنده از فعالیتِ واقعی، با کشِ کوتاه (progress/leaderboard.py). قبلاً از
# جدولِ LeaderboardEntry خوانده می‌شد که فقط با اجرای دستیِ
# rebuild_leaderboard پر می‌شد، پس XPی کاربرِ تازه هیچ‌وقت دیده نمی‌شد.
from django.core.cache import cache
from .leaderboard import compute_scores
scores = cache.get_or_set(
f'leaderboard:{period}', lambda: compute_scores(period), LEADERBOARD_CACHE_TTL,
)
entries = [
LeaderboardEntry(user_id=uid, period=period, xp_in_period=xp, rank=rank)
for rank, (uid, xp) in enumerate(scores, 1)
]
# نام کاربر از accounts.User خونده می‌شه (فقط برای نمایش، بدون FK واقعی).
from accounts.models import User as AccountUser