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

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)