Files
MathApp/progress/management/commands/rebuild_leaderboard.py

102 lines
6.2 KiB
Python
Raw Permalink 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']
# مرتب‌سازی و کنار گذاشتنِ XPی صفر را خودِ compute_scores انجام می‌دهد.
scores = self._collect(period, days, include_zero=opts['include_zero'])[: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)} ردیف ساخته شد.'))