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 <noreply@anthropic.com>
This commit is contained in:
15
CLAUDE.md
15
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`.
|
||||
|
||||
بخشهای تاریخیِ زیر بهترتیبِ زمان نگه داشته شدهاند چون درسهایشان هنوز معتبر است؛
|
||||
**اعدادِ محتواییشان کهنهاند و ملاک همین بخش است.**
|
||||
|
||||
@@ -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` برای
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -374,6 +374,10 @@ class LogoutView(APIView):
|
||||
کاربر فقط در همین worker پاک میشود؛ پیشفرض ۰ است و آن حالت تست نشده.
|
||||
"""
|
||||
|
||||
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `user_profile_write` همان نرخِ مسیرهای همخانواده است.
|
||||
throttle_scope = 'user_profile_write'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
|
||||
@@ -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)'])
|
||||
|
||||
@@ -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(
|
||||
|
||||
27
content/testing.py
Normal file
27
content/testing.py
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
192
content/tests_finish_requires_answers.py
Normal file
192
content/tests_finish_requires_answers.py
Normal file
@@ -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))
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
98
content/tests_read_throttles.py
Normal file
98
content/tests_read_throttles.py
Normal file
@@ -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')
|
||||
106
content/views.py
106
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)
|
||||
|
||||
61
deploy.sh
61
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 شکست خورد؛ سرویس ریاستارت نشد."
|
||||
|
||||
@@ -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)'])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 را حدس
|
||||
# بزند و **کلِ ویو را از اسکیما کنار میگذارد** — یعنی این مسیر در قراردادی
|
||||
|
||||
@@ -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)'])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)'])
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user