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