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