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:
@@ -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'], '')
|
||||
|
||||
Reference in New Issue
Block a user