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

@@ -89,3 +89,24 @@ class APIRequestLoggingMiddleware:
log_func(message, request.method, request.path, status, duration_ms, _user_id_for_log(request)) log_func(message, request.method, request.path, status, duration_ms, _user_id_for_log(request))
except Exception: except Exception:
logger.exception('api_request logging failed') logger.exception('api_request logging failed')
class APINoStoreMiddleware:
"""
هر پاسخِ `/api/` هدرِ `Cache-Control: no-store` می‌گیرد، مگر خودِ view
چیزِ دیگری گذاشته باشد (مثلاً استریمِ دستیار که `no-cache` دارد).
ArvanCloud جلوی سرور است و الان برای API کش نمی‌کند (`x-cache: BYPASS`)،
ولی این فقط به تنظیمِ پنلِ آروان بند است. پاسخ‌های API به کاربر وابسته‌اند
(توکنِ Bearer)؛ اگر روزی کسی کش را در پنل روشن کند، بدونِ این هدر یک
دانش‌آموز می‌توانست `/api/home` یا پروفایلِ دیگری را ببیند.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if request.path.startswith(_LOGGED_PATH_PREFIX) and not response.has_header('Cache-Control'):
response['Cache-Control'] = 'private, no-store'
return response

View File

@@ -225,6 +225,8 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
# پاسخِ API هرگز در کشِ ArvanCloud/مرورگر نماند (config/middleware.py).
'config.middleware.APINoStoreMiddleware',
# ⚠️ افزوده شده: لاگِ سبکِ هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر)؛ # ⚠️ افزوده شده: لاگِ سبکِ هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر)؛
# عمداً آخرِ لیست قرار گرفته تا نزدیک‌ترین میان‌افزار به خودِ view باشه # عمداً آخرِ لیست قرار گرفته تا نزدیک‌ترین میان‌افزار به خودِ view باشه
# (مدت‌زمانِ دقیق‌تر، و request.user که توسطِ DRF authentication در # (مدت‌زمانِ دقیق‌تر، و request.user که توسطِ DRF authentication در

View File

@@ -1,3 +1,5 @@
import logging
from django.core.cache import cache from django.core.cache import cache
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.db.models import Count, F from django.db.models import Count, F
@@ -43,6 +45,8 @@ from .serializers import (
QuestionReportResponseSerializer, QuestionReportResponseSerializer,
) )
logger = logging.getLogger(__name__)
class GuestAwareContentThrottle(ScopedRateThrottle): class GuestAwareContentThrottle(ScopedRateThrottle):
""" """
@@ -163,6 +167,18 @@ class GradesListView(APIView):
return Response(GradesListResponseSerializer(_grades_payload()).data) return Response(GradesListResponseSerializer(_grades_payload()).data)
def _record_streak(user_id):
"""
فعالیتِ امروز را در استریک ثبت می‌کند. خطایش هرگز نباید ثبتِ پاسخ یا
تمام شدنِ درس را خراب کند — استریک تزئینی است، پیشرفت نیست.
"""
try:
from progress.services import record_activity
record_activity(user_id)
except Exception: # noqa: BLE001
logger.exception('streak: ثبتِ فعالیتِ کاربر %s شکست خورد', user_id)
def _node_status(lesson_ids, completed_ids, is_first_unlocked_candidate, quiz_completed=False): def _node_status(lesson_ids, completed_ids, is_first_unlocked_candidate, quiz_completed=False):
# آزمونِ مبحث تمام شده → مبحث تمام است، مستقل از تک‌تکِ درس‌ها. # آزمونِ مبحث تمام شده → مبحث تمام است، مستقل از تک‌تکِ درس‌ها.
if quiz_completed: if quiz_completed:
@@ -437,6 +453,7 @@ class QuestionAnswerView(APIView):
'heartsRemaining': hearts_remaining if hearts_remaining is not None else 5, 'heartsRemaining': hearts_remaining if hearts_remaining is not None else 5,
'mascotMood': 'happy' if choice.is_correct else 'encourage', 'mascotMood': 'happy' if choice.is_correct else 'encourage',
} }
_record_streak(user_id)
return Response(AnswerResponseSerializer(data).data) return Response(AnswerResponseSerializer(data).data)
@@ -593,6 +610,7 @@ class LessonCompleteView(APIView):
'coinsEarned': coins_earned, 'coinsEarned': coins_earned,
'stats': stats_payload, 'stats': stats_payload,
} }
_record_streak(user_id)
return Response(LessonCompleteResponseSerializer(data).data) return Response(LessonCompleteResponseSerializer(data).data)
@@ -904,6 +922,7 @@ class NodeQuizCompleteView(APIView):
raise raise
stats = UserStats.objects.filter(user_id=user_id).first() stats = UserStats.objects.filter(user_id=user_id).first()
_record_streak(user_id)
return respond(completion, stats) return respond(completion, stats)

View File

@@ -109,6 +109,20 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م
می‌کند؛ قبلاً retrieval رد می‌شد و جواب عمومی بود. `lessonId` اگر بیاید همچنان می‌کند؛ قبلاً retrieval رد می‌شد و جواب عمومی بود. `lessonId` اگر بیاید همچنان
دقیق‌تر است، چون فصل را هم معلوم می‌کند. دقیق‌تر است، چون فصل را هم معلوم می‌کند.
## استریک و جدولِ رتبه‌بندی، زنده از فعالیت
تا ۲۰۲۶-۰۹-۲۴ هیچ‌جای اپ `StreakRecord` نمی‌نوشت و `GET /api/leaderboard` فقط
جدولی را می‌خواند که با اجرای دستیِ `rebuild_leaderboard` پر می‌شد؛ یعنی برای
کاربرِ واقعی «روز پیاپی» همیشه صفر بود و XPاش هیچ‌وقت در جدول دیده نمی‌شد.
- **استریک** (`progress/services.py`): پاسخ به سوال، پایانِ درس و پایانِ جزیره
امروز را (به وقتِ تهران) روزِ فعال ثبت می‌کنند. نمایش با
`effective_current_streak` است: اگر آخرین روزِ فعال نه امروز است نه دیروز،
صفر نشان داده می‌شود. خطای استریک هرگز پاسخ یا تکمیل را خراب نمی‌کند.
- **رتبه‌بندی** (`progress/leaderboard.py`): زنده حساب و ۶۰ ثانیه کش می‌شود.
`all_time` از `UserStats.xp`، `weekly` از XPی درس‌ها و جزیره‌های ۷ روزِ اخیر.
`rebuild_leaderboard` هم از همین تابع می‌خواند و فقط جدولِ ادمین را پر می‌کند.
## دیتابیس ## دیتابیس
هنوز SQLite است (`OPTIONS.timeout=20` تا قفل‌های هم‌زمانیِ نوشتن را تحمل کند). هنوز SQLite است (`OPTIONS.timeout=20` تا قفل‌های هم‌زمانیِ نوشتن را تحمل کند).

View File

@@ -106,6 +106,7 @@ curl https://api.math.tadrisino2.ir/api/health
| `CACHE_URL` | سرویس بالا می‌آید ولی نرخ بینِ workerها مشترک نیست؛ `/api/health` هشدار می‌دهد | | `CACHE_URL` | سرویس بالا می‌آید ولی نرخ بینِ workerها مشترک نیست؛ `/api/health` هشدار می‌دهد |
| `KAVENEGAR_API_KEY` | `POST /api/auth/otp/request` خطای صریحِ `503` می‌دهد، نه موفقیتِ ساختگی | | `KAVENEGAR_API_KEY` | `POST /api/auth/otp/request` خطای صریحِ `503` می‌دهد، نه موفقیتِ ساختگی |
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed می‌ماند (`503`) | | `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed می‌ماند (`503`) |
| `gunicorn.conf.py` (در ریپو، نه env) | gunicorn خودش از پوشه‌ی کاری می‌خواندش: workerهای `gthread` با ۸ نخ و timeoutِ ۱۲۰. بدونِ آن، سه استریمِ هم‌زمانِ دستیار کلِ API را قفل می‌کرد |
| `FRONTEND_URL` · `ZARINPAL_REDIRECT_URL` | بعد از پرداخت کاربر به `https://math.tadrisino2.ir/?orderId=…&status=paid\|failed` برمی‌گردد. `ZARINPAL_REDIRECT_URL=-` یعنی همان پاسخِ JSONِ قدیمی | | `FRONTEND_URL` · `ZARINPAL_REDIRECT_URL` | بعد از پرداخت کاربر به `https://math.tadrisino2.ir/?orderId=…&status=paid\|failed` برمی‌گردد. `ZARINPAL_REDIRECT_URL=-` یعنی همان پاسخِ JSONِ قدیمی |
| `OPENAI_API_KEY` · `QDRANT_API_KEY` | `POST /api/ai/ask` همان پاسخِ stub را می‌دهد | | `OPENAI_API_KEY` · `QDRANT_API_KEY` | `POST /api/ai/ask` همان پاسخِ stub را می‌دهد |
| `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) | | `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) |

28
gunicorn.conf.py Normal file
View File

@@ -0,0 +1,28 @@
"""
تنظیماتِ gunicorn — خودِ gunicorn این فایل را از پوشه‌ی کاری
(WorkingDirectory=~/mathapp-v2 در unitِ systemd) خودکار می‌خواند، پس بدونِ
دست زدن به /etc با همان deploy.sh به سرور می‌رسد. آرگومان‌های خطِ فرمانِ
unit (مثلِ `--workers 3` و `--bind`) بر این فایل مقدم‌اند.
چرا لازم شد (۲۰۲۶-۰۹-۲۴): unit با workerهای sync و timeoutِ پیش‌فرضِ ۳۰
ثانیه اجرا می‌شد. هر استریمِ «از پی بپرس» یک worker را تا پایانِ جواب (~۱۰
ثانیه، چون GapGPT خودش تا آماده شدنِ کلِ جواب نگه می‌دارد) اشغال می‌کرد؛ سه
دانش‌آموزِ هم‌زمان با دستیار یعنی بقیه‌ی اپ — نقشه، آزمون، همه‌چیز — در صف.
جوابِ بالای ۳۰ ثانیه هم وسطِ کار کشته می‌شد.
gthread هر worker را چند نخی می‌کند: ۳ worker × ۸ نخ = ۲۴ درخواستِ هم‌زمان.
کدِ جنگو برای نخ امن است و SQLite با `OPTIONS.timeout=20` نوشتنِ هم‌زمان را
تحمل می‌کند. timeout در gthread به ضربانِ خودِ worker است، نه طولِ هر
درخواست، پس استریمِ طولانی دیگر کشته نمی‌شود.
"""
worker_class = 'gthread'
threads = 8
timeout = 120
graceful_timeout = 30
keepalive = 5
# worker بعد از چند هزار درخواست تازه می‌شود تا نشتِ حافظه‌ی احتمالی جمع
# نشود؛ jitter نمی‌گذارد هر سه با هم ری‌استارت شوند.
max_requests = 2000
max_requests_jitter = 200

49
progress/leaderboard.py Normal file
View 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]

View File

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

59
progress/services.py Normal file
View File

@@ -0,0 +1,59 @@
"""
استریک: روزهای پیاپیِ فعالیت.
تا ۲۰۲۶-۰۹-۲۴ هیچ‌جای کد StreakRecord یا ActiveDay نمی‌نوشت — فقط
seed_test_data. یعنی «روز پیاپی» روی صفحه‌ی خانه برای هر کاربرِ واقعی برای
همیشه صفر بود و فقط کاربرهای دمو عدد داشتند.
«فعالیت» یعنی پاسخ به سوال یا تمام کردنِ درس/جزیره. هر روز (به وقتِ تهران،
TIME_ZONE) یک بار شمرده می‌شود؛ صدا زدنِ مکرر در یک روز بی‌اثر است.
"""
from datetime import timedelta
from django.db import transaction
from django.utils import timezone
from .models import ActiveDay, StreakRecord
def record_activity(user_id, today=None):
"""امروز را روزِ فعال ثبت می‌کند و استریک را جلو می‌برد. idempotent در یک روز."""
today = today or timezone.localdate()
with transaction.atomic():
StreakRecord.objects.get_or_create(user_id=user_id)
streak = StreakRecord.objects.select_for_update().get(user_id=user_id)
last = (
ActiveDay.objects.filter(streak=streak)
.order_by('-date').values_list('date', flat=True).first()
)
if last == today:
return streak
if last == today - timedelta(days=1):
streak.current_streak += 1
else:
streak.current_streak = 1
streak.longest_streak = max(streak.longest_streak, streak.current_streak)
streak.save(update_fields=['current_streak', 'longest_streak'])
ActiveDay.objects.get_or_create(streak=streak, date=today)
return streak
def effective_current_streak(streak, today=None):
"""
استریکی که الان باید نشان داده شود.
current_streak فقط در لحظه‌ی فعالیت به‌روز می‌شود؛ کاربری که دو روز
نیامده هنوز عددِ قبلی را در دیتابیس دارد. اگر آخرین روزِ فعال نه امروز
است نه دیروز، زنجیره شکسته و عددِ واقعی صفر است.
"""
if streak is None:
return 0
today = today or timezone.localdate()
last = (
streak.active_days.order_by('-date').values_list('date', flat=True).first()
)
if last is None or last < today - timedelta(days=1):
return 0
return streak.current_streak

View File

@@ -0,0 +1,131 @@
"""
استریک و جدولِ رتبه‌بندی از فعالیتِ واقعی زنده می‌شوند.
تا ۲۰۲۶-۰۹-۲۴ هیچ‌جای اپ StreakRecord نمی‌نوشت («روز پیاپی» برای هر کاربرِ
واقعی صفر می‌ماند) و جدولِ رتبه‌بندی فقط با اجرای دستیِ rebuild_leaderboard
عوض می‌شد.
"""
from datetime import date
from unittest import mock
from django.core.cache import cache
from django.test import TestCase
from accounts.authentication import issue_access_token
from accounts.models import User
from content.models import Choice, Grade, Lesson, Node, Question, Unit
from progress.models import StreakRecord
from progress.services import effective_current_streak, record_activity
class StreakTests(TestCase):
def test_consecutive_days_grow_the_streak(self):
record_activity(1, today=date(2026, 9, 20))
record_activity(1, today=date(2026, 9, 21))
streak = record_activity(1, today=date(2026, 9, 22))
self.assertEqual(streak.current_streak, 3)
self.assertEqual(streak.longest_streak, 3)
def test_same_day_counts_once(self):
record_activity(1, today=date(2026, 9, 20))
streak = record_activity(1, today=date(2026, 9, 20))
self.assertEqual(streak.current_streak, 1)
self.assertEqual(streak.active_days.count(), 1)
def test_a_missed_day_restarts_it_but_keeps_the_longest(self):
for day in (20, 21, 22):
record_activity(1, today=date(2026, 9, day))
streak = record_activity(1, today=date(2026, 9, 25))
self.assertEqual(streak.current_streak, 1)
self.assertEqual(streak.longest_streak, 3)
def test_shown_streak_is_zero_once_the_chain_breaks(self):
"""عددِ ذخیره‌شده فقط سرِ فعالیت عوض می‌شود؛ نمایش باید شکستن را ببیند."""
streak = record_activity(1, today=date(2026, 9, 20))
self.assertEqual(effective_current_streak(streak, today=date(2026, 9, 21)), 1)
self.assertEqual(effective_current_streak(streak, today=date(2026, 9, 23)), 0)
self.assertEqual(effective_current_streak(None), 0)
class ActivityFeedsStreakAndLeaderboardTests(TestCase):
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
self.node = Node.objects.create(unit=unit, title='جزیره', order=1)
for i in range(4):
lesson = Lesson.objects.create(node=self.node, title=f'درس {i}')
q = Question.objects.create(lesson=lesson, type='multiple_choice', prompt=f'س {i}')
self.correct = Choice.objects.create(question=q, value='الف', is_correct=True)
Choice.objects.create(question=q, value='ب', is_correct=False)
self.question = q
self.user = User.objects.create(phone='09120000031', name='سارا', grade_id=grade.id)
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
def test_answering_a_question_starts_the_streak(self):
self.client.post(
f'/api/questions/{self.question.pk}/answer',
{'selectedOptionId': self.correct.pk}, content_type='application/json', **self.auth,
)
summary = self.client.get('/api/user/summary', **self.auth).json()
self.assertEqual(summary['streak']['current'], 1)
calendar = self.client.get('/api/user/streak/calendar', **self.auth).json()
self.assertEqual(len(calendar['activeDays']), 1)
def test_a_finished_island_shows_up_on_the_leaderboard(self):
self.client.post(
f'/api/nodes/{self.node.pk}/quiz/complete',
{'correctCount': 3, 'totalQuestions': 4},
content_type='application/json', **self.auth,
)
for period in ('weekly', 'all_time'):
entries = self.client.get(f'/api/leaderboard?period={period}').json()['entries']
self.assertEqual(entries[0]['userId'], str(self.user.id), period)
self.assertEqual(entries[0]['name'], 'سارا')
self.assertEqual(entries[0]['xpInPeriod'], 30, period)
def test_streak_failure_never_breaks_the_answer(self):
with mock.patch('progress.services.record_activity', side_effect=RuntimeError('boom')):
response = self.client.post(
f'/api/questions/{self.question.pk}/answer',
{'selectedOptionId': self.correct.pk}, content_type='application/json', **self.auth,
)
self.assertEqual(response.status_code, 200)
self.assertFalse(StreakRecord.objects.filter(user_id=self.user.id).exists())
class ApiResponsesAreNeverCachedTests(TestCase):
def test_api_responses_say_no_store(self):
response = self.client.get('/api/grades')
self.assertIn('no-store', response['Cache-Control'])
def test_a_view_that_sets_its_own_cache_control_keeps_it(self):
from django.http import HttpResponse
from config.middleware import APINoStoreMiddleware
def view(request):
response = HttpResponse()
response['Cache-Control'] = 'no-cache'
return response
request = mock.Mock(path='/api/ai/ask')
self.assertEqual(APINoStoreMiddleware(view)(request)['Cache-Control'], 'no-cache')
class SummaryShowsTheActivePlanTests(TestCase):
def test_active_subscription_is_named(self):
from subscription.models import Plan, Subscription
user = User.objects.create(phone='09120000032')
auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(user.id)}'}
self.assertIsNone(self.client.get('/api/user/summary', **auth).json()['subscription'])
plan = Plan.objects.create(title='طلایی', price=1000)
Subscription.objects.create(user_id=user.id, plan=plan, status='active')
self.assertEqual(self.client.get('/api/user/summary', **auth).json()['subscription'], 'طلایی')

View File

@@ -8,6 +8,7 @@ from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
from .models import Character, LeaderboardEntry, StreakRecord, UserHearts, UserStats from .models import Character, LeaderboardEntry, StreakRecord, UserHearts, UserStats
from .services import effective_current_streak
class GuestAwareReadThrottle(ScopedRateThrottle): class GuestAwareReadThrottle(ScopedRateThrottle):
@@ -34,6 +35,20 @@ from .serializers import (
) )
def _active_plan_title(user_id):
from django.db.models import Q
from django.utils import timezone
from subscription.models import Subscription
sub = (
Subscription.objects.select_related('plan')
.filter(user_id=user_id, status='active')
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now()))
.first()
)
return sub.plan.title if sub and sub.plan else None
class UserSummaryView(APIView): class UserSummaryView(APIView):
"""GET /api/user/summary — پروفایل + آمار + استریک (ادغام‌شده)""" """GET /api/user/summary — پروفایل + آمار + استریک (ادغام‌شده)"""
@@ -55,11 +70,12 @@ class UserSummaryView(APIView):
'xp': stats.xp if stats else 0, 'xp': stats.xp if stats else 0,
'coins': stats.coins if stats else 0, 'coins': stats.coins if stats else 0,
'streak': { 'streak': {
'current': streak.current_streak if streak else 0, 'current': effective_current_streak(streak),
'longest': streak.longest_streak if streak else 0, 'longest': streak.longest_streak if streak else 0,
}, },
# اپ subscription هنوز در این فاز پیاده‌سازی نشده؛ فعلاً null. # عنوانِ پلنِ اشتراکِ فعال، یا null. تا ۲۰۲۶-۰۹-۲۴ همیشه null بود
'subscription': None, # (از زمانی که اپِ subscription هنوز وجود نداشت).
'subscription': _active_plan_title(user.id),
} }
return Response(UserSummaryResponseSerializer(data).data) return Response(UserSummaryResponseSerializer(data).data)
@@ -77,13 +93,18 @@ class StreakCalendarView(APIView):
streak = StreakRecord.objects.filter(user_id=user.id).first() streak = StreakRecord.objects.filter(user_id=user.id).first()
data = { data = {
'currentStreak': streak.current_streak if streak else 0, 'currentStreak': effective_current_streak(streak),
'longestStreak': streak.longest_streak if streak else 0, 'longestStreak': streak.longest_streak if streak else 0,
'activeDays': list(streak.active_days.values_list('date', flat=True)) if streak else [], 'activeDays': list(streak.active_days.values_list('date', flat=True)) if streak else [],
} }
return Response(StreakCalendarResponseSerializer(data).data) return Response(StreakCalendarResponseSerializer(data).data)
# کوتاه تا XPی تازه زود دیده شود، بلند تا هزار کاربرِ هم‌زمان هر بار جدول را
# از نو حساب نکنند.
LEADERBOARD_CACHE_TTL = 60
class LeaderboardView(APIView): class LeaderboardView(APIView):
"""GET /api/leaderboard?period= — رتبه‌بندی هفتگی/ماهانه""" """GET /api/leaderboard?period= — رتبه‌بندی هفتگی/ماهانه"""
@@ -98,7 +119,20 @@ class LeaderboardView(APIView):
) )
def get(self, request): def get(self, request):
period = request.query_params.get('period', 'weekly') period = request.query_params.get('period', 'weekly')
entries = LeaderboardEntry.objects.filter(period=period).order_by('rank')[:50]
# زنده از فعالیتِ واقعی، با کشِ کوتاه (progress/leaderboard.py). قبلاً از
# جدولِ LeaderboardEntry خوانده می‌شد که فقط با اجرای دستیِ
# rebuild_leaderboard پر می‌شد، پس XPی کاربرِ تازه هیچ‌وقت دیده نمی‌شد.
from django.core.cache import cache
from .leaderboard import compute_scores
scores = cache.get_or_set(
f'leaderboard:{period}', lambda: compute_scores(period), LEADERBOARD_CACHE_TTL,
)
entries = [
LeaderboardEntry(user_id=uid, period=period, xp_in_period=xp, rank=rank)
for rank, (uid, xp) in enumerate(scores, 1)
]
# نام کاربر از accounts.User خونده می‌شه (فقط برای نمایش، بدون FK واقعی). # نام کاربر از accounts.User خونده می‌شه (فقط برای نمایش، بدون FK واقعی).
from accounts.models import User as AccountUser from accounts.models import User as AccountUser