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

@@ -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 برگردانده می‌شود.