diff --git a/config/middleware.py b/config/middleware.py index 5dcf5ad..06eaf94 100644 --- a/config/middleware.py +++ b/config/middleware.py @@ -89,3 +89,24 @@ class APIRequestLoggingMiddleware: log_func(message, request.method, request.path, status, duration_ms, _user_id_for_log(request)) except Exception: 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 diff --git a/config/settings.py b/config/settings.py index f3e22cc..356b6af 100644 --- a/config/settings.py +++ b/config/settings.py @@ -225,6 +225,8 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + # پاسخِ API هرگز در کشِ ArvanCloud/مرورگر نماند (config/middleware.py). + 'config.middleware.APINoStoreMiddleware', # ⚠️ افزوده شده: لاگِ سبکِ هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر)؛ # عمداً آخرِ لیست قرار گرفته تا نزدیک‌ترین میان‌افزار به خودِ view باشه # (مدت‌زمانِ دقیق‌تر، و request.user که توسطِ DRF authentication در diff --git a/content/views.py b/content/views.py index a5828dd..99a3e74 100644 --- a/content/views.py +++ b/content/views.py @@ -1,3 +1,5 @@ +import logging + from django.core.cache import cache from django.db import IntegrityError, transaction from django.db.models import Count, F @@ -43,6 +45,8 @@ from .serializers import ( QuestionReportResponseSerializer, ) +logger = logging.getLogger(__name__) + class GuestAwareContentThrottle(ScopedRateThrottle): """ @@ -163,6 +167,18 @@ class GradesListView(APIView): 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): # آزمونِ مبحث تمام شده → مبحث تمام است، مستقل از تک‌تکِ درس‌ها. if quiz_completed: @@ -437,6 +453,7 @@ class QuestionAnswerView(APIView): 'heartsRemaining': hearts_remaining if hearts_remaining is not None else 5, 'mascotMood': 'happy' if choice.is_correct else 'encourage', } + _record_streak(user_id) return Response(AnswerResponseSerializer(data).data) @@ -593,6 +610,7 @@ class LessonCompleteView(APIView): 'coinsEarned': coins_earned, 'stats': stats_payload, } + _record_streak(user_id) return Response(LessonCompleteResponseSerializer(data).data) @@ -904,6 +922,7 @@ class NodeQuizCompleteView(APIView): raise stats = UserStats.objects.filter(user_id=user_id).first() + _record_streak(user_id) return respond(completion, stats) diff --git a/docs/architecture.md b/docs/architecture.md index 993807c..8ad563a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -109,6 +109,20 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م می‌کند؛ قبلاً 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` تا قفل‌های هم‌زمانیِ نوشتن را تحمل کند). diff --git a/docs/operations.md b/docs/operations.md index 1973d93..5a93235 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -106,6 +106,7 @@ curl https://api.math.tadrisino2.ir/api/health | `CACHE_URL` | سرویس بالا می‌آید ولی نرخ بینِ workerها مشترک نیست؛ `/api/health` هشدار می‌دهد | | `KAVENEGAR_API_KEY` | `POST /api/auth/otp/request` خطای صریحِ `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ِ قدیمی | | `OPENAI_API_KEY` · `QDRANT_API_KEY` | `POST /api/ai/ask` همان پاسخِ stub را می‌دهد | | `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) | diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..da28770 --- /dev/null +++ b/gunicorn.conf.py @@ -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 diff --git a/progress/leaderboard.py b/progress/leaderboard.py new file mode 100644 index 0000000..5addd5f --- /dev/null +++ b/progress/leaderboard.py @@ -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] diff --git a/progress/management/commands/rebuild_leaderboard.py b/progress/management/commands/rebuild_leaderboard.py index 9faf194..ff33d86 100644 --- a/progress/management/commands/rebuild_leaderboard.py +++ b/progress/management/commands/rebuild_leaderboard.py @@ -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] diff --git a/progress/services.py b/progress/services.py new file mode 100644 index 0000000..4bfa2d5 --- /dev/null +++ b/progress/services.py @@ -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 diff --git a/progress/tests_live_gamification.py b/progress/tests_live_gamification.py new file mode 100644 index 0000000..85669a4 --- /dev/null +++ b/progress/tests_live_gamification.py @@ -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'], 'طلایی') diff --git a/progress/views.py b/progress/views.py index 1037e73..5b502a1 100644 --- a/progress/views.py +++ b/progress/views.py @@ -8,6 +8,7 @@ from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView from .models import Character, LeaderboardEntry, StreakRecord, UserHearts, UserStats +from .services import effective_current_streak 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): """GET /api/user/summary — پروفایل + آمار + استریک (ادغام‌شده)""" @@ -55,11 +70,12 @@ class UserSummaryView(APIView): 'xp': stats.xp if stats else 0, 'coins': stats.coins if stats else 0, 'streak': { - 'current': streak.current_streak if streak else 0, + 'current': effective_current_streak(streak), 'longest': streak.longest_streak if streak else 0, }, - # اپ subscription هنوز در این فاز پیاده‌سازی نشده؛ فعلاً null. - 'subscription': None, + # عنوانِ پلنِ اشتراکِ فعال، یا null. تا ۲۰۲۶-۰۹-۲۴ همیشه null بود + # (از زمانی که اپِ subscription هنوز وجود نداشت). + 'subscription': _active_plan_title(user.id), } return Response(UserSummaryResponseSerializer(data).data) @@ -77,13 +93,18 @@ class StreakCalendarView(APIView): streak = StreakRecord.objects.filter(user_id=user.id).first() data = { - 'currentStreak': streak.current_streak if streak else 0, + 'currentStreak': effective_current_streak(streak), 'longestStreak': streak.longest_streak if streak else 0, 'activeDays': list(streak.active_days.values_list('date', flat=True)) if streak else [], } return Response(StreakCalendarResponseSerializer(data).data) +# کوتاه تا XPی تازه زود دیده شود، بلند تا هزار کاربرِ هم‌زمان هر بار جدول را +# از نو حساب نکنند. +LEADERBOARD_CACHE_TTL = 60 + + class LeaderboardView(APIView): """GET /api/leaderboard?period= — رتبه‌بندی هفتگی/ماهانه""" @@ -98,7 +119,20 @@ class LeaderboardView(APIView): ) def get(self, request): 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 واقعی). from accounts.models import User as AccountUser