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:
@@ -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