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>
108 lines
6.5 KiB
Python
108 lines
6.5 KiB
Python
"""
|
||
ساختِ دوبارهی 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)} ردیف ساخته شد.'))
|