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:
@@ -301,3 +301,95 @@ class NodeQuizTests(TestCase):
|
||||
|
||||
def test_unknown_node_is_a_clean_404(self):
|
||||
self.assertEqual(self.client.get('/api/nodes/999999/quiz').status_code, 404)
|
||||
|
||||
|
||||
class LessonCompletionNotificationTests(TestCase):
|
||||
"""
|
||||
شکافی که بسته شد: 'lesson_completed' در Notification.TYPE_CHOICES تعریف
|
||||
شده بود ولی هیچجای کد آن را نمیساخت، یعنی کلِ مسیرِ کوییز هیچ اعلانی
|
||||
تولید نمیکرد و /api/notifications برای کاربرِ واقعی همیشه خالی میماند.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
grade = Grade.objects.create(title='پایه نهم', icon='g', color='#fff')
|
||||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||||
node = Node.objects.create(unit=unit, title='نود', order=1)
|
||||
self.lesson = Lesson.objects.create(node=node, title='توانِ منفی')
|
||||
question = Question.objects.create(
|
||||
lesson=self.lesson, type='multiple_choice', prompt='؟',
|
||||
)
|
||||
self.correct = Choice.objects.create(question=question, value='الف', is_correct=True)
|
||||
self.question = question
|
||||
|
||||
self.user = User.objects.create(device_id='notif-on-complete', is_guest=True, grade_id=grade.id)
|
||||
from accounts.authentication import issue_access_token
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def _complete(self, **extra):
|
||||
return self.client.post(
|
||||
f'/api/lessons/{self.lesson.id}/complete',
|
||||
{'correctCount': 1, 'totalQuestions': 1},
|
||||
format='json', **extra,
|
||||
)
|
||||
|
||||
def test_finishing_a_lesson_creates_a_notification_the_user_can_read(self):
|
||||
from notifications.models import Notification
|
||||
|
||||
self.assertEqual(Notification.objects.count(), 0)
|
||||
self.assertEqual(self._complete().status_code, 200)
|
||||
|
||||
notification = Notification.objects.get()
|
||||
self.assertEqual(notification.user_id, self.user.id)
|
||||
self.assertEqual(notification.type, 'lesson_completed')
|
||||
self.assertEqual(notification.related_id, self.lesson.id)
|
||||
self.assertIn('توانِ منفی', notification.body)
|
||||
self.assertFalse(notification.is_read)
|
||||
|
||||
# و از همان راهی که فرانت میبیند هم برگردد.
|
||||
listing = self.client.get('/api/notifications')
|
||||
self.assertEqual(listing.data['unreadCount'], 1)
|
||||
self.assertEqual(listing.data['items'][0]['type'], 'lesson_completed')
|
||||
|
||||
def test_repeating_the_same_lesson_does_not_pile_up_notifications(self):
|
||||
# تکرارِ درس عمداً xp/coins نمیدهد؛ اعلان هم نباید بدهد، وگرنه کاربری
|
||||
# که یک درس را ده بار تمرین کند ده اعلانِ یکسان میگیرد.
|
||||
from notifications.models import Notification
|
||||
|
||||
self._complete()
|
||||
self._complete()
|
||||
self._complete()
|
||||
|
||||
self.assertEqual(Notification.objects.count(), 1)
|
||||
|
||||
def test_idempotent_replay_does_not_create_a_second_notification(self):
|
||||
from notifications.models import Notification
|
||||
|
||||
self._complete(HTTP_IDEMPOTENCY_KEY='same-key')
|
||||
self._complete(HTTP_IDEMPOTENCY_KEY='same-key')
|
||||
|
||||
self.assertEqual(Notification.objects.count(), 1)
|
||||
|
||||
def test_a_failing_notification_rolls_the_whole_completion_back(self):
|
||||
# اعلان عمداً داخلِ همان transactionِ تکمیل است. این تست همان ادعا را
|
||||
# میسنجد: اگر نوشتنِ اعلان بشکند، نباید تکمیلی ثبت شده باشد که
|
||||
# xp داده ولی اعلانش گم شده — یعنی نه «نیمهتمامِ خاموش».
|
||||
from unittest.mock import patch
|
||||
|
||||
from content.models import UserLessonCompletion
|
||||
from notifications.models import Notification
|
||||
from progress.models import UserStats
|
||||
|
||||
with patch('notifications.services.Notification.objects.create',
|
||||
side_effect=RuntimeError('boom')):
|
||||
response = self._complete()
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertEqual(Notification.objects.count(), 0)
|
||||
self.assertEqual(UserLessonCompletion.objects.count(), 0)
|
||||
stats = UserStats.objects.filter(user_id=self.user.id).first()
|
||||
self.assertEqual(stats.xp if stats else 0, 0)
|
||||
|
||||
@@ -519,6 +519,25 @@ class LessonCompleteView(APIView):
|
||||
).values('lesson_id').distinct().count()
|
||||
stats.progress_percent = round(min(completed_lessons / total_lessons, 1.0) * 100, 2)
|
||||
stats.save(update_fields=['xp', 'coins', 'progress_percent'])
|
||||
|
||||
# اعلانِ درسِ تکمیلشده. تا پیش از این، نوعِ 'lesson_completed'
|
||||
# در Notification.TYPE_CHOICES تعریف شده بود ولی هیچجای کد
|
||||
# آن را نمیساخت — یعنی کلِ مسیرِ کوییز هیچ اعلانی تولید
|
||||
# نمیکرد. فقط بارِ اول (همان شرطی که xp/coins میدهد)؛
|
||||
# تکرارِ درس اعلانِ تازه نمیسازد.
|
||||
#
|
||||
# عمداً داخلِ همین transaction است: اگر نوشتنِ اعلان شکست
|
||||
# بخورد، تکمیلِ درس هم برنمیگردد — همالگو با
|
||||
# campaigns.services که اعلان و وضعیت را یک واحد میداند.
|
||||
if is_first_completion:
|
||||
from notifications.services import notify
|
||||
notify(
|
||||
user_id=user_id,
|
||||
type='lesson_completed',
|
||||
title='درس را کامل کردی',
|
||||
body=f'«{lesson.title}» را با {stars} ستاره تمام کردی و {xp_earned} امتیاز گرفتی.',
|
||||
related_id=lesson.id,
|
||||
)
|
||||
except IntegrityError:
|
||||
# قید یکتاییِ سطح DB جلوی درخواستهای همزمان با همان کلید را میگیرد؛
|
||||
# نتیجهی completion رقیب idempotent برگردانده میشود.
|
||||
|
||||
13
deploy.sh
13
deploy.sh
@@ -175,6 +175,19 @@ step "محتوای پایه"
|
||||
python manage.py seed_content 2>&1 | sed 's/^/ /' \
|
||||
|| warn "seed_content شکست خورد؛ کد بهروز شد ولی محتوا دستنخورده ماند."
|
||||
|
||||
# جدولِ لیدربورد کاملاً مشتقشده است و تا پیش از این هیچ تولیدکنندهای نداشت —
|
||||
# یعنی /api/leaderboard روی سرورِ واقعی همیشه `entries: []` میداد. این مرحله
|
||||
# آن را در هر ریلیز از روی XPی واقعی از نو میسازد.
|
||||
#
|
||||
# ⚠️ این «تازهسازیِ هر ریلیز» است، نه یک زمانبندِ واقعی: بینِ دو دیپلوی،
|
||||
# رتبهها ثابت میمانند. وقتی ترافیک واقعی شد، همین دستور باید در یک cron
|
||||
# (مثلاً هر ساعت) بنشیند.
|
||||
#
|
||||
# ایدمپوتنت است و شکستش دیپلوی را نمیخواباند.
|
||||
step "بازسازیِ لیدربورد"
|
||||
python manage.py rebuild_leaderboard 2>&1 | sed 's/^/ /' \
|
||||
|| warn "rebuild_leaderboard شکست خورد؛ رتبهها از اجرای قبلی ماندهاند."
|
||||
|
||||
# ─── ۵. فایلهای استاتیک ───────────────────────────────────────────────────
|
||||
# بیقید و شرط اجرا میشود، حتی وقتی DEBUG روشن است.
|
||||
#
|
||||
|
||||
@@ -6,10 +6,15 @@ from .models import Notification
|
||||
class NotificationItemSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField()
|
||||
isRead = serializers.BooleanField(source='is_read')
|
||||
# body و createdAt از ابتدا روی مدل بودند ولی در پاسخ نبودند، یعنی فرانت
|
||||
# نه متنِ اعلان را داشت نه زمانش — و هر لیستِ اعلانی به هر دو نیاز دارد.
|
||||
# هر دو فقط «اضافه» میشوند؛ هیچ فیلدی حذف یا تغییرِ نام نداده، پس
|
||||
# کلاینتِ فعلی نمیشکند.
|
||||
createdAt = serializers.DateTimeField(source='created_at', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = ['id', 'type', 'title', 'isRead']
|
||||
fields = ['id', 'type', 'title', 'body', 'isRead', 'createdAt']
|
||||
|
||||
|
||||
class NotificationListResponseSerializer(serializers.Serializer):
|
||||
|
||||
@@ -108,3 +108,41 @@ class NotificationListCapTests(TestCase):
|
||||
# جدیدترین اول (Meta.ordering = -created_at؛ اینجا همزمان → -id غالب)
|
||||
ids = [it['id'] if isinstance(it, dict) else it.id for it in r.data['items']]
|
||||
self.assertEqual(ids, sorted(ids, reverse=True))
|
||||
|
||||
|
||||
class NotificationPayloadShapeTests(TestCase):
|
||||
"""
|
||||
body و createdAt پیشتر روی مدل بودند ولی در پاسخ نبودند، یعنی فرانت نه
|
||||
متنِ اعلان را میدید نه زمانش. این تست همان شکاف را قفل میکند تا دوباره
|
||||
از قرارداد نیفتد.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='dev-notif-shape', is_guest=True, name='n')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
def test_list_item_carries_body_and_created_at(self):
|
||||
Notification.objects.create(
|
||||
user_id=self.user.id, type='order_paid',
|
||||
title='پرداخت موفق', body='اشتراک ماهانه فعال شد.', is_read=False,
|
||||
)
|
||||
r = self.client.get('/api/notifications', HTTP_AUTHORIZATION=f'Bearer {self.token}')
|
||||
|
||||
self.assertEqual(r.status_code, 200)
|
||||
item = r.data['items'][0]
|
||||
self.assertEqual(item['body'], 'اشتراک ماهانه فعال شد.')
|
||||
self.assertIsNotNone(item['createdAt'])
|
||||
# فیلدهای قبلی هنوز سرِ جایشاناند (تغییر فقط افزایشی بوده).
|
||||
self.assertEqual(
|
||||
set(item.keys()), {'id', 'type', 'title', 'body', 'isRead', 'createdAt'},
|
||||
)
|
||||
|
||||
def test_empty_body_is_returned_as_empty_string_not_omitted(self):
|
||||
# body روی مدل blank=True است؛ نبودِ متن نباید فیلد را از پاسخ حذف کند،
|
||||
# وگرنه کلاینت باید برای دو شکلِ متفاوتِ پاسخ کد بنویسد.
|
||||
Notification.objects.create(user_id=self.user.id, type='xp_earned', title='t')
|
||||
r = self.client.get('/api/notifications', HTTP_AUTHORIZATION=f'Bearer {self.token}')
|
||||
|
||||
self.assertEqual(r.data['items'][0]['body'], '')
|
||||
|
||||
0
progress/management/__init__.py
Normal file
0
progress/management/__init__.py
Normal file
0
progress/management/commands/__init__.py
Normal file
0
progress/management/commands/__init__.py
Normal file
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)} ردیف ساخته شد.'))
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user