""" ساختِ دوباره‌ی LeaderboardEntry از روی فعالیتِ واقعیِ کاربرها. چرا این دستور لازم شد: تا پیش از این **هیچ‌جای کد** یک LeaderboardEntry نمی‌ساخت — نه سیگنالی، نه سرویسی. تنها راهِ پرشدنِ جدول، فرمِ جنگو-ادمین یا seed_test_data بود، یعنی `GET /api/leaderboard` روی هر سرورِ واقعی برای همیشه `entries: []` می‌داد. این دستور آن شکاف را می‌بندد. منبعِ اعداد (و دلیلش): - `all_time` از `UserStats.xp` خوانده می‌شود، چون همان عددی است که خودِ اپ در طولِ عمرِ کاربر بالا می‌برد و در `/api/user/summary` هم نشان داده می‌شود. - `weekly` از جمعِ `content.UserLessonCompletion.xp_earned` در بازه‌ی اخیر ساخته می‌شود. ⚠️ چرا `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 from django.db.models import Sum from django.utils import timezone 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): 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 _rebuild(self, period, opts): from progress.models import LeaderboardEntry days = opts['days'] scores = self._collect(period, days) 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)} ردیف ساخته شد.'))