make streaks and the leaderboard live, and stop three AI chats from freezing the API
Found in a deeper pass over what the frontend's flow actually touches:
- Nothing in the app ever wrote StreakRecord: "days in a row" was 0 forever
for every real user. Answers and lesson/island completions now record the
day (Tehran time); a broken chain shows 0. A streak error never fails the
answer itself.
- /api/leaderboard only changed when someone ran rebuild_leaderboard by
hand. It is now computed live (60s cache); weekly counts island XP too.
- gunicorn ran 3 sync workers with a 30s timeout, so every "ask Pi" stream
pinned a whole worker (~10s, GapGPT buffers the answer) and three at once
queued the entire API. gunicorn.conf.py (read automatically from the
working directory) switches to gthread, 8 threads, 120s timeout.
- Every /api/ response now says Cache-Control: private, no-store, so turning
on caching in the ArvanCloud panel can never serve one student another's data.
- /api/user/summary named no active plan ever ("subscription app not
implemented yet" had long stopped being true).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
49
progress/leaderboard.py
Normal file
49
progress/leaderboard.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
امتیازهای جدولِ رتبهبندی، مستقیم از فعالیتِ واقعی.
|
||||
|
||||
تا ۲۰۲۶-۰۹-۲۴ `GET /api/leaderboard` فقط جدولِ LeaderboardEntry را میخواند که
|
||||
تنها با اجرای دستیِ `rebuild_leaderboard` پر میشد؛ XPی که کاربرِ تازه میگرفت
|
||||
هیچوقت در جدول دیده نمیشد. حالا view همین تابع را صدا میزند و نتیجه را
|
||||
کوتاهمدت کش میکند، و دستورِ rebuild هم از همین تابع میخواند تا دو منبعِ
|
||||
عدد از هم جدا نیفتند.
|
||||
|
||||
- `all_time`: همان `UserStats.xp` که در `/api/user/summary` هم دیده میشود.
|
||||
- `weekly`: XPی درسها و جزیرههایی که در `days` روزِ اخیر تمام شدهاند —
|
||||
همان دو جایی که `UserStats.xp` زیاد میشود. `UserAnswer.xp_earned` عمداً
|
||||
شمرده نمیشود چون به `UserStats.xp` اضافه نمیشود.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
|
||||
PERIODS = ('weekly', 'all_time')
|
||||
|
||||
|
||||
def compute_scores(period, days=7, limit=50, include_zero=False):
|
||||
"""[(user_id, xp), ...] مرتب: XPی بیشتر جلوتر، در تساوی user_id کوچکتر.
|
||||
پیشفرض فقط XPی مثبت؛ include_zero برای دستورِ rebuild است."""
|
||||
if period == 'all_time':
|
||||
from progress.models import UserStats
|
||||
qs = UserStats.objects.all() if include_zero else UserStats.objects.filter(xp__gt=0)
|
||||
rows = list(
|
||||
qs.order_by('-xp', 'user_id')
|
||||
.values_list('user_id', 'xp')[:limit]
|
||||
)
|
||||
return [(uid, xp) for uid, xp in rows]
|
||||
|
||||
if period != 'weekly':
|
||||
return []
|
||||
|
||||
from content.models import UserLessonCompletion, UserNodeCompletion
|
||||
since = timezone.now() - timezone.timedelta(days=days)
|
||||
totals = defaultdict(int)
|
||||
for model in (UserLessonCompletion, UserNodeCompletion):
|
||||
for row in (
|
||||
model.objects.filter(created_at__gte=since)
|
||||
.values('user_id').annotate(total=Sum('xp_earned'))
|
||||
):
|
||||
totals[row['user_id']] += row['total'] or 0
|
||||
scores = [(uid, xp) for uid, xp in totals.items() if include_zero or xp > 0]
|
||||
scores.sort(key=lambda p: (-p[1], p[0]))
|
||||
return scores[:limit]
|
||||
Reference in New Issue
Block a user