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:
2026-10-07 13:30:24 +03:30
parent 7e3854098f
commit 7ecb337263
26 changed files with 688 additions and 69 deletions

View File

@@ -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`.
بخش‌های تاریخیِ زیر به‌ترتیبِ زمان نگه داشته شده‌اند چون درس‌هایشان هنوز معتبر است؛
**اعدادِ محتوایی‌شان کهنه‌اند و ملاک همین بخش است.**

View File

@@ -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` برای

View File

@@ -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

View File

@@ -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)

View File

@@ -374,6 +374,10 @@ class LogoutView(APIView):
کاربر فقط در همین worker پاک می‌شود؛ پیش‌فرض ۰ است و آن حالت تست نشده.
"""
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `user_profile_write` همان نرخِ مسیرهای هم‌خانواده است.
throttle_scope = 'user_profile_write'
throttle_classes = [ScopedRateThrottle]
permission_classes = [IsAuthenticated]
@extend_schema(

View File

@@ -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)'])

View File

@@ -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
View 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

View File

@@ -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)

View 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))

View File

@@ -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):

View File

@@ -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)

View File

@@ -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)

View File

@@ -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},

View 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')

View File

@@ -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)

View File

@@ -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 شکست خورد؛ سرویس ری‌استارت نشد."

View File

@@ -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)'])

View File

@@ -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

View File

@@ -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` |

View File

@@ -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 را حدس
# بزند و **کلِ ویو را از اسکیما کنار می‌گذارد** — یعنی این مسیر در قراردادی

View File

@@ -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)'])

View File

@@ -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(

View File

@@ -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):

View File

@@ -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)'])

View File

@@ -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):