Files
MathApp/progress/management/commands/rebuild_leaderboard.py
Zareian 8fce3b9548 give the leaderboard and notifications a real producer
Three gaps found while seeding test data for the frontend, all of the same
kind: a contract that looks complete from the outside and is empty underneath.

Nothing in the codebase ever wrote a LeaderboardEntry. Not a signal, not a
service — only the admin form and seed_test_data. So /api/leaderboard returned
`entries: []` forever on any real server, and any row put there was a frozen
number that never tracked activity. rebuild_leaderboard now derives both
periods from real XP: all_time from UserStats.xp, weekly from a sum over
content.UserLessonCompletion in a window. It deliberately does not count
UserAnswer.xp_earned — that field is written but never added to UserStats, so
summing both would report roughly double. deploy.sh runs it every release;
a cron is the real answer once traffic is real, and the script says so.

'lesson_completed' was declared in Notification.TYPE_CHOICES but nothing
called notify() with it, so finishing a lesson produced no notification at
all. LessonCompleteView now emits one, on first completion only (the same
condition that grants xp/coins), inside the completion transaction so a lost
notification cannot leave a silent half-done state.

The notification list serializer returned only id/type/title/isRead. body and
created_at were on the model from the start but never exposed, so the frontend
had neither the text of a notification nor its time. Both added; the change is
purely additive, so the existing client keeps working.

472 tests green, up from 458. makemigrations --check, check and spectacular
are all clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 13:30:57 +03:30

118 lines
6.7 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` از جمعِ `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)} ردیف ساخته شد.'))