Files
MathApp/progress/management/commands/rebuild_leaderboard.py
Zareian 701d8badcc 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>
2026-09-24 06:39:50 +03:30

108 lines
6.5 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.
"""
ساختِ دوباره‌ی LeaderboardEntry از روی فعالیتِ واقعیِ کاربرها.
چرا این دستور لازم شد: تا پیش از این **هیچ‌جای کد** یک LeaderboardEntry
نمی‌ساخت — نه سیگنالی، نه سرویسی. تنها راهِ پرشدنِ جدول، فرمِ جنگو-ادمین یا
seed_test_data بود، یعنی `GET /api/leaderboard` روی هر سرورِ واقعی برای همیشه
`entries: []` می‌داد. این دستور آن شکاف را می‌بندد.
منبعِ اعداد (و دلیلش):
- `all_time` از `UserStats.xp` خوانده می‌شود، چون همان عددی است که خودِ اپ
در طولِ عمرِ کاربر بالا می‌برد و در `/api/user/summary` هم نشان داده می‌شود.
- `weekly` از جمعِ XPی درس‌ها و جزیره‌های تمام‌شده در بازه‌ی اخیر ساخته
می‌شود (`UserLessonCompletion` و `UserNodeCompletion`).
از ۲۰۲۶-۰۹-۲۴ خودِ `GET /api/leaderboard` زنده از همین منبع می‌خواند؛ این
دستور فقط جدولِ LeaderboardEntry را برای دیدن در ادمین پر می‌کند.
⚠️ چرا `UserAnswer.xp_earned` شمرده **نمی‌شود**: با اینکه آن فیلد پر می‌شود،
`QuestionAnswerView` هیچ‌وقت آن را به `UserStats.xp` اضافه نمی‌کند — تنها
جایی که `UserStats.xp` زیاد می‌شود `LessonCompleteView` است و مقدارش
`correct_count * 10` یعنی همان `UserLessonCompletion.xp_earned`. اگر هر دو را
جمع بزنیم، XPی هفتگی تقریباً دو برابرِ all_time در می‌آید. این را پیش از
تغییرِ منبعِ اعداد دوباره بررسی کن.
python manage.py rebuild_leaderboard # هر دو بازه
python manage.py rebuild_leaderboard --period weekly
python manage.py rebuild_leaderboard --days 30 # بازه‌ی weekly را عوض می‌کند
python manage.py rebuild_leaderboard --dry-run # فقط گزارش
idempotent است: ردیف‌های همان بازه اول پاک و دوباره ساخته می‌شوند، پس اجرای
مکرر نه ردیفِ تکراری می‌سازد نه رتبه‌ی پراکنده. بازه‌ی دیگر دست نمی‌خورد.
"""
from django.core.management.base import BaseCommand
from django.db import transaction
PERIODS = ('weekly', 'all_time')
# سقفِ ردیف‌هایی که نگه می‌داریم. LeaderboardView فقط ۵۰ تای اول را می‌خواند؛
# ۲۰۰ فضای کافی برای «رتبه‌ی من» در آینده می‌دهد بدون اینکه جدول با ده‌ها هزار
# ردیفِ هیچ‌وقت‌خوانده‌نشده رشد کند.
DEFAULT_LIMIT = 200
class Command(BaseCommand):
help = 'ساختِ دوباره‌ی جدولِ لیدربورد از روی XPی واقعیِ کاربرها (idempotent).'
def add_arguments(self, parser):
parser.add_argument('--period', choices=PERIODS, default=None,
help='فقط همین بازه. پیش‌فرض: هر دو.')
parser.add_argument('--days', type=int, default=7,
help='طولِ بازه‌ی weekly به روز (پیش‌فرض ۷).')
parser.add_argument('--limit', type=int, default=DEFAULT_LIMIT,
help=f'چند نفرِ اول نگه داشته شود (پیش‌فرض {DEFAULT_LIMIT}).')
parser.add_argument('--include-zero', action='store_true',
help='کاربرهای با XP صفر هم بیایند (پیش‌فرض: نه).')
parser.add_argument('--dry-run', action='store_true',
help='چیزی نوشته نمی‌شود، فقط نتیجه چاپ می‌شود.')
def handle(self, *args, **opts):
periods = [opts['period']] if opts['period'] else list(PERIODS)
for period in periods:
self._rebuild(period, opts)
# -- جمع‌آوریِ امتیازها ---------------------------------------------------
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)
# -- نوشتن ---------------------------------------------------------------
def _rebuild(self, period, opts):
from progress.models import LeaderboardEntry
days = opts['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]
# ترتیبِ قطعی: امتیازِ بیشتر جلوتر، و در تساوی user_id کوچک‌تر جلوتر —
# وگرنه دو اجرای پشت‌سرهم روی داده‌ی یکسان رتبه‌های متفاوت می‌دهند.
scores.sort(key=lambda p: (-p[1], p[0]))
scores = scores[:opts['limit']]
label = period if period == 'all_time' else f'{period} ({days} روزِ اخیر)'
if opts['dry_run']:
self.stdout.write(self.style.WARNING(f'[dry-run] {label}: {len(scores)} ردیف'))
for rank, (uid, xp) in enumerate(scores[:10], 1):
self.stdout.write(f' {rank:>3}. user={uid} xp={xp}')
if len(scores) > 10:
self.stdout.write(f' … و {len(scores) - 10} ردیفِ دیگر')
return
with transaction.atomic():
# فقط همین بازه پاک می‌شود؛ بازه‌ی دیگر دست نمی‌خورد. این جدول
# کاملاً مشتق‌شده است — هیچ داده‌ای که جای دیگری نباشد اینجا نیست.
LeaderboardEntry.objects.filter(period=period).delete()
LeaderboardEntry.objects.bulk_create([
LeaderboardEntry(user_id=uid, period=period, xp_in_period=xp, rank=rank)
for rank, (uid, xp) in enumerate(scores, 1)
])
self.stdout.write(self.style.SUCCESS(f'{label}: {len(scores)} ردیف ساخته شد.'))