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:
2026-09-22 13:30:57 +03:30
parent 66364e0e92
commit 8fce3b9548
9 changed files with 412 additions and 2 deletions

View File

View File

View 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)} ردیف ساخته شد.'))

View File

@@ -1,5 +1,6 @@
from datetime import timedelta
from django.core.management import call_command
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
@@ -7,7 +8,13 @@ from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from .models import HEART_REFILL_INTERVAL, Character, UserHearts, UserStats
from .models import (
HEART_REFILL_INTERVAL,
Character,
LeaderboardEntry,
UserHearts,
UserStats,
)
class UserHeartsRegenTests(TestCase):
@@ -129,3 +136,122 @@ class CharacterGamificationTests(TestCase):
by_slug = {c['slug']: c for c in response.data['characters']}
self.assertTrue(by_slug['owl']['selected'])
self.assertFalse(by_slug['fox']['selected'])
class RebuildLeaderboardCommandTests(TestCase):
"""
تا پیش از این هیچ‌جای کد LeaderboardEntry نمی‌ساخت و /api/leaderboard روی
هر سرورِ واقعی برای همیشه خالی بود. این تست‌ها تولیدکننده‌ی تازه را قفل
می‌کنند.
"""
def _complete(self, user_id, lesson, xp, when=None):
from content.models import UserLessonCompletion
row = UserLessonCompletion.objects.create(
user_id=user_id, lesson=lesson, correct_count=1, stars=3, xp_earned=xp,
)
if when is not None:
# auto_now_add را نمی‌شود موقعِ create گذاشت؛ بعدش update می‌کنیم.
UserLessonCompletion.objects.filter(pk=row.pk).update(created_at=when)
return row
def _lesson(self, title='درس'):
from content.models import Grade, Lesson, Node, Unit
grade = Grade.objects.create(title='پایه تست')
unit = Unit.objects.create(grade=grade, title='مبحث', order=1)
node = Node.objects.create(unit=unit, title='نود', order=1)
return Lesson.objects.create(node=node, title=title)
def test_all_time_ranks_come_from_user_stats_xp(self):
UserStats.objects.create(user_id=1, xp=50)
UserStats.objects.create(user_id=2, xp=300)
UserStats.objects.create(user_id=3, xp=120)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
rows = list(
LeaderboardEntry.objects.filter(period='all_time')
.order_by('rank').values_list('rank', 'user_id', 'xp_in_period')
)
self.assertEqual(rows, [(1, 2, 300), (2, 3, 120), (3, 1, 50)])
def test_weekly_sums_only_completions_inside_the_window(self):
lesson = self._lesson()
now = timezone.now()
self._complete(1, lesson, 30, when=now - timedelta(days=1))
self._complete(1, lesson, 20, when=now - timedelta(days=2))
# خارج از بازه‌ی ۷ روزه — نباید شمرده شود.
self._complete(1, lesson, 999, when=now - timedelta(days=30))
self._complete(2, lesson, 40, when=now - timedelta(days=3))
call_command('rebuild_leaderboard', '--period', 'weekly', verbosity=0)
rows = list(
LeaderboardEntry.objects.filter(period='weekly')
.order_by('rank').values_list('rank', 'user_id', 'xp_in_period')
)
self.assertEqual(rows, [(1, 1, 50), (2, 2, 40)])
def test_days_flag_widens_the_window(self):
lesson = self._lesson()
self._complete(1, lesson, 999, when=timezone.now() - timedelta(days=30))
call_command('rebuild_leaderboard', '--period', 'weekly', '--days', '60', verbosity=0)
self.assertEqual(
list(LeaderboardEntry.objects.filter(period='weekly')
.values_list('user_id', 'xp_in_period')),
[(1, 999)],
)
def test_rerun_is_idempotent_and_does_not_duplicate_or_gap_ranks(self):
UserStats.objects.create(user_id=1, xp=10)
UserStats.objects.create(user_id=2, xp=20)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
ranks = list(
LeaderboardEntry.objects.filter(period='all_time')
.order_by('rank').values_list('rank', flat=True)
)
self.assertEqual(ranks, [1, 2])
def test_zero_xp_users_are_excluded_unless_asked_for(self):
UserStats.objects.create(user_id=1, xp=0)
UserStats.objects.create(user_id=2, xp=5)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
self.assertEqual(LeaderboardEntry.objects.filter(period='all_time').count(), 1)
call_command('rebuild_leaderboard', '--period', 'all_time', '--include-zero', verbosity=0)
self.assertEqual(LeaderboardEntry.objects.filter(period='all_time').count(), 2)
def test_rebuilding_one_period_leaves_the_other_untouched(self):
LeaderboardEntry.objects.create(user_id=9, period='weekly', xp_in_period=7, rank=1)
UserStats.objects.create(user_id=1, xp=10)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
self.assertEqual(LeaderboardEntry.objects.filter(period='weekly').count(), 1)
def test_ties_break_deterministically_so_two_runs_agree(self):
UserStats.objects.create(user_id=7, xp=100)
UserStats.objects.create(user_id=3, xp=100)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
first = list(LeaderboardEntry.objects.filter(period='all_time')
.order_by('rank').values_list('user_id', flat=True))
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
second = list(LeaderboardEntry.objects.filter(period='all_time')
.order_by('rank').values_list('user_id', flat=True))
self.assertEqual(first, [3, 7]) # تساوی → user_id کوچک‌تر جلوتر
self.assertEqual(first, second)
def test_dry_run_writes_nothing(self):
UserStats.objects.create(user_id=1, xp=10)
call_command('rebuild_leaderboard', '--period', 'all_time', '--dry-run', verbosity=0)
self.assertEqual(LeaderboardEntry.objects.count(), 0)