diff --git a/content/tests_flow.py b/content/tests_flow.py index 1333dfc..fcb09ab 100644 --- a/content/tests_flow.py +++ b/content/tests_flow.py @@ -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) diff --git a/content/views.py b/content/views.py index a05cb9d..2082453 100644 --- a/content/views.py +++ b/content/views.py @@ -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 برگردانده می‌شود. diff --git a/deploy.sh b/deploy.sh index 4a3262d..76c9a0a 100755 --- a/deploy.sh +++ b/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 روشن است. # diff --git a/notifications/serializers.py b/notifications/serializers.py index 3a10612..8f8cd13 100644 --- a/notifications/serializers.py +++ b/notifications/serializers.py @@ -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): diff --git a/notifications/tests.py b/notifications/tests.py index 3085644..8974509 100644 --- a/notifications/tests.py +++ b/notifications/tests.py @@ -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'], '') diff --git a/progress/management/__init__.py b/progress/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/progress/management/commands/__init__.py b/progress/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/progress/management/commands/rebuild_leaderboard.py b/progress/management/commands/rebuild_leaderboard.py new file mode 100644 index 0000000..9faf194 --- /dev/null +++ b/progress/management/commands/rebuild_leaderboard.py @@ -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)} ردیف ساخته شد.')) diff --git a/progress/tests.py b/progress/tests.py index 8ab0f50..71768e0 100644 --- a/progress/tests.py +++ b/progress/tests.py @@ -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)