From 7ecb33726357abaf8f32c9093130256309cf538b Mon Sep 17 00:00:00 2001 From: Zareian Date: Wed, 7 Oct 2026 13:30:24 +0330 Subject: [PATCH] Finishing a lesson or island needs recorded answers and an unlocked island; auto DB backup before migrate; throttles on 15 more routes - content: POST nodes/{id}/quiz/complete and lessons/{id}/complete refuse a locked island (403 node_locked, same rule as /api/home) and a user who has not answered every question (409 answers_missing). Before, only a user with zero hearts and no answers was refused, so anyone else finished any island, locked or not, and finishing a locked island's lessons unlocked it. A node or lesson with no questions still passes when unlocked, with no XP. The client can no longer understate totalQuestions to buy stars. - throttle_scope on 15 routes that had none (question bank without login, intro, hearts, notifications, tickets, plans, subscription status, orders, offers, affiliate, assistant history and feedback, logout). - admin: OTPCode and Session can only be added/changed by a superuser. - deploy.sh: integrity-checked DB backup before migrate (keeps the last 2); no migrate without it. - docs and CLAUDE.md updated to say what is verified and what is not. Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 15 +- README.md | 6 +- accounts/admin.py | 14 ++ accounts/tests_security_review.py | 22 +++ accounts/views.py | 4 + affiliate/views.py | 8 + ai_tutor/views.py | 8 + content/testing.py | 27 ++++ content/tests.py | 15 +- content/tests_finish_requires_answers.py | 192 +++++++++++++++++++++++ content/tests_flow.py | 2 + content/tests_hearts_gate.py | 7 +- content/tests_island_progress.py | 6 +- content/tests_node_completion.py | 86 ++++++++-- content/tests_read_throttles.py | 98 ++++++++++++ content/views.py | 106 +++++++++++-- deploy.sh | 61 +++++++ discount/views.py | 4 + docs/operations.md | 6 +- docs/security-before-production.md | 46 +++--- notifications/views.py | 3 + payment/views.py | 4 + progress/tests_live_gamification.py | 5 +- progress/views.py | 2 + subscription/views.py | 8 + support/views.py | 2 + 26 files changed, 688 insertions(+), 69 deletions(-) create mode 100644 content/testing.py create mode 100644 content/tests_finish_requires_answers.py create mode 100644 content/tests_read_throttles.py diff --git a/CLAUDE.md b/CLAUDE.md index fc27c94..657ff8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -298,7 +298,12 @@ grades 8–12 does; the 39 that fall short are all in grades 3/6/7. Finishing the quiz finishes the island: `POST /api/nodes/{id}/quiz/complete` marks the node completed whatever the score (the owner's call on 2026-09-24: keep it simple) and returns -`nextNodeId`. Before it existed, a node only completed when every one of its 8–13 lessons +`nextNodeId`. Since 2026-10-07 it refuses (in code; deployed only after the next release) a node +that is locked on the map (403 `node_locked`, same rule as `/api/home`, `content.views.node_is_unlocked`), +and a user who has not answered every quiz question (409 `answers_missing`; +the required count is `max(client totalQuestions, min(4, questions in node))`, so the client cannot +understate it). A node or lesson with no questions has nothing to answer: it passes when unlocked (no stars, no XP) so it does not block the chain, and is still refused when locked. `POST /api/lessons/{id}/complete` has the same lock and answer rules. Before that, +only a user with zero hearts and no answers was refused; anyone else finished any island, locked or not. Before it existed, a node only completed when every one of its 8–13 lessons was completed one by one, so a student playing node quizzes was stuck after the first island of every unit. Details in `docs/architecture.md`. @@ -821,10 +826,10 @@ migrationها اعمال شدند، سه‌یار و `nextLessonId` از بیر (`templates/tadrisino/`) تا متنِ فارسی راست‌به‌چپ رندر شود. قالبِ drf-spectacular دست‌نخورده است و با `{{ block.super }}` گسترش داده شده. -**بکاپ هنوز خودکار نیست.** `deploy.sh` پیش از `migrate` بکاپ نمی‌گیرد؛ روشِ دستی در -`docs/operations.md` است و در سه دیپلویِ آن روز دستی گرفته شد. خودکار کردنش -ارزشمندترین کارِ بعدی‌ست. `sqlite3` روی سرور نصب نیست — با `venv/bin/python` و -`sqlite3.Connection.backup()` بکاپ بگیر، نه با `cp`. +**بکاپ:** `deploy.sh` پیش از `migrate` خودش بکاپ می‌گیرد (`db-before-deploy-*`، ۲ تای آخر، +بی‌بکاپ migrate نمی‌شود)؛ فقط روی لپ‌تاپ آزموده شد و روی سرور هنوز اجرا نشده. بیرون از ماشین بکاپی +نیست. `sqlite3` روی سرور نصب نیست — برای بکاپِ دستی با `venv/bin/python` و +`sqlite3.Connection.backup()` بگیر، نه با `cp`. بخش‌های تاریخیِ زیر به‌ترتیبِ زمان نگه داشته شده‌اند چون درس‌هایشان هنوز معتبر است؛ **اعدادِ محتوایی‌شان کهنه‌اند و ملاک همین بخش است.** diff --git a/README.md b/README.md index 9e80a6c..0b0c320 100644 --- a/README.md +++ b/README.md @@ -136,9 +136,9 @@ curl https://mathapi.tadrisino2.ir/api/health - **دیتابیس هنوز SQLite است**، نه Postgres. برای حجمِ فعلی کافی‌ست، ولی تک‌نویسنده بودنش اولین گلوگاهِ مقیاس است. -- **بکاپِ خودکار وجود ندارد.** `db.sqlite3` در گیت نیست و تنها نسخه‌ی داده‌ی - کاربران است. روشِ بکاپِ دستی در [`docs/operations.md`](docs/operations.md) - آمده؛ خودکار کردنش اولین کارِ ارزشمندِ بعدی است. +- **بکاپ فقط روی همان ماشین است.** `deploy.sh` پیش از `migrate` از `db.sqlite3` بکاپ می‌گیرد + (۲ تای آخر)، ولی نسخه‌ای بیرون از سرور نیست. جزئیات در + [`docs/operations.md`](docs/operations.md). - **`CACHE_URL` روی سرور ست نشده**، پس محدودیتِ نرخ بینِ workerها مشترک نیست. Redis روی همان ماشین بالاست. `/api/health` این را هشدار می‌دهد. - **`KAVENEGAR_API_KEY` تنظیم نشده**، پس `POST /api/auth/otp/request` برای diff --git a/accounts/admin.py b/accounts/admin.py index 4c6b565..010dbe8 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -174,12 +174,26 @@ class SessionAdmin(admin.ModelAdmin): list_display = ('id', 'user', 'expires_at', 'revoked') list_filter = ('revoked',) + # ساختن/ویرایشِ دستیِ نشست یعنی ساختنِ ورود برای هر کاربر؛ فقط ابرکاربر. + def has_add_permission(self, request): + return request.user.is_superuser + + def has_change_permission(self, request, obj=None): + return request.user.is_superuser + @admin.register(OTPCode) class OTPCodeAdmin(admin.ModelAdmin): list_display = ('id', 'phone', 'code', 'expires_at', 'is_used') list_filter = ('is_used',) + # ردیفِ OTP که کارمند می‌سازد = ورود بی‌پیامک به هر شماره (راهِ تستر)؛ فقط ابرکاربر. + def has_add_permission(self, request): + return request.user.is_superuser + + def has_change_permission(self, request, obj=None): + return request.user.is_superuser + # صفحه‌ی «ناظر» (Supervisor): یک ایندکسِ read-only از لینک‌ها به # changelistهای موجود (کاربران/پرداخت‌ها/اشتراک‌ها)، داخلِ همون AdminSite diff --git a/accounts/tests_security_review.py b/accounts/tests_security_review.py index 81520bd..2b1d982 100644 --- a/accounts/tests_security_review.py +++ b/accounts/tests_security_review.py @@ -218,3 +218,25 @@ class UnicodeInputsAreNormalisedOrRejectedTests(TestCase): self.assertEqual(clean('علی رضایی'), 'علی رضایی') + + +class StaffCannotMintLoginsTests(TestCase): + """کارمندِ غیرابرکاربر با اجازه‌ی add نمی‌تواند OTPCode یا Session دستی بسازد (= ورود به هر شماره).""" + + def setUp(self): + from django.contrib.auth.models import Permission, User as AuthUser + self.staff = AuthUser.objects.create_user('staff1', 's@x.c', 'pw12345!', is_staff=True) + self.staff.user_permissions.set(Permission.objects.filter( + codename__in=['add_otpcode', 'change_otpcode', 'view_otpcode', + 'add_session', 'change_session', 'view_session'])) + self.root = AuthUser.objects.create_superuser('root1', 'r@x.c', 'pw12345!') + + def test_staff_gets_403_on_add_pages(self): + self.client.force_login(self.staff) + for model in ('otpcode', 'session'): + self.assertEqual(self.client.get(f'/admin/accounts/{model}/add/').status_code, 403, model) + + def test_superuser_can_still_add(self): + self.client.force_login(self.root) + for model in ('otpcode', 'session'): + self.assertEqual(self.client.get(f'/admin/accounts/{model}/add/').status_code, 200, model) diff --git a/accounts/views.py b/accounts/views.py index fd545a8..fb40341 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -374,6 +374,10 @@ class LogoutView(APIView): کاربر فقط در همین worker پاک می‌شود؛ پیش‌فرض ۰ است و آن حالت تست نشده. """ + # سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `user_profile_write` همان نرخِ مسیرهای هم‌خانواده است. + throttle_scope = 'user_profile_write' + throttle_classes = [ScopedRateThrottle] + permission_classes = [IsAuthenticated] @extend_schema( diff --git a/affiliate/views.py b/affiliate/views.py index 38c5ba2..989c655 100644 --- a/affiliate/views.py +++ b/affiliate/views.py @@ -18,6 +18,10 @@ from .serializers import ( class AffiliateMeView(APIView): """GET /api/affiliate/me — حساب و کد ارجاع کاربر""" + # سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای هم‌خانواده است. + throttle_scope = 'read' + throttle_classes = [ScopedRateThrottle] + permission_classes = [IsAuthenticated] @extend_schema(responses=AffiliateMeResponseSerializer, tags=['Affiliate (affiliate)']) @@ -80,6 +84,10 @@ class TrackConversionView(APIView): class ConversionsListView(APIView): """GET /api/affiliate/conversions — لیست تبدیل‌ها و کمیسیون""" + # سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای هم‌خانواده است. + throttle_scope = 'read' + throttle_classes = [ScopedRateThrottle] + permission_classes = [IsAuthenticated] @extend_schema(responses=ConversionListResponseSerializer, tags=['Affiliate (affiliate)']) diff --git a/ai_tutor/views.py b/ai_tutor/views.py index 1a88960..e5a98a6 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -791,6 +791,10 @@ class AskAIView(APIView): class ConversationMessagesView(APIView): """GET /api/ai/conversations/{id}/messages — تاریخچه‌ی مکالمه""" + # سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای هم‌خانواده است. + throttle_scope = 'read' + throttle_classes = [ScopedRateThrottle] + permission_classes = [IsAuthenticated] @extend_schema(responses=ConversationMessagesResponseSerializer, tags=['AI Tutor (ai_tutor)']) @@ -814,6 +818,10 @@ class ConversationMessagesView(APIView): class MessageFeedbackView(APIView): """POST /api/ai/messages/{id}/feedback — فیدبک 👍/👎""" + # سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `user_profile_write` همان نرخِ مسیرهای هم‌خانواده است. + throttle_scope = 'user_profile_write' + throttle_classes = [ScopedRateThrottle] + permission_classes = [IsAuthenticated] @extend_schema( diff --git a/content/testing.py b/content/testing.py new file mode 100644 index 0000000..35d4e88 --- /dev/null +++ b/content/testing.py @@ -0,0 +1,27 @@ +"""کمک‌تابعِ تست‌ها: جوابِ ثبت‌شده مثلِ اپ (اپ هر سوال را جدا با /answer ثبت می‌کند و بعد پایان را می‌زند).""" +from .models import Choice, Question, UserAnswer + + +def record_answers(user_id, *, lesson=None, node=None, correct=True): + """ + به همه‌ی سوال‌های یک درس یا یک جزیره جوابِ ثبت‌شده می‌دهد (مستقیم در DB، بی‌قلب و بی‌throttle). + `correct=True` گزینه‌ی درست، `False` گزینه‌ی غلط را می‌زند؛ سوالی که آن گزینه را ندارد + گزینه‌اش ساخته می‌شود. سوالی که پیش‌تر جواب داده شده دست نمی‌خورد. تعدادِ سوال‌های جوابِ‌داده‌شده را برمی‌گرداند. + """ + questions = Question.objects.all() + if lesson is not None: + questions = questions.filter(lesson=lesson) + if node is not None: + questions = questions.filter(lesson__node=node) + count = 0 + for question in questions.prefetch_related('choices'): + choice = next((c for c in question.choices.all() if c.is_correct == correct), None) + if choice is None: + choice = Choice.objects.create( + question=question, value='درست' if correct else 'غلط', is_correct=correct) + UserAnswer.objects.get_or_create( + user_id=user_id, question=question, + defaults={'choice': choice, 'correct': correct, 'xp_earned': 10 if correct else 0}, + ) + count += 1 + return count diff --git a/content/tests.py b/content/tests.py index 55e6aab..d685303 100644 --- a/content/tests.py +++ b/content/tests.py @@ -12,6 +12,7 @@ from progress.models import UserHearts, UserStats from subscription.models import Plan, Subscription from .models import Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion +from .testing import record_answers class LessonCompleteImpossibleCountTests(TestCase): @@ -93,11 +94,14 @@ class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase): Choice.objects.create(question=question, value='درست', is_correct=True) def _complete(self, correct_count, total_questions): - # مثلِ اپ: جواب‌ها اول ثبت می‌شوند؛ امتیاز فقط برای جوابِ ثبت‌شده است. - for question in self.lesson.questions.order_by('id')[:max(correct_count, 0)]: + # مثلِ اپ: همه‌ی سوال‌ها جواب داده می‌شوند (پایانِ درس بی‌جواب پذیرفته نمی‌شود)، ولی فقط + # `correct_count` تایشان درست؛ امتیاز فقط برای جوابِ درستِ ثبت‌شده است. + for index, question in enumerate(self.lesson.questions.order_by('id')): + Choice.objects.create(question=question, value='غلط', is_correct=False) self.client.post( f'/api/questions/{question.id}/answer', - {'selectedOptionId': str(question.choices.get(is_correct=True).id)}, + {'selectedOptionId': str(question.choices.get( + is_correct=index < max(correct_count, 0)).id)}, format='json', HTTP_AUTHORIZATION=f'Bearer {self.token}', ) return self.client.post( @@ -259,6 +263,8 @@ class LessonCompleteThrottleTests(TestCase): Question.objects.create( lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}', ) + # پایانِ درس بی‌جواب پذیرفته نمی‌شود؛ این تست‌ها فقط سقفِ نرخ را می‌سنجند. + record_answers(self.user.id, lesson=self.lesson) def _complete(self, idempotency_key=None, remote_addr=None): kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'} @@ -292,6 +298,7 @@ class LessonCompleteThrottleTests(TestCase): # کلیدِ throttle شناسه‌ی کاربر است، نه IP؛ تغییرِ IP سطلِ تازه نمی‌دهد. registered_user = User.objects.create(name='', phone='+989120000702') registered_token = issue_access_token(registered_user.id) + record_answers(registered_user.id, lesson=self.lesson) def _complete_as(remote_addr): return self.client.post( @@ -421,10 +428,12 @@ class LessonCompletePerUserThrottleTests(TestCase): # قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن. with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '1/hour'}): user_a = User.objects.create(name='', phone='+989120000721') + record_answers(user_a.id, lesson=self.lesson) response_a = self._complete(issue_access_token(user_a.id)) self.assertEqual(response_a.status_code, 200) user_b = User.objects.create(name='', phone='+989120000722') + record_answers(user_b.id, lesson=self.lesson) response_b = self._complete(issue_access_token(user_b.id)) self.assertEqual(response_b.status_code, 200) diff --git a/content/tests_finish_requires_answers.py b/content/tests_finish_requires_answers.py new file mode 100644 index 0000000..efbf687 --- /dev/null +++ b/content/tests_finish_requires_answers.py @@ -0,0 +1,192 @@ +""" +پایانِ درس و جزیره بی‌جواب و روی جزیره‌ی قفل پذیرفته نمی‌شود (۲۰۲۶-۱۰-۰۷). + +تا پیش از این: کسی که قلب داشت و هیچ سوالی جواب نداده بود جزیره را تمام می‌کرد، حتی جزیره‌ی قفل را +(با correctCount=0)؛ نقشه هم وضعیتِ «باز» را از روی درس‌های تمام‌شده حساب می‌کرد، پس تمام‌کردنِ +درسِ جزیره‌ی قفل آن را باز می‌کرد. فقط قلبِ صفر جلوی بی‌جواب‌تمام‌کردن را می‌گرفت. +""" +from django.core.cache import cache +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from accounts.authentication import issue_access_token +from accounts.models import User +from progress.models import UserHearts, UserStats + +from .models import ( + Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserLessonCompletion, UserNodeCompletion, +) +from .testing import record_answers +from .views import node_is_unlocked + + +class _World: + def build(self, phone): + cache.clear() + self.grade = Grade.objects.create(title='پایه نهم', icon='', color='') + self.unit = Unit.objects.create(grade=self.grade, title='فصل ۱', order=1) + self.other_unit = Unit.objects.create(grade=self.grade, title='فصل ۲', order=2) + self.n1 = self.node(self.unit, 'یک', 1) + self.n2 = self.node(self.unit, 'دو', 2) + self.n3 = self.node(self.unit, 'سه', 3) + self.m1 = self.node(self.other_unit, 'الف', 1) + self.user = User.objects.create(phone=phone, grade_id=self.grade.id) + self.client = APIClient() + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') + + def node(self, unit, title, order, lessons=2): + node = Node.objects.create(unit=unit, title=title, order=order) + for i in range(lessons): + lesson = Lesson.objects.create(node=node, title=f'{title} {i}') + q = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='؟') + Choice.objects.create(question=q, value='درست', is_correct=True) + Choice.objects.create(question=q, value='غلط', is_correct=False) + return node + + def finish_node(self, node, correct=0, total=2): + return self.client.post(f'/api/nodes/{node.id}/quiz/complete', + {'correctCount': correct, 'totalQuestions': total}, format='json') + + def finish_lesson(self, lesson): + return self.client.post(f'/api/lessons/{lesson.id}/complete', + {'correctCount': 0, 'totalQuestions': 1}, format='json') + + def statuses(self): + units = self.client.get('/api/home').json()['units'] + return {n['title']: n['status'] for u in units for n in u['nodes']} + + +class FinishWithoutAnswersTests(_World, TestCase): + def setUp(self): + self.build('09120007801') + + def tearDown(self): + cache.clear() + + def test_a_user_with_hearts_and_no_answers_cannot_finish_the_island(self): + """همان جمله‌ای که باز مانده بود: قلب دارد، هیچ سوالی جواب نداده.""" + self.assertEqual(UserHearts.objects.filter(user_id=self.user.id).count(), 0) # قلبِ پیش‌فرضِ پر + response = self.finish_node(self.n1) + self.assertEqual((response.status_code, response.json()['code']), (409, 'answers_missing')) + self.assertEqual((response.json()['answered'], response.json()['required']), (0, 2)) + self.assertFalse(UserNodeCompletion.objects.exists()) + self.assertFalse(NodeProgress.objects.filter(status='completed').exists()) + self.assertEqual(self.statuses()['دو'], 'locked') + + def test_the_locked_island_cannot_be_finished_even_after_answering_everything(self): + record_answers(self.user.id, node=self.n2) + response = self.finish_node(self.n2, correct=2) + self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked')) + self.assertFalse(UserNodeCompletion.objects.exists()) + self.assertFalse(UserStats.objects.filter(user_id=self.user.id, xp__gt=0).exists()) + + def test_the_locked_island_cannot_be_finished_with_zero_answers_either(self): + response = self.finish_node(self.n3) + self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked')) + + def test_finishing_the_lessons_of_a_locked_island_does_not_unlock_it(self): + """بدونِ قفلِ درس، تمام‌کردنِ یک درسِ جزیره‌ی قفل وضعیتِ آن را «باز» می‌کرد.""" + lesson = self.n2.lessons.first() + record_answers(self.user.id, lesson=lesson) + response = self.finish_lesson(lesson) + self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked')) + self.assertFalse(UserLessonCompletion.objects.exists()) + self.assertEqual(self.statuses()['دو'], 'locked') + + def test_a_lesson_cannot_be_finished_without_answers(self): + lesson = self.n1.lessons.first() + response = self.finish_lesson(lesson) + self.assertEqual((response.status_code, response.json()['code']), (409, 'answers_missing')) + self.assertFalse(UserLessonCompletion.objects.exists()) + + def test_a_lesson_with_some_answers_missing_is_refused(self): + lesson = self.n1.lessons.first() + second = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='دوم؟') + Choice.objects.create(question=second, value='x', is_correct=True) + first = lesson.questions.exclude(pk=second.pk).get() + record_answers(self.user.id, lesson=lesson) + from .models import UserAnswer + UserAnswer.objects.filter(question=second).delete() # فقط سوالِ اول جواب داده شده + response = self.finish_lesson(lesson) + self.assertEqual((response.status_code, response.json()['answered']), (409, 1)) + record_answers(self.user.id, lesson=lesson) + self.assertEqual(self.finish_lesson(lesson).status_code, 200) + self.assertTrue(first.answers.exists()) + + def test_a_lesson_without_questions_in_an_open_island_still_passes_and_earns_nothing(self): + empty = Lesson.objects.create(node=self.n1, title='بی‌سوال') + response = self.finish_lesson(empty) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual((response.json()['xpEarned'], response.json()['stars']), (0, 0)) + + def test_a_lesson_without_questions_in_a_locked_island_is_still_locked(self): + empty = Lesson.objects.create(node=self.n3, title='بی‌سوالِ قفل') + response = self.finish_lesson(empty) + self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked')) + self.assertFalse(UserLessonCompletion.objects.exists()) + + def test_the_normal_path_still_works_in_order(self): + """جزیره‌ی اول، بعد دوم، بعد سوم؛ و اولین جزیره‌ی فصلِ دیگر از اول باز است.""" + self.assertEqual(self.statuses(), {'یک': 'unlocked', 'دو': 'locked', 'سه': 'locked', 'الف': 'unlocked'}) + for node in (self.n1, self.n2, self.n3, self.m1): + record_answers(self.user.id, node=node) + response = self.finish_node(node, correct=2) + self.assertEqual(response.status_code, 200, (node.title, response.content)) + self.assertEqual(set(self.statuses().values()), {'completed'}) + + def test_replaying_a_finished_island_still_works(self): + record_answers(self.user.id, node=self.n1) + self.assertEqual(self.finish_node(self.n1, correct=2).status_code, 200) + self.assertEqual(self.finish_node(self.n1, correct=2).status_code, 200) + + def test_an_idempotent_retry_of_a_finished_island_returns_the_first_result(self): + record_answers(self.user.id, node=self.n1) + kwargs = {'HTTP_IDEMPOTENCY_KEY': 'k-1'} + first = self.client.post(f'/api/nodes/{self.n1.id}/quiz/complete', + {'correctCount': 2, 'totalQuestions': 2}, format='json', **kwargs) + again = self.client.post(f'/api/nodes/{self.n1.id}/quiz/complete', + {'correctCount': 2, 'totalQuestions': 2}, format='json', **kwargs) + self.assertEqual((first.status_code, again.status_code), (200, 200)) + self.assertEqual(first.json()['xpEarned'], again.json()['xpEarned']) + + def test_unanswered_with_zero_hearts_still_gets_the_hearts_page_not_a_generic_error(self): + from datetime import timedelta + from django.utils import timezone + now = timezone.now() + UserHearts.objects.create(user_id=self.user.id, current_hearts=0, + next_refill_at=now + timedelta(hours=4), full_refill_at=now + timedelta(hours=20)) + with override_settings(HEARTS_ENFORCED=True): + response = self.finish_node(self.n1) + self.assertEqual((response.status_code, response.json()['code']), (403, 'no_hearts')) + + +class NodeIsUnlockedMatchesHomeTests(_World, TestCase): + """قاعده‌ی قفلِ سرور باید دقیقاً همانی باشد که نقشه (`/api/home`) نشان می‌دهد.""" + + def setUp(self): + self.build('09120007802') + + def _assert_matches_home(self, label): + home = self.statuses() + for node in (self.n1, self.n2, self.n3, self.m1): + self.assertEqual( + node_is_unlocked(self.user.id, node), home[node.title] != 'locked', + f'{label}: {node.title} (نقشه: {home[node.title]})', + ) + + def test_matches_at_every_step(self): + self._assert_matches_home('شروع') + record_answers(self.user.id, node=self.n1) + self.assertEqual(self.finish_node(self.n1, correct=2).status_code, 200) + self._assert_matches_home('بعد از جزیره‌ی یک') + # تمام‌کردنِ درس‌های جزیره‌ی دو (از راهِ درس، نه آزمون) + for lesson in self.n2.lessons.all(): + record_answers(self.user.id, lesson=lesson) + self.assertEqual(self.finish_lesson(lesson).status_code, 200) + self._assert_matches_home('بعد از درس‌های جزیره‌ی دو') + self.assertEqual(self.statuses()['سه'], 'unlocked') + + def test_a_node_missing_from_the_tree_is_locked_and_logged(self): + ghost = Node(id=999999, unit=self.unit, title='شبح', order=9) + with self.assertLogs('content.views', level='WARNING'): + self.assertFalse(node_is_unlocked(self.user.id, ghost)) diff --git a/content/tests_flow.py b/content/tests_flow.py index 551565c..99b30be 100644 --- a/content/tests_flow.py +++ b/content/tests_flow.py @@ -317,6 +317,8 @@ class LessonCompletionNotificationTests(TestCase): self.user = User.objects.create(phone='09000000009', grade_id=grade.id) from accounts.authentication import issue_access_token + from content.testing import record_answers + record_answers(self.user.id, lesson=self.lesson) # پایانِ درس بی‌جواب پذیرفته نمی‌شود self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') def tearDown(self): diff --git a/content/tests_hearts_gate.py b/content/tests_hearts_gate.py index b4ffe61..e4baed2 100644 --- a/content/tests_hearts_gate.py +++ b/content/tests_hearts_gate.py @@ -103,7 +103,9 @@ class HeartsGateTests(_World, TestCase): def test_unlimited_hearts_from_a_pack_are_never_blocked(self): self.hearts(0) HeartsPass.objects.create(user_id=self.user.id, expires_at=timezone.now() + timedelta(days=7)) - self.assertEqual(self.answer(self.questions[0], correct=False).status_code, 200) + # جزیره دو سوال دارد و پایان بی‌جواب پذیرفته نمی‌شود؛ قلبِ نامحدود هر دو جواب را می‌گیرد. + for question in self.questions: + self.assertEqual(self.answer(question, correct=False).status_code, 200) self.assertEqual(self.finish_island().status_code, 200) def test_after_the_wait_a_heart_comes_back_and_play_continues(self): @@ -115,6 +117,9 @@ class HeartsGateTests(_World, TestCase): def test_hearts_off_changes_nothing(self): self.hearts(0) with override_settings(HEARTS_ENFORCED=False): + # با قلبِ خاموش، قلبِ صفر هم جواب‌دادن را نمی‌بندد؛ پایانِ جزیره هم پس از جواب‌ها می‌رسد. + for question in self.questions: + self.assertEqual(self.answer(question, correct=True).status_code, 200) self.assertEqual(self.finish_island().status_code, 200) diff --git a/content/tests_island_progress.py b/content/tests_island_progress.py index 0dfcae9..562b8af 100644 --- a/content/tests_island_progress.py +++ b/content/tests_island_progress.py @@ -15,6 +15,7 @@ from accounts.authentication import issue_access_token from accounts.models import User from .models import Choice, Grade, Lesson, Node, Question, Unit +from .testing import record_answers class IslandProgressTests(TestCase): @@ -29,9 +30,9 @@ class IslandProgressTests(TestCase): self.b = self._lesson(self.first, 'ب', with_question=True) self._lesson(self.second, 'ج', with_question=True) - user = User.objects.create(phone='09120000950', grade_id=grade.id) + self.user = User.objects.create(phone='09120000950', grade_id=grade.id) self.client = APIClient() - self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') def tearDown(self): cache.clear() @@ -52,6 +53,7 @@ class IslandProgressTests(TestCase): return {n['title']: n for n in units[0]['nodes']} def _complete(self, lesson): + record_answers(self.user.id, lesson=lesson) # مثلِ اپ: اول جواب، بعد پایان response = self.client.post(f'/api/lessons/{lesson.id}/complete', {'correctCount': 1, 'totalQuestions': 1}, format='json') self.assertEqual(response.status_code, 200, response.content) diff --git a/content/tests_node_completion.py b/content/tests_node_completion.py index 2287a3b..26ed292 100644 --- a/content/tests_node_completion.py +++ b/content/tests_node_completion.py @@ -11,8 +11,10 @@ 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, NodeProgress, Question, Unit, UserLessonCompletion, + Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserLessonCompletion, UserNodeCompletion, ) +from content.testing import record_answers +from content.views import NodeQuizView from notifications.models import Notification from progress.models import UserStats @@ -40,17 +42,20 @@ class NodeQuizCompleteTests(TestCase): Choice.objects.create(question=question, value='ب', is_correct=False) return node - def _answer_correctly(self, node, count): - """مثلِ اپ: اول جواب‌ها ثبت می‌شوند، بعد پایانِ آزمون.""" - questions = Question.objects.filter(lesson__node=node).order_by('id')[:count] - for question in questions: - choice = question.choices.get(is_correct=True) + def _answer(self, node, correct, wrong=0): + """مثلِ اپ: اول جواب‌ها ثبت می‌شوند (`correct` تای اول درست، `wrong` تای بعدی غلط)، بعد پایانِ آزمون.""" + questions = list(Question.objects.filter(lesson__node=node).order_by('id')[:correct + wrong]) + for index, question in enumerate(questions): + choice = question.choices.get(is_correct=index < correct) self.client.post(f'/api/questions/{question.pk}/answer', {'selectedOptionId': choice.pk}, content_type='application/json', **self.auth) def _complete(self, node, correct=3, total=4, answer=True, **headers): if answer: - self._answer_correctly(node, correct) + # سرور همه‌ی سوال‌های آزمون را جواب‌داده می‌خواهد (دستِ‌کم آزمونِ استانداردِ ۴ تایی). + pool = Question.objects.filter(lesson__node=node).count() + needed = max(total, min(NodeQuizView.DEFAULT_SIZE, pool)) + self._answer(node, correct, wrong=max(needed - correct, 0)) return self.client.post( f'/api/nodes/{node.pk}/quiz/complete', {'correctCount': correct, 'totalQuestions': total}, @@ -101,14 +106,62 @@ class NodeQuizCompleteTests(TestCase): self.assertEqual(UserStats.objects.get(user_id=self.user.id).xp, 40) def test_answers_that_were_never_given_earn_nothing(self): - # پیش‌تر correctCount همان‌طور که کلاینت فرستاده بود پذیرفته می‌شد. - body = self._complete(self.first, correct=4, answer=False).json() - self.assertEqual(body['xpEarned'], 0) - self.assertEqual(body['stars'], 0) + # پیش‌تر correctCount همان‌طور که کلاینت فرستاده بود پذیرفته می‌شد؛ بعد (۲۰۲۶-۱۰-۰۷) بی‌جواب + # اصلاً تمام نمی‌شود: ۴۰۹، و نه جزیره تمام می‌شود نه امتیازی ثبت. + response = self._complete(self.first, correct=4, answer=False) + self.assertEqual((response.status_code, response.json()['code']), (409, 'answers_missing')) + self.assertFalse(UserNodeCompletion.objects.filter(user_id=self.user.id).exists()) + self.assertEqual(self._statuses()['دوم'], 'locked') - self._answer_correctly(self.second, 2) - body = self._complete(self.second, correct=3, total=3, answer=False).json() - self.assertEqual(body['xpEarned'], 20) # فقط دو جوابِ ثبت‌شده + # چهار جواب ثبت شده ولی فقط دو تا درست؛ ادعای ۴ درست فقط برای همان دو امتیاز می‌دهد. + self._answer(self.first, correct=2, wrong=2) + body = self._complete(self.first, correct=4, total=4, answer=False).json() + self.assertEqual(body['xpEarned'], 20) + + def test_understating_the_question_count_does_not_buy_stars(self): + """با totalQuestions=1 و یک جوابِ درست ۳ ستاره می‌شد؛ آزمونِ استانداردِ سرور ۴ سوال است.""" + self._answer(self.first, correct=1, wrong=3) + body = self._complete(self.first, correct=1, total=1, answer=False).json() + self.assertEqual(body['stars'], 1) # ۱ از ۴ + self.assertEqual(UserNodeCompletion.objects.get(user_id=self.user.id).total_questions, 4) + + def test_answering_fewer_questions_than_the_quiz_has_is_refused(self): + self._answer(self.first, correct=3) + response = self._complete(self.first, correct=3, total=4, answer=False) + self.assertEqual(response.status_code, 409) + self.assertEqual((response.json()['answered'], response.json()['required']), (3, 4)) + + def test_a_locked_island_cannot_be_finished(self): + """جزیره‌ی دوم تا اولی تمام نشده قفل است؛ حتی با جوابِ همه‌ی سوال‌هایش.""" + self._answer(self.second, correct=3) + response = self._complete(self.second, correct=3, total=3, answer=False) + self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked')) + self.assertFalse(UserNodeCompletion.objects.filter(user_id=self.user.id).exists()) + self.assertEqual(self._statuses()['دوم'], 'locked') + + def test_the_first_island_of_every_unit_is_open(self): + """همان‌طور که نقشه نشان می‌دهد: اولین جزیره‌ی فصل‌ها بی‌قید باز است.""" + self.assertEqual(self._statuses()['فصلِ بعد'], 'unlocked') + self.assertEqual(self._complete(self.other_unit_first, correct=2, total=2).status_code, 200) + + def test_an_open_island_without_questions_passes_without_answers_and_earns_nothing(self): + """چیزی برای جواب‌دادن نیست؛ رد می‌شود تا مسیرِ بقیه را نبندد (بی‌ستاره، بی‌امتیاز).""" + empty = Node.objects.create(unit=self.next_unit, title='خالی', order=0) # اولین جزیره‌ی فصل: باز + Lesson.objects.create(node=empty, title='درسِ بی‌سوال') + response = self.client.post( + f'/api/nodes/{empty.pk}/quiz/complete', {'correctCount': 0, 'totalQuestions': 1}, + content_type='application/json', **self.auth) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual((response.json()['xpEarned'], response.json()['stars']), (0, 0)) + self.assertEqual(response.json()['nextNodeId'], str(self.other_unit_first.pk)) + + def test_a_locked_island_without_questions_is_still_locked(self): + empty = Node.objects.create(unit=self.unit, title='خالیِ قفل', order=9) # بعد از دو جزیره‌ی تمام‌نشده + Lesson.objects.create(node=empty, title='درسِ بی‌سوال') + response = self.client.post( + f'/api/nodes/{empty.pk}/quiz/complete', {'correctCount': 0, 'totalQuestions': 1}, + content_type='application/json', **self.auth) + self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked')) def test_same_idempotency_key_does_not_double_count(self): self._complete(self.first, correct=4, HTTP_IDEMPOTENCY_KEY='k1') @@ -144,6 +197,7 @@ class NodeQuizCompleteTests(TestCase): """درسِ تازه در همان جزیره نباید آن را به in_progress برگرداند.""" self._complete(self.first) lesson = self.first.lessons.first() + record_answers(self.user.id, lesson=lesson) self.client.post( f'/api/lessons/{lesson.pk}/complete', {'correctCount': 1, 'totalQuestions': 1}, @@ -163,7 +217,9 @@ class NodeQuizCompleteTests(TestCase): self.assertEqual(home['stats']['progressPercent'], 50.0) def test_lesson_completion_progress_is_per_grade_too(self): - lesson = self.second.lessons.first() + # درسی از جزیره‌ی باز (جزیره‌ی دوم تا اولی تمام نشده قفل است و درسش را نمی‌شود تمام کرد). + lesson = self.first.lessons.first() + record_answers(self.user.id, lesson=lesson) body = self.client.post( f'/api/lessons/{lesson.pk}/complete', {'correctCount': 1, 'totalQuestions': 1}, diff --git a/content/tests_read_throttles.py b/content/tests_read_throttles.py new file mode 100644 index 0000000..a43886d --- /dev/null +++ b/content/tests_read_throttles.py @@ -0,0 +1,98 @@ +""" +مسیرهای خواندنی‌ای که تا پیش از این سقفِ نرخ نداشتند (بند ۳۰ در docs/security-before-production.md). +هر تست نرخِ `read` را روی ۳ در دقیقه می‌گذارد و می‌بیند درخواستِ چهارم ۴۲۹ می‌گیرد. +""" +from unittest import mock + +from django.core.cache import cache +from django.test import TestCase +from rest_framework.test import APIClient +from rest_framework.throttling import ScopedRateThrottle + +from accounts.authentication import issue_access_token +from accounts.models import User +from content.models import Grade, Lesson, Node, Unit +from notifications.models import Notification +from support.models import Ticket + + +class ReadEndpointsAreThrottledTests(TestCase): + def setUp(self): + cache.clear() + grade = Grade.objects.create(title='G', icon='', color='', number=77) + unit = Unit.objects.create(grade=grade, title='U', order=1) + node = Node.objects.create(unit=unit, title='N', order=1) + self.lesson = Lesson.objects.create(node=node, title='L', intro_text='x') + self.user = User.objects.create(phone='09120008888') + self.notification = Notification.objects.create(user_id=self.user.id, title='t', body='b') + self.ticket = Ticket.objects.create(user_id=self.user.id, subject='s') + self.client = APIClient() + + def _auth(self): + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') + + def _hammer(self, method, url, scope='read'): + rates = dict(ScopedRateThrottle.THROTTLE_RATES, **{scope: '3/min'}) + with mock.patch.object(ScopedRateThrottle, 'THROTTLE_RATES', rates): + codes = [getattr(self.client, method)(url).status_code for _ in range(5)] + self.assertEqual(codes[3:], [429, 429], codes) + self.assertNotIn(429, codes[:3], codes) + + def test_lesson_intro_without_login(self): + self._hammer('get', f'/api/lessons/{self.lesson.id}/intro') + + def test_lesson_questions_without_login(self): + self._hammer('get', f'/api/lessons/{self.lesson.id}/questions') + + def test_progress_nodes(self): + self._auth() + self._hammer('get', '/api/progress/nodes') + + def test_hearts(self): + self._auth() + self._hammer('get', '/api/user/hearts') + + def test_notification_mark_read(self): + self._auth() + self._hammer('patch', f'/api/notifications/{self.notification.id}/read') + + def test_ticket_detail(self): + self._auth() + self._hammer('get', f'/api/support/tickets/{self.ticket.id}') + + # مسیرهای دسته‌ی دوم. شناسه‌ها عمداً وجود ندارند: throttle پیش از ویو اجرا می‌شود، پس + # ۴۰۴ هم یک درخواستِ شمرده‌شده است و جلوی ساختنِ داده‌ی هر مسیر را می‌گیرد. + def test_plans_without_login(self): + self._hammer('get', '/api/subscription/plans') + + def test_subscription_status(self): + self._auth() + self._hammer('get', '/api/subscription/status') + + def test_order_detail(self): + self._auth() + self._hammer('get', '/api/payment/orders/999999') + + def test_my_offers(self): + self._auth() + self._hammer('get', '/api/discount/offers/mine') + + def test_affiliate_me(self): + self._auth() + self._hammer('get', '/api/affiliate/me') + + def test_affiliate_conversions(self): + self._auth() + self._hammer('get', '/api/affiliate/conversions') + + def test_conversation_messages(self): + self._auth() + self._hammer('get', '/api/ai/conversations/999999/messages') + + def test_message_feedback(self): + self._auth() + self._hammer('post', '/api/ai/messages/999999/feedback', scope='user_profile_write') + + def test_logout(self): + self._auth() + self._hammer('post', '/api/auth/logout', scope='user_profile_write') diff --git a/content/views.py b/content/views.py index a94438a..9ff1119 100644 --- a/content/views.py +++ b/content/views.py @@ -209,6 +209,60 @@ def _answered(user_id, **lookup) -> int: return UserAnswer.objects.filter(user_id=user_id, **lookup).values('question_id').distinct().count() +def node_locked_response() -> Response: + return Response( + {'detail': 'این جزیره هنوز قفل است؛ اول جزیره‌ی قبلی را تمام کن.', 'code': 'node_locked'}, + status=status.HTTP_403_FORBIDDEN, + ) + + +def answers_missing_response(answered: int, required: int) -> Response: + return Response( + { + 'detail': (f'برای تمام کردن باید به همه‌ی سوال‌ها جواب بدهی ({answered} از {required}).' + .translate(_FA_DIGITS)), + 'code': 'answers_missing', + 'answered': answered, + 'required': required, + }, + status=status.HTTP_409_CONFLICT, + ) + + +def node_is_unlocked(user_id, node) -> bool: + """ + آیا این جزیره برای این کاربر روی نقشه قفل نیست؟ همان قاعده‌ی `HomeView` است + (`_node_status` روی همان `_grade_tree`): اولین جزیره‌ی هر فصل باز است و بقیه + وقتی باز می‌شوند که همه‌ی جزیره‌های قبلیِ همان فصل تمام شده باشند؛ جزیره‌ی + تمام‌شده تمام می‌ماند. تست `NodeIsUnlockedMatchesHomeTests` این برابری را نگه می‌دارد. + + اگر جزیره در درختِ کش‌شده نبود (مثلاً تازه ساخته شده و کش کهنه است) «قفل» حساب می‌شود + و در لاگ WARNING می‌ماند؛ همان‌طور که نقشه هم آن را نشان نمی‌دهد. + """ + tree = _grade_tree(node.unit.grade_id) or [] + unit = next((u for u in tree if u['id'] == str(node.unit_id)), None) + if unit is not None: + lesson_ids = [lid for n in unit['nodes'] for lid in n['lesson_ids']] + node_ids = [int(n['id']) for n in unit['nodes']] + completed_ids = set(UserLessonCompletion.objects.filter( + user_id=user_id, lesson_id__in=lesson_ids).values_list('lesson_id', flat=True)) + finished = quiz_completed_node_ids(user_id, node_ids) | set( + NodeProgress.objects.filter(user_id=user_id, node_id__in=node_ids, status='completed') + .values_list('node_id', flat=True)) + unlock_next = True + for n in unit['nodes']: + playable = n.get('playable_ids') or n['lesson_ids'] + node_status = _node_status(playable, completed_ids, unlock_next, + quiz_completed=int(n['id']) in finished) + if int(n['id']) == node.id: + return node_status != 'locked' + if node_status != 'completed': + unlock_next = False + logger.warning('node_is_unlocked: جزیره %s در درختِ پایه %s پیدا نشد؛ قفل حساب شد', + node.id, node.unit.grade_id) + return False + + def _completion_body(title, stars, xp): """ متنِ اعلانِ «تمام کردی». عددها فارسی، چون اپ متنِ اعلان را همان‌طور که @@ -373,6 +427,10 @@ class HomeView(APIView): class LessonIntroView(APIView): """GET /api/lessons/{id}/intro""" + # بی‌ورود هم باز است؛ بدونِ سقف، کلِ متنِ درس‌ها با یک حلقه قابلِ کپی بود. + throttle_scope = 'read' + throttle_classes = [ScopedRateThrottle] + @extend_schema(responses=LessonIntroResponseSerializer, tags=['Curriculum (content)']) def get(self, request, lesson_id): lesson = get_object_or_404(Lesson, pk=lesson_id) @@ -394,6 +452,10 @@ class LessonIntroView(APIView): class LessonQuestionsView(APIView): """GET /api/lessons/{id}/questions""" + # بی‌ورود هم باز است؛ بدونِ سقف، کلِ بانکِ سوال با یک حلقه قابلِ کپی بود. + throttle_scope = 'read' + throttle_classes = [ScopedRateThrottle] + @extend_schema(responses=LessonQuestionsResponseSerializer, tags=['Curriculum (content)']) def get(self, request, lesson_id): lesson = get_object_or_404(Lesson, pk=lesson_id) @@ -610,11 +672,19 @@ class LessonCompleteView(APIView): } return Response(LessonCompleteResponseSerializer(data).data) - blocked = blocked_without_hearts( - user_id, _answered(user_id, question__lesson=lesson), actual_question_count, - ) + # درسِ بی‌سوال چیزی برای جواب‌دادن ندارد و (اگر جزیره‌اش باز است) مثلِ قبل رد می‌شود، + # بی‌امتیاز؛ فقط قفل بودنِ جزیره‌اش بررسی می‌شود. + # جزیره‌ی قفل را با تمام‌کردنِ درس‌هایش هم نمی‌شود باز کرد (وضعیتِ «باز» از روی + # درس‌های تمام‌شده هم حساب می‌شود، پس بدونِ این قفل از همین راه دور زده می‌شد). + if not node_is_unlocked(user_id, lesson.node): + return node_locked_response() + + answered = _answered(user_id, question__lesson=lesson) + blocked = blocked_without_hearts(user_id, answered, actual_question_count) if blocked is not None: return blocked + if answered < actual_question_count: + return answers_missing_response(answered, actual_question_count) # atomic لازمه چون create پایین‌تر سیگنالِ post_save رو تریگر می‌کنه # (NodeProgress) و UserStats هم آپدیت می‌شه؛ بدون اون، fail شدنِ save @@ -710,6 +780,8 @@ class NodeProgressListView(APIView): """ permission_classes = [IsAuthenticated] + throttle_scope = 'read' + throttle_classes = [ScopedRateThrottle] @extend_schema( parameters=[ @@ -914,10 +986,7 @@ class NodeQuizCompleteView(APIView): # ⚠️ سقف «کلِ استخر» است (تا ۲۰)، نه آزمونِ چهارسوالی‌ای که واقعاً داده # شد؛ جواب‌های درستِ مسیرِ درس‌ها هم اینجا دوباره شمرده می‌شوند. قفل بودنِ # جزیره هم بررسی نمی‌شود: هر جزیره‌ای با correctCount=0 «تمام» می‌شود. - pool = min( - Question.objects.filter(lesson__node_id=node_id).count(), - NodeQuizView.MAX_SIZE, - ) + pool = min(Question.objects.filter(lesson__node_id=node_id).count(), NodeQuizView.MAX_SIZE) if total_questions > max(pool, 1): raise ValidationError({ 'totalQuestions': f'این مبحث حداکثر {pool} سوال دارد.', @@ -930,7 +999,12 @@ class NodeQuizCompleteView(APIView): # مثلِ پایانِ درس: فقط جواب‌های درستِ ثبت‌شده در همین مبحث شمرده می‌شوند. correct_count = min(correct_count, verified_correct_count(request.user.id, node_id=node_id)) - ratio = correct_count / total_questions + # تعدادِ سوالِ آزمون را کلاینت نمی‌تواند کمتر از آزمونِ استانداردِ سرور بگوید (با + # totalQuestions=1 و یک جوابِ درست ۳ ستاره می‌گرفت). بیشتر هم بگوید، باید همان‌قدر جواب بدهد. + # جزیره‌ی بی‌سوال چیزی برای جواب‌دادن ندارد: اگر باز است رد می‌شود (بی‌ستاره و بی‌امتیاز) + # تا مسیرِ بقیه را نبندد؛ قفل بودنش همچنان بررسی می‌شود. + total_questions = max(total_questions, min(NodeQuizView.DEFAULT_SIZE, pool)) if pool else 0 + ratio = correct_count / total_questions if total_questions else 0 stars = 3 if ratio >= 0.9 else 2 if ratio >= 0.6 else 1 if ratio > 0 else 0 user_id = request.user.id idempotency_key = request.headers.get('Idempotency-Key') or None @@ -957,12 +1031,22 @@ class NodeQuizCompleteView(APIView): if existing is not None: return respond(existing, UserStats.objects.filter(user_id=user_id).first()) - blocked = blocked_without_hearts( - user_id, _answered(user_id, question__lesson__node_id=node_id), total_questions, - ) + # قفل: جزیره‌ی قفل را نمی‌شود تمام کرد (تا ۲۰۲۶-۱۰-۰۷ می‌شد، با correctCount=0). + if not node_is_unlocked(user_id, node): + return node_locked_response() + + answered = _answered(user_id, question__lesson__node_id=node_id) + blocked = blocked_without_hearts(user_id, answered, total_questions) if blocked is not None: return blocked + # جوابِ ثبت‌شده: بی‌جواب‌دادن، حتی با قلبِ پر، جزیره را تمام نمی‌کند. نمره مهم نیست + # (تصمیمِ ۲۰۲۶-۰۹-۲۴: هر نمره‌ای)، فقط باید همه‌ی سوال‌ها جواب داده شده باشند. + # ⚠️ «جواب داده» یعنی سوالِ متمایزی از همین جزیره، نه لزوماً همان چهار سوالی که این بار + # نشان داده شد؛ کاربری که پیش‌تر جواب داده دوباره‌بازی را بی‌جوابِ تازه می‌بندد. + if answered < total_questions: + return answers_missing_response(answered, total_questions) + try: with transaction.atomic(): UserStats.objects.get_or_create(user_id=user_id) diff --git a/deploy.sh b/deploy.sh index a6d85ec..56bf580 100755 --- a/deploy.sh +++ b/deploy.sh @@ -188,6 +188,67 @@ if [ -d .deploy-backups ]; then fi printf ' دیتابیس و بکاپ‌ها فقط برای %s خواندنی‌اند.\n' "$(id -un)" +# ─── ۳.۷ بکاپِ دیتابیس پیش از migrate ────────────────────────────────────── +# migrate تنها مرحله‌ای است که داده را برنمی‌گشت‌پذیر عوض می‌کند، پس درست پیش از آن بکاپ +# می‌گیریم؛ تا اینجا بکاپ دستی بود و در یک انتشارِ بی‌بکاپ هیچ راهی به عقب نبود. +# +# با sqlite3.Connection.backup() نه cp: کپیِ ساده وسطِ یک نوشتن فایلِ نیمه‌کاره می‌دهد (sqlite3 +# به‌عنوانِ فرمان روی سرور نیست). اگر بکاپ نشد، migrate اجرا نمی‌شود: دیپلوی بی‌بکاپ را +# عمداً نمی‌پذیریم. فقط ۲ بکاپِ خودکارِ آخر (db-before-deploy-*: همین انتشار و یکی قبل‌تر) نگه داشته می‌شود تا دیسکِ +# مشترک پر نشود؛ بکاپ‌های دستی با نامِ دیگر دست نمی‌خورند. +# آزموده‌نشده: Postgres (بعد از مهاجرت باید pg_dump جایگزین شود؛ اینجا فقط هشدار می‌دهد)، +# و بکاپ از بیرونِ ماشین که هنوز نیست (بند ۱۷ در چک‌لیستِ لانچ). +step "بکاپِ دیتابیس" +python - <<'PYBACKUP' || fail "بکاپِ دیتابیس نشد؛ migrate اجرا نشد و هیچ تغییری روی داده اعمال نشد." +import datetime +import glob +import os +import shutil +import sqlite3 + +import django + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +django.setup() +from django.conf import settings + +db = settings.DATABASES['default'] +if db['ENGINE'] != 'django.db.backends.sqlite3': + print(f" ⚠ موتورِ دیتابیس {db['ENGINE']} است؛ بکاپِ خودکار فقط SQLite را پوشش می‌دهد. خودتان بکاپ بگیرید.") + raise SystemExit(0) + +src_path = str(db['NAME']) +if not os.path.exists(src_path): + print(' دیتابیس هنوز وجود ندارد (نصبِ تازه)؛ چیزی برای بکاپ نیست.') + raise SystemExit(0) + +os.makedirs('.deploy-backups', mode=0o700, exist_ok=True) +need = os.path.getsize(src_path) * 2 +free = shutil.disk_usage('.deploy-backups').free +if free < need: + raise SystemExit(f' فضای خالی ({free // 2**20} MB) کمتر از دو برابرِ دیتابیس ({need // 2**20} MB) است.') + +dst = '.deploy-backups/db-before-deploy-%s.sqlite3' % datetime.datetime.now().strftime('%Y%m%d-%H%M%S') +src, out = sqlite3.connect(src_path), sqlite3.connect(dst) +with out: + src.backup(out) +src.close() +out.close() +os.chmod(dst, 0o600) +check = sqlite3.connect(dst) +result = check.execute('PRAGMA integrity_check').fetchone()[0] +check.close() +if result != 'ok': + raise SystemExit(f' بکاپ ساخته شد ولی integrity_check «{result}» داد: {dst}') +print(f' {dst} ({os.path.getsize(dst) // 2**20} MB، integrity_check: ok)') + +old = sorted(glob.glob('.deploy-backups/db-before-deploy-*.sqlite3'))[:-2] +for path in old: + os.remove(path) +if old: + print(f' {len(old)} بکاپِ خودکارِ قدیمی‌تر پاک شد (۲ تای آخر می‌ماند).') +PYBACKUP + # ─── ۴. دیتابیس ──────────────────────────────────────────────────────────── step "اعمالِ مهاجرت‌های دیتابیس" python manage.py migrate --noinput || fail "migrate شکست خورد؛ سرویس ری‌استارت نشد." diff --git a/discount/views.py b/discount/views.py index 4bc3c54..56227f2 100644 --- a/discount/views.py +++ b/discount/views.py @@ -199,6 +199,10 @@ class RedeemOfferCodeView(APIView): class MyOffersView(APIView): """GET /api/discount/offers/mine — لیست آفرهایی که کاربر گرفته (خودکار یا با کد)""" + # سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای هم‌خانواده است. + throttle_scope = 'read' + throttle_classes = [ScopedRateThrottle] + permission_classes = [IsAuthenticated] @extend_schema(responses=UserOfferResponseSerializer(many=True), tags=['Discount (discount)']) diff --git a/docs/operations.md b/docs/operations.md index b8eb0fe..83376b6 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -60,8 +60,10 @@ curl -i https://mathapi.tadrisino2.ir/api/health ## بکاپ و بازیابی -`db.sqlite3` در گیت **نیست** و تنها نسخه‌ی داده‌ی کاربران است. `deploy.sh` -پیش از `migrate` بکاپ نمی‌گیرد؛ تا وقتی این خودکار نشده، دستی بگیرید: +`db.sqlite3` در گیت **نیست** و تنها نسخه‌ی داده‌ی کاربران است. `deploy.sh` پیش از `migrate` +خودش بکاپ می‌گیرد (`.deploy-backups/db-before-deploy-<زمان>.sqlite3`، با `integrity_check`، ۲ تای +آخر نگه داشته می‌شود) و اگر بکاپ نشد migrate را اجرا نمی‌کند. این بکاپ روی همان ماشین است؛ نسخه‌ی +بیرون از ماشین هنوز نیست. برای بکاپِ دستی (مثلاً پیش از تغییرِ `.env` یا کارِ ادمین): ```bash cd /home/administrator/mathapp-v2 diff --git a/docs/security-before-production.md b/docs/security-before-production.md index 5c891fc..be178dd 100644 --- a/docs/security-before-production.md +++ b/docs/security-before-production.md @@ -1,6 +1,6 @@ # پیش از پروداکشنِ واقعی: چیزهایی که عمداً باز مانده‌اند -**آخرین بازبینی با کد: ۲۰۲۶-۱۰-۰۵ (۱۳ مهر ۱۴۰۵)؛ با سرورِ زنده: ۲۰۲۶-۱۰-۰۴.** +**آخرین بازبینی با کد: ۲۰۲۶-۱۰-۰۷ (۱۵ مهر ۱۴۰۵)؛ با سرورِ زنده: ۲۰۲۶-۱۰-۰۴.** (تغییرهای ۱۰-۰۷ در کد است و تا دیپلوی روی سرور نیست.) این پروژه تا این تاریخ در حالتِ **دمو و ارائه‌ی کارآموزی** است، نه محصولِ نهایی. چند چیز عمداً باز گذاشته شده تا توسعه‌دهنده‌ها، فرانت‌کار و ارائه کار کنند. هر کدام برای یک سرورِ @@ -31,18 +31,16 @@ | ۹ | کشِ مشترک (Redis) نیست؛ محدودیت‌ها per-worker‌اند | متوسط | `.env` سرور | | ۱۱ | کدِ OTP بی‌هش در دیتابیس و در فهرستِ پنل | کم | کد | | ۱۲ | هزینه‌ی دستیارِ AI با ساختِ شماره‌ی تازه (هر شماره ۳ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد | -| ۱۴ | SQLite روی دیسکِ مشترکِ پُر، بدونِ بکاپِ خودکارِ بیرون از ماشین | کم (داده) | زیرساخت | +| ۱۴ | SQLite روی دیسکِ مشترکِ پُر؛ بکاپِ پیش از migrate خودکار است ولی بیرون از ماشین نیست | کم (داده) | زیرساخت | | ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور | | ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت | | ۲۰ | یونیتِ systemd `DJANGO_DEBUG=1` و کلیدِ ناامن دارد؛ فقط یک خطِ `.env` جلوی DEBUG را گرفته | بالا (نهفته) | ادمینِ سرور | | ۲۲ | سایتِ فرانت هیچ هدرِ امنیتی ندارد (CSP، frame-ancestors، HSTS) | کم | nginx/فرانت | | ۲۴ | سقفِ روزانه‌ی ۳۰۰ پیامکِ کلِ سامانه را یک ناشناس پر می‌کند و ورود با پیامک برای همه بسته می‌شود | بالا (دسترس‌پذیری، هزینه) | تصمیمِ طراحی | | ۲۵ | با دانستنِ شماره‌ی کسی، ورودِ او تا ۲۴ ساعت بسته می‌شود | متوسط | تصمیمِ طراحی | -| ۲۶ | پایانِ درس و جزیره به گفته‌ی کلاینت است (جزیره‌ی قفل، قلبِ صفر، گزارشِ والدین) | متوسط | کد، بعد از ارائه | | ۲۷ | کلیدِ جوابِ هر سوال با یک حسابِ دوم درمی‌آید؛ امتیازِ هر جوابِ درست دو بار داده می‌شود | کم | تصمیمِ محصولی | | ۲۸ | استفاده‌ی دوباره از توکنِ تمدیدِ دزدیده‌شده تشخیص داده نمی‌شود؛ «خروج از همه‌ی دستگاه‌ها» نیست | کم | کد، بعد از ارائه | | ۲۹ | پیشنهادِ ثبت‌نام با حذفِ حساب و ورودِ دوباره با همان شماره دوباره داده می‌شود | کم | کد | -| ۳۰ | چند مسیرِ خواندنی (از جمله بانکِ سوال، بی‌ورود) throttle ندارند | کم | کد، بعد از ارائه | | ۳۱ | لپ‌تاپِ توسعه با `mobile-preview` روی شبکه‌ی محلی باز است و در DEBUG کدِ `0000` را می‌پذیرد | کم | لپ‌تاپِ توسعه | --- @@ -170,12 +168,15 @@ ## ۱۴. دیتابیس و بکاپ - **چیست:** SQLite در `~/mathapp-v2/db.sqlite3`، روی دیسکی که بینِ چند سرویس مشترک است و - ~۸۳٪ پر است. `transaction_mode: IMMEDIATE` هم‌زمانی را درست ولی سریالی می‌کند. بکاپ دستی - است (`deploy.sh` پیش از `migrate` بکاپ نمی‌گیرد) و در همان پوشه می‌ماند. + ~۸۳٪ پر است. `transaction_mode: IMMEDIATE` هم‌زمانی را درست ولی سریالی می‌کند. `deploy.sh` + پیش از `migrate` بکاپ می‌گیرد (`.deploy-backups/db-before-deploy-*`، ۲ تای آخر؛ اگر بکاپ + نشد، migrate اجرا نمی‌شود)، ولی همه در همان پوشه و روی همان دیسک‌اند. روی لپ‌تاپ آزموده شد + (ساخت، `integrity_check`، حذفِ قدیمی‌ها)؛ روی خودِ سرور هنوز اجرا نشده. دیتابیسِ لپ‌تاپ ۱۲۶ مگابایت + است و دو بکاپ ~۲۵۰ مگابایت می‌گیرند؛ پیش از اولین دیپلو `df -h /` را ببینید. - **خطر:** از دست رفتنِ کلِ داده با یک خطای دیسک یا یک دستورِ اشتباه؛ پُر شدنِ دیسک سرویس‌های دیگر (Gitea، ایمیل) را هم می‌خواباند. -- **رفع:** مهاجرت به Postgres (روی همان ماشین نصب است)، بکاپِ خودکار پیش از هر `migrate`، و - نگه‌داریِ بکاپ بیرون از ماشین. +- **رفع:** مهاجرت به Postgres (روی همان ماشین نصب است؛ بعدش بکاپِ `deploy.sh` باید `pg_dump` شود، + الان فقط هشدار می‌دهد) و نگه‌داریِ یک نسخه بیرون از ماشین. ## ۱۵. دسترسیِ سرور @@ -235,17 +236,6 @@ - **رفع (تصمیمِ طراحی):** کد به درخواست‌کننده گره بخورد (درخواستِ کد یک شناسه‌ی موقت برگرداند که ورود لازمش دارد) و حدسِ غلط برای هر درخواست‌کننده جدا شمرده شود. -## ۲۶. پایانِ درس و جزیره به گفته‌ی کلاینت - -- **چیست:** `POST /api/nodes/{id}/quiz/complete` و `POST /api/lessons/{id}/complete` تعدادِ سوال و - جوابِ درست را از کلاینت می‌گیرند. بازتولید شد: با قلبِ صفر و `totalQuestions=1` جزیره تمام - می‌شود؛ جزیره‌ی قفل با `correctCount=0` تمام می‌شود؛ درس و جزیره بی‌هیچ جوابی تمام می‌شوند، پس - `GET /api/user/weekly-report` پیشرفتِ ۱۰۰٪ نشان می‌دهد بی‌آن‌که سوالی جواب داده شده باشد. -- **چرا باز مانده:** رفتارِ آزمون در فرانت به همین شکل است؛ عوض کردنش پیش از ارائه جریانِ نقشه را - به خطر می‌اندازد. -- **رفع:** تعدادِ سوالِ آزمون را سرور تعیین کند (همان `/quiz` با seed)، همان تعداد جوابِ ثبت‌شده - لازم باشد، و جزیره‌ی قبلی تمام شده باشد. - ## ۲۷. کلیدِ جواب با حسابِ دوم، امتیازِ دوباره - **چیست:** جوابِ غلط `correctOptionId` را برمی‌گرداند و برای همه یکی است؛ پایانِ درس و پایانِ @@ -268,12 +258,14 @@ ثبت‌نام فعال باشد. - **رفع:** گره زدنِ پیشنهادِ ثبت‌نام به هشِ شماره. -## ۳۰. مسیرهای خواندنیِ بی‌throttle +## ۳۰. مسیرهای بی‌throttle -- **چیست:** `lessons/{id}/questions` و `lessons/{id}/intro` (بی‌ورود)، `progress/nodes`، - `user/hearts`، `notifications/{id}/read`، `support/tickets/{id}` و چند مسیرِ دیگر `throttle_scope` - ندارند؛ کلِ بانکِ سوال بی‌ورود قابلِ کپی است. (`/api/schema/` سقفِ `read` دارد.) -- **رفع:** برای هر کدام `throttle_scope` و ردیفِ نرخ در `config/settings.py` اضافه شود. +- **وضع:** با پیمایشِ resolverِ جنگو هر مسیرِ `/api/` که `throttle_scope` نداشت فهرست شد. پانزده مسیر + سقف گرفتند (جدولِ حفاظ‌ها: `content/tests_read_throttles.py`). دو مسیر عمداً ماندند چون با + `X-Internal-Key` محافظت می‌شوند و عمومی نیستند: `rag/chunks` و `payment/orders` (POST). + `/api/docs/` و `/api/redoc/` هم سقفِ جدا ندارند (صفحه‌ی ایستا؛ بند ۵). +- **نسنجیده:** رفتارِ سقفِ `read` (۱۲۰ در دقیقه) روی ترافیکِ واقعی؛ پشتِ IPِ مشترکِ مدرسه یا + CGNAT ممکن است کاربرِ عادی هم به آن برسد (به بندِ «نرخ‌های throttle» در چک‌لیستِ لانچ نگاه کنید). ## ۳۱. لپ‌تاپِ توسعه روی شبکه‌ی محلی @@ -299,9 +291,13 @@ | جدولِ رتبه‌بندی فقط برای کاربرِ واردشده | `progress/tests.py` (`LeaderboardPeriodValidationTests`) | | شماره‌ی موبایل نرمال می‌شود؛ cooldown، سقفِ روزانه‌ی هر شماره و سقفِ کلِ پیامک در دیتابیس شمرده می‌شوند | `accounts/tests_otp_limits.py` | | OTPِ ۶ رقمی با ۵ حدس برای هر کد؛ خروج از حساب توکن‌های قبلی را باطل می‌کند | `accounts/tests_audit_fixes.py` | -| قلبِ صفر جوابِ سوال را می‌بندد؛ چکش داخلِ تراکنش است (پایانِ جزیره را فقط کامل نمی‌بندد، مورد ۲۶) | `content/tests_hearts_gate.py` | +| قلبِ صفر جوابِ سوال را می‌بندد؛ چکش داخلِ تراکنش است (بی‌جواب‌ماندن و جزیره‌ی قفل جداگانه در ردیفِ بعدی بسته شده‌اند) | `content/tests_hearts_gate.py` | | `select_for_update` مؤثر با `transaction_mode: IMMEDIATE`؛ مقایسه‌ی زمان‌ثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` | +| `deploy.sh` پیش از `migrate` بکاپِ `db-before-deploy-*` می‌گیرد، `integrity_check` می‌کند و اگر نشد migrate را اجرا نمی‌کند | فقط دستی آزموده شد: مرحله‌ی «بکاپِ دیتابیس» روی دیتابیسِ لپ‌تاپ، ۷ بار پشتِ هم | | دیتابیس و بکاپ‌ها فقط برای صاحبِ سرویس خواندنی‌اند (مرحله‌ی «مجوزِ فایل‌های داده» در `deploy.sh`) | با `sudo -u nobody head -c 15 ~/mathapp-v2/db.sqlite3` روی سرور باید خطای دسترسی بدهد | +| پانزده مسیرِ خواندنی و نوشتنیِ کم‌خطر سقفِ نرخ دارند (بانکِ سوال و intro بی‌ورود، `progress/nodes`، `user/hearts`، اعلان، تیکت، پلن‌ها، وضعیتِ اشتراک، سفارش، پیشنهادها، affiliate، تاریخچه و فیدبکِ دستیار، خروج). با نرخِ ۳ در دقیقه درخواستِ چهارم ۴۲۹ می‌گیرد | `content/tests_read_throttles.py` | +| ساختنِ دستیِ `OTPCode` و `Session` در پنل فقط برای ابرکاربر (کارمندِ عادی ۴۰۳) | `accounts/tests_security_review.py` (`StaffCannotMintLoginsTests`) | +| پایانِ درس و پایانِ آزمونِ جزیره جوابِ ثبت‌شده به همه‌ی سوال‌ها می‌خواهند (۴۰۹ `answers_missing`)، روی جزیره‌ی قفل ۴۰۳ `node_locked` می‌دهند (همان قاعده‌ی نقشه)، جزیره یا درسِ بی‌سوال چیزی برای جواب‌دادن ندارد و اگر باز است بی‌امتیاز رد می‌شود (قفل بودنش هنوز چک می‌شود)، و تعدادِ سوالِ آزمون را کلاینت نمی‌تواند کمتر از آزمونِ استانداردِ ۴ تایی بگوید (ستاره‌ی دروغین) | `content/tests_finish_requires_answers.py`، `content/tests_node_completion.py` | | کتابخانه‌ها بدونِ آسیب‌پذیریِ شناخته‌شده (`urllib3>=2.8.0`، `python-dotenv 1.2.2`) | پایگاهِ OSV روی `pip freeze` | | actionهای لغو/بازپرداختِ سفارش، علامتِ «بررسی‌شده» و لینکِ بازار اجازه‌ی تغییر می‌خواهند؛ کارمندِ فقط‌خواندنی (Supervisor) اجرایشان نمی‌کند | `payment/tests_security_review.py`، `content/tests_security_review.py` | | ورودِ فایلِ سوال/برنامه‌ی درسی اجازه‌ی افزودن (و برای جایگزینی، حذف) می‌خواهد؛ مرورگرِ محتوا اجازه‌ی دیدنِ سوال | `content/tests_security_review.py` | diff --git a/notifications/views.py b/notifications/views.py index d1bd913..ab03f94 100644 --- a/notifications/views.py +++ b/notifications/views.py @@ -35,6 +35,9 @@ class NotificationMarkReadView(APIView): """PATCH /api/notifications/{id}/read — علامت‌گذاری یک اعلان به‌عنوان خوانده‌شده""" permission_classes = [IsAuthenticated] + # همان سقفِ read؛ «همه را خوانده کن» برای همین جدا ساخته شد تا ۷۵ اعلان ۷۵ درخواست نشود. + throttle_scope = 'read' + throttle_classes = [ScopedRateThrottle] # request=None لازم است: بدونِ آن spectacular نمی‌تواند بدنه‌ی PATCH را حدس # بزند و **کلِ ویو را از اسکیما کنار می‌گذارد** — یعنی این مسیر در قراردادی diff --git a/payment/views.py b/payment/views.py index a9865bf..4765c3b 100644 --- a/payment/views.py +++ b/payment/views.py @@ -391,6 +391,10 @@ class BazaarVerifyPurchaseView(APIView): class OrderDetailView(APIView): """GET /api/payment/orders/{id} — وضعیت سفارش""" + # سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای هم‌خانواده است. + throttle_scope = 'read' + throttle_classes = [ScopedRateThrottle] + permission_classes = [IsAuthenticated] @extend_schema(responses=OrderDetailResponseSerializer, tags=['Payment (payment)']) diff --git a/progress/tests_live_gamification.py b/progress/tests_live_gamification.py index d632cb1..79e0479 100644 --- a/progress/tests_live_gamification.py +++ b/progress/tests_live_gamification.py @@ -78,10 +78,11 @@ class ActivityFeedsStreakAndLeaderboardTests(TestCase): self.assertEqual(len(calendar['activeDays']), 1) def test_a_finished_island_shows_up_on_the_leaderboard(self): - for question in Question.objects.filter(lesson__node=self.node).order_by('id')[:3]: + # آزمونِ چهارسوالی: هر چهار سوال جواب داده می‌شود (سه تا درست، یکی غلط)؛ پایان بی‌جواب پذیرفته نمی‌شود. + for index, question in enumerate(Question.objects.filter(lesson__node=self.node).order_by('id')[:4]): self.client.post( f'/api/questions/{question.pk}/answer', - {'selectedOptionId': question.choices.get(is_correct=True).pk}, + {'selectedOptionId': question.choices.get(is_correct=index < 3).pk}, content_type='application/json', **self.auth, ) self.client.post( diff --git a/progress/views.py b/progress/views.py index a2cebd7..1230ea3 100644 --- a/progress/views.py +++ b/progress/views.py @@ -170,6 +170,8 @@ class UserHeartsView(APIView): """GET /api/user/hearts — وضعیت فعلی جان‌ها (هارت)""" permission_classes = [IsAuthenticated] + throttle_scope = 'read' + throttle_classes = [ScopedRateThrottle] @extend_schema(responses=UserHeartsResponseSerializer, tags=['Progress & Gamification (progress)']) def get(self, request): diff --git a/subscription/views.py b/subscription/views.py index b3329f8..e336519 100644 --- a/subscription/views.py +++ b/subscription/views.py @@ -33,6 +33,10 @@ PLANS_CACHE_TTL = 3600 class PlansListView(APIView): """GET /api/subscription/plans — list available plans.""" + # سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای هم‌خانواده است. + throttle_scope = 'read' + throttle_classes = [ScopedRateThrottle] + @extend_schema(responses=PlansListResponseSerializer, tags=['Subscription (subscription)']) def get(self, request): def build(): @@ -215,6 +219,10 @@ class PurchaseView(APIView): class SubscriptionStatusView(APIView): """GET /api/subscription/status — current subscription status.""" + # سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای هم‌خانواده است. + throttle_scope = 'read' + throttle_classes = [ScopedRateThrottle] + permission_classes = [IsAuthenticated] @extend_schema(responses=SubscriptionStatusResponseSerializer, tags=['Subscription (subscription)']) diff --git a/support/views.py b/support/views.py index b13ca40..500be03 100644 --- a/support/views.py +++ b/support/views.py @@ -128,6 +128,8 @@ class TicketDetailView(APIView): """ permission_classes = [IsAuthenticated] + throttle_scope = 'read' + throttle_classes = [ScopedRateThrottle] @extend_schema(responses=TicketDetailResponseSerializer, tags=['Support (support)']) def get(self, request, ticket_id):