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:
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)
|
||||
|
||||
Reference in New Issue
Block a user