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:
2026-09-24 06:39:50 +03:30
parent c874c186b1
commit 701d8badcc
11 changed files with 375 additions and 27 deletions

View File

@@ -10,8 +10,11 @@ seed_test_data بود، یعنی `GET /api/leaderboard` روی هر سرورِ
- `all_time` از `UserStats.xp` خوانده می‌شود، چون همان عددی است که خودِ اپ
در طولِ عمرِ کاربر بالا می‌برد و در `/api/user/summary` هم نشان داده می‌شود.
- `weekly` از جمعِ `content.UserLessonCompletion.xp_earned` در بازه‌ی اخیر
ساخته می‌شود.
- `weekly` از جمعِ XPی درس‌ها و جزیره‌های تمام‌شده در بازه‌ی اخیر ساخته
می‌شود (`UserLessonCompletion` و `UserNodeCompletion`).
از ۲۰۲۶-۰۹-۲۴ خودِ `GET /api/leaderboard` زنده از همین منبع می‌خواند؛ این
دستور فقط جدولِ LeaderboardEntry را برای دیدن در ادمین پر می‌کند.
⚠️ چرا `UserAnswer.xp_earned` شمرده **نمی‌شود**: با اینکه آن فیلد پر می‌شود،
`QuestionAnswerView` هیچ‌وقت آن را به `UserStats.xp` اضافه نمی‌کند — تنها
@@ -30,8 +33,6 @@ idempotent است: ردیف‌های همان بازه اول پاک و دوبا
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Sum
from django.utils import timezone
PERIODS = ('weekly', 'all_time')
@@ -63,23 +64,12 @@ class Command(BaseCommand):
# -- جمع‌آوریِ امتیازها ---------------------------------------------------
def _collect(self, period, days):
if period == 'all_time':
from progress.models import UserStats
# values_list مادی می‌شود (list)، نه iterator — طبق درسِ
# ExecuteCampaignRealConcurrencyTests: کرسرِ بازِ خواندن روی SQLite
# در حلقه‌ای که می‌نویسد، جدول را برای هر نویسنده‌ی هم‌زمان قفل می‌کند.
return list(UserStats.objects.values_list('user_id', 'xp'))
from content.models import UserLessonCompletion
since = timezone.now() - timezone.timedelta(days=days)
rows = (
UserLessonCompletion.objects
.filter(created_at__gte=since)
.values('user_id')
.annotate(total=Sum('xp_earned'))
)
return [(r['user_id'], r['total'] or 0) for r in rows]
def _collect(self, period, days, include_zero=False):
# همان منبعی که GET /api/leaderboard زنده می‌خواند (progress/leaderboard.py)
# تا عددِ جدولِ ادمین و عددِ اپ از هم جدا نیفتند. limit بزرگ است چون
# سقف را خودِ دستور با --limit اعمال می‌کند.
from progress.leaderboard import compute_scores
return compute_scores(period, days=days, limit=10**9, include_zero=include_zero)
# -- نوشتن ---------------------------------------------------------------
@@ -87,7 +77,7 @@ class Command(BaseCommand):
from progress.models import LeaderboardEntry
days = opts['days']
scores = self._collect(period, days)
scores = self._collect(period, days, include_zero=opts['include_zero'])
if not opts['include_zero']:
scores = [(uid, xp) for uid, xp in scores if xp > 0]