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>
This commit is contained in:
117
progress/management/commands/rebuild_leaderboard.py
Normal file
117
progress/management/commands/rebuild_leaderboard.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
ساختِ دوبارهی 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)} ردیف ساخته شد.'))
|
||||
Reference in New Issue
Block a user