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

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