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

@@ -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'], '')