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:
@@ -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
|
||||||
|
|||||||
@@ -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 در
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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` تا قفلهای همزمانیِ نوشتن را تحمل کند).
|
||||||
|
|||||||
@@ -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
28
gunicorn.conf.py
Normal 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
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]
|
||||||
@@ -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
59
progress/services.py
Normal 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
|
||||||
131
progress/tests_live_gamification.py
Normal file
131
progress/tests_live_gamification.py
Normal 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'], 'طلایی')
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user