tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed

Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown,
with rag/services/mathtext.py converting whatever still slips through; full
step-by-step solutions with a check before the final answer; three tones by
grade; the last 6 messages of the conversation go to the model and follow-ups
are no longer served from another user's cache; book solutions the checker
disputes are kept out of the context; a questionId gives the tutor the
practice question and the student's wrong answer. /api/health reports when
the AI credit runs out. rag_model_benchmark compares chat models on verified
book problems (run on the server; credit ran out mid-run).

Map: only lessons that have questions count toward finishing an island,
lessonIds[0] / nextLessonId is always the next lesson to play, and a finished
island stays finished. The Seyar characters (sigma/delta/pi) are seeded.

Audit fixes, each reproduced before fixing:
- the plan is stored on the order, so paying a monthly order after starting
  a yearly checkout no longer grants a year
- opening checkout no longer demotes an active subscription; renewals extend
  from the current expiry; expired subscriptions are not reported active
- one discount code per order and only before the gateway; a 100% code
  completes the purchase; lost Zarinpal confirmations are re-verified
- the unused /api/payment/webhook "mark paid" endpoint is removed
- XP counts only answers recorded on the server
- 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout
- a guest who signs up keeps their account and progress (test phones excluded)
- affiliate commission on the first real payment; guests excluded
- parents with several children no longer get a 500
- SQLite runs transactions IMMEDIATE, since it ignores select_for_update
- admin login is rate-limited; display names drop phone numbers and links;
  userQuestion is capped at 2000 characters
- server-side heart enforcement exists behind HEARTS_ENFORCED (off until the
  frontend handles 403 no_hearts)

Not done on purpose: rotating the server's secret key and moving to Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 06:24:13 +03:30
parent fbdb9495ee
commit 42783f9ba4
56 changed files with 2792 additions and 326 deletions

View File

@@ -1,7 +1,7 @@
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from .models import Grade, Lesson, Node, Unit, UserAnswer, UserLessonCompletion
from .models import Grade, Lesson, Node, Question, Unit, UserAnswer, UserLessonCompletion
from .services import touch_node_progress_on_answer, update_node_progress_on_lesson_completion
@@ -29,6 +29,10 @@ def _on_lesson_completion_created(sender, instance, created, **kwargs):
@receiver(post_delete, sender=Node, dispatch_uid='content_bump_curriculum_node_delete')
@receiver(post_save, sender=Lesson, dispatch_uid='content_bump_curriculum_lesson_save')
@receiver(post_delete, sender=Lesson, dispatch_uid='content_bump_curriculum_lesson_delete')
# سوال هم: نقشه فقط درس‌های سوال‌دار را برای تمام شدنِ جزیره می‌شمارد، پس
# اولین سوالِ یک درس (از ادمین یا import) باید نقشه‌ی کش‌شده را باطل کند.
@receiver(post_save, sender=Question, dispatch_uid='content_bump_curriculum_question_save')
@receiver(post_delete, sender=Question, dispatch_uid='content_bump_curriculum_question_delete')
def _bump_curriculum_cache_version(sender, **kwargs):
from .views import bump_curriculum_version
bump_curriculum_version()

View File

@@ -32,7 +32,20 @@ class NodeStatusSerializer(serializers.Serializer):
status = serializers.ChoiceField(choices=['locked', 'unlocked', 'completed'])
# شناسه‌ی درس‌های همین گره تا کلاینت بدونه با کلیک روی گره کدوم درس رو
# باز کنه (endpointهای lessons/{id}/... مستقیم با همین id کار می‌کنن).
lessonIds = serializers.ListField(child=serializers.IntegerField())
# ⚠️ عضوِ اول همیشه nextLessonId است؛ بقیه به ترتیبِ کتاب. فرانتِ فعلی با
# کلیک روی جزیره lessonIds[0] را باز می‌کند، پس با همین ترتیب هر بار درسِ
# بعدی باز می‌شود — پیش‌تر همیشه درسِ اول باز می‌شد و جزیره هرگز تمام نمی‌شد.
lessonIds = serializers.ListField(
child=serializers.IntegerField(),
help_text='عضوِ اول = nextLessonId؛ بقیه به ترتیبِ کتاب.',
)
nextLessonId = serializers.IntegerField(
allow_null=True,
help_text=(
'درسی که با کلیک روی این جزیره باید باز شود: اولین درسِ تمام‌نشده‌ای '
'که سوال دارد؛ اگر همه تمام شده‌اند، اولین درس (برای مرور).'
),
)
class UnitWithNodesSerializer(serializers.Serializer):

View File

@@ -6,6 +6,7 @@ content/receivers.py صدا زده می‌شن (نه مستقیم از views.py)
دقیقاً همون الگوی signal-based که برای discount/subscription استفاده شده.
"""
from django.db import transaction
from django.db.models import Count
from django.utils import timezone
from statuslog.signals import status_changed
@@ -53,6 +54,39 @@ def touch_node_progress_on_answer(user_id, question):
return progress
def playable_lesson_ids(lessons_with_counts) -> list[int]:
"""
[(lesson_id, تعدادِ سوال), ...] → idهای درس‌های سوال‌دار، به همان ترتیب.
۵۸۵ درس از ۲٬۶۲۴ (۲۰۲۶-۰۹-۲۴) هنوز سوال ندارند و اپ برایشان فقط «به‌زودی»
نشان می‌دهد، پس هیچ‌وقت تمام نمی‌شوند. اگر شرطِ تمام شدنِ جزیره «همه‌ی
درس‌ها» باشد، ۱۸۱ جزیره از ۲۶۹ هرگز تمام نمی‌شد. جزیره‌ای که هیچ درسِ
سوال‌داری ندارد، همه‌ی درس‌هایش را نگه می‌دارد (رفتارِ قبلی).
"""
pairs = list(lessons_with_counts)
playable = [lid for lid, count in pairs if count]
return playable or [lid for lid, _count in pairs]
def node_playable_lesson_ids(node) -> list[int]:
return playable_lesson_ids(
node.lessons.annotate(question_total=Count('questions'))
.values_list('id', 'question_total')
)
def verified_correct_count(user_id, *, lesson_id=None, node_id=None) -> int:
"""تعدادِ سوال‌های متمایزی از این درس/مبحث که کاربر درست جواب داده و ثبت شده."""
from .models import UserAnswer
qs = UserAnswer.objects.filter(user_id=user_id, correct=True)
if lesson_id is not None:
qs = qs.filter(question__lesson_id=lesson_id)
if node_id is not None:
qs = qs.filter(question__lesson__node_id=node_id)
return qs.values('question_id').distinct().count()
def update_node_progress_on_lesson_completion(user_id, lesson):
"""
وقتی یک Lesson تکمیل می‌شه، تعداد Lessonهای تکمیل‌شده‌ی Node رو
@@ -65,7 +99,7 @@ def update_node_progress_on_lesson_completion(user_id, lesson):
old_status = progress.status
now = timezone.now()
lesson_ids = list(node.lessons.values_list('id', flat=True))
lesson_ids = node_playable_lesson_ids(node)
completed_count = UserLessonCompletion.objects.filter(
user_id=user_id, lesson_id__in=lesson_ids
).values('lesson_id').distinct().count()
@@ -129,9 +163,10 @@ def grade_progress_percent(user_id, grade_id):
"""
if not grade_id:
return 0
lesson_ids = set(
Lesson.objects.filter(node__unit__grade_id=grade_id).values_list('id', flat=True)
)
lesson_ids = set(playable_lesson_ids(
Lesson.objects.filter(node__unit__grade_id=grade_id)
.annotate(question_total=Count('questions')).values_list('id', 'question_total')
))
if not lesson_ids:
return 0
done = set(
@@ -142,6 +177,7 @@ def grade_progress_percent(user_id, grade_id):
user_id=user_id, node__unit__grade_id=grade_id,
).values_list('node_id', flat=True)
done |= set(Lesson.objects.filter(node_id__in=quiz_nodes).values_list('id', flat=True))
done &= lesson_ids
return round(min(len(done) / len(lesson_ids), 1.0) * 100, 2)

View File

@@ -28,11 +28,19 @@ class LessonCompleteImpossibleCountTests(TestCase):
node = Node.objects.create(unit=unit, title='گره ۱', order=1)
self.lesson = Lesson.objects.create(node=node, title='درس ۱', intro_text='...')
for i in range(3):
Question.objects.create(
question = Question.objects.create(
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
)
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)]:
self.client.post(
f'/api/questions/{question.id}/answer',
{'selectedOptionId': str(question.choices.get(is_correct=True).id)},
format='json', HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
return self.client.post(
f'/api/lessons/{self.lesson.id}/complete',
{'correctCount': correct_count, 'totalQuestions': total_questions},
@@ -79,11 +87,19 @@ class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase):
node = Node.objects.create(unit=unit, title='گره ۲', order=1)
self.lesson = Lesson.objects.create(node=node, title='درس ۲', intro_text='...')
for i in range(3):
Question.objects.create(
question = Question.objects.create(
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
)
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)]:
self.client.post(
f'/api/questions/{question.id}/answer',
{'selectedOptionId': str(question.choices.get(is_correct=True).id)},
format='json', HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
return self.client.post(
f'/api/lessons/{self.lesson.id}/complete',
{'correctCount': correct_count, 'totalQuestions': total_questions},
@@ -595,7 +611,8 @@ class HomeViewScalabilityTests(TestCase):
self.assertEqual(self._home(g.id).status_code, 200)
sql = ' '.join(e['sql'] for e in q).lower()
self.assertNotIn('content_unit', sql)
self.assertNotIn('content_node', sql)
# content_nodeprogress دیتای کاربر است (جزیره‌های تمام‌شده)، نه برنامه‌ی درسی.
self.assertNotRegex(sql, r'content_node(?!progress)')
# فقط کوئریِ per-user باید بمونه
self.assertIn('userlessoncompletion', sql.replace('_', ''))

View File

@@ -0,0 +1,96 @@
"""
جزیره‌ها روی نقشه باید با بازی کردنِ درس‌ها واقعاً تمام شوند (۲۰۲۶-۰۹-۲۵).
دو چیز با هم جلوی پیشرفت را گرفته بود:
۱. جزیره فقط وقتی تمام می‌شد که *همه‌ی* درس‌هایش تمام شده باشند، ولی ۵۸۵ درس
هنوز سوال ندارند و اپ برایشان دکمه‌ی شروع ندارد — ۱۸۱ جزیره از ۲۶۹.
۲. فرانت با کلیک روی جزیره همیشه lessonIds[0] را باز می‌کرد، یعنی درسِ اول
را بارها و بارها.
"""
from django.core.cache import cache
from django.test import TestCase
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from .models import Choice, Grade, Lesson, Node, Question, Unit
class IslandProgressTests(TestCase):
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='g', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
self.first = Node.objects.create(unit=unit, title='جزیره‌ی اول', order=1)
self.second = Node.objects.create(unit=unit, title='جزیره‌ی دوم', order=2)
self.a = self._lesson(self.first, 'الف', with_question=True)
self.empty = self._lesson(self.first, 'بی‌سوال', with_question=False)
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.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
def tearDown(self):
cache.clear()
def _lesson(self, node, title, *, with_question):
lesson = Lesson.objects.create(node=node, title=title, intro_text='x', mascot_animation='x')
if with_question:
self._question(lesson)
return lesson
def _question(self, lesson):
question = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='؟')
Choice.objects.create(question=question, value='۱', is_correct=True)
Choice.objects.create(question=question, value='۲', is_correct=False)
def _nodes(self):
units = self.client.get('/api/home').json()['units']
return {n['title']: n for n in units[0]['nodes']}
def _complete(self, 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)
def test_tapping_the_island_opens_the_next_unfinished_lesson(self):
nodes = self._nodes()
self.assertEqual(nodes['جزیره‌ی اول']['nextLessonId'], self.a.id)
self.assertEqual(nodes['جزیره‌ی اول']['lessonIds'][0], self.a.id)
self._complete(self.a)
first = self._nodes()['جزیره‌ی اول']
# درسِ بی‌سوال رد می‌شود؛ lessonIds[0] همان است که فرانت باز می‌کند.
self.assertEqual(first['nextLessonId'], self.b.id)
self.assertEqual(first['lessonIds'][0], self.b.id)
self.assertCountEqual(first['lessonIds'], [self.a.id, self.empty.id, self.b.id])
def test_a_lesson_without_questions_does_not_hold_the_island(self):
self._complete(self.a)
self._complete(self.b)
nodes = self._nodes()
self.assertEqual(nodes['جزیره‌ی اول']['status'], 'completed')
self.assertEqual(nodes['جزیره‌ی دوم']['status'], 'unlocked')
# همه‌ی درس‌ها تمام شده‌اند: برای مرور، درسِ اول.
self.assertEqual(nodes['جزیره‌ی اول']['nextLessonId'], self.a.id)
def test_progress_counts_only_lessons_that_can_be_played(self):
self._complete(self.a)
percent = self.client.get('/api/home').json()['stats']['progressPercent']
self.assertAlmostEqual(percent, 33.33, places=2) # ۱ از ۳ درسِ سوال‌دار
def test_new_content_is_offered_but_progress_never_goes_backwards(self):
self._complete(self.a)
self._complete(self.b)
self.assertEqual(self._nodes()['جزیره‌ی اول']['status'], 'completed')
self._question(self.empty) # حالا این درس هم بازی‌شدنی است
nodes = self._nodes()
# نقشه‌ی کش‌شده باطل شد: درسِ تازه پیشنهاد می‌شود…
self.assertEqual(nodes['جزیره‌ی اول']['nextLessonId'], self.empty.id)
# …ولی جزیره‌ی تمام‌شده باز و جزیره‌ی بعدی قفل نمی‌شود.
self.assertEqual(nodes['جزیره‌ی اول']['status'], 'completed')
self.assertEqual(nodes['جزیره‌ی دوم']['status'], 'unlocked')

View File

@@ -40,7 +40,17 @@ class NodeQuizCompleteTests(TestCase):
Choice.objects.create(question=question, value='ب', is_correct=False)
return node
def _complete(self, node, correct=3, total=4, **headers):
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)
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)
return self.client.post(
f'/api/nodes/{node.pk}/quiz/complete',
{'correctCount': correct, 'totalQuestions': total},
@@ -90,6 +100,16 @@ class NodeQuizCompleteTests(TestCase):
self.assertEqual(again['coinsEarned'], 0)
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)
self._answer_correctly(self.second, 2)
body = self._complete(self.second, correct=3, total=3, answer=False).json()
self.assertEqual(body['xpEarned'], 20) # فقط دو جوابِ ثبت‌شده
def test_same_idempotency_key_does_not_double_count(self):
self._complete(self.first, correct=4, HTTP_IDEMPOTENCY_KEY='k1')
body = self._complete(self.first, correct=4, HTTP_IDEMPOTENCY_KEY='k1').json()

View File

@@ -1,8 +1,9 @@
import logging
from django.conf import settings
from django.core.cache import cache
from django.db import IntegrityError, transaction
from django.db.models import Count, F
from django.db.models import Count, F, Prefetch
from django.http import Http404
import random
@@ -21,7 +22,7 @@ from subscription.services import has_unlimited_hearts
from .services import (
grade_progress_percent, mark_node_completed_by_quiz, next_node_after,
quiz_completed_node_ids,
playable_lesson_ids, quiz_completed_node_ids, verified_correct_count,
)
from .models import (
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, UserAnswer,
@@ -120,7 +121,8 @@ _NODE_ICONS = ['Grid3x3', 'TrendingUp', 'LineChart', 'Sigma', 'Compass', 'Repeat
def _grade_tree(grade_id: int):
"""اسکلتِ گریدِ [{id,title,nodes:[{id,title,lesson_ids}]}] — بدونِ status/دیتای کاربر.
None یعنی گریدِ ناموجود؛ [] یعنی گریدِ موجودِ بدونِ واحد."""
key = f'content:tree:grade:{grade_id}:v{curriculum_version()}'
# tree2: هر node حالا `playable_ids` هم دارد؛ کلیدِ ساختارِ قبلی باطل شد.
key = f'content:tree2:grade:{grade_id}:v{curriculum_version()}'
cached = cache.get(key)
if cached is not None:
return None if cached is False else cached
@@ -131,7 +133,10 @@ def _grade_tree(grade_id: int):
units = (
Grade.objects.get(pk=grade_id)
.units.prefetch_related('nodes__lessons').all()
.units.prefetch_related(Prefetch(
'nodes__lessons',
queryset=Lesson.objects.annotate(question_total=Count('questions')),
)).all()
)
tree = []
position = 0 # شمارنده‌ی سراسریِ مبحث در این پایه، برای چرخشِ رنگ
@@ -144,6 +149,11 @@ def _grade_tree(grade_id: int):
'icon': node.icon or _NODE_ICONS[position % len(_NODE_ICONS)],
'color': node.color or _NODE_COLORS[position % len(_NODE_COLORS)],
'lesson_ids': [lsn.id for lsn in node.lessons.all()],
# فقط درس‌هایی که سوال دارند؛ درسِ بی‌سوال را نمی‌شود «تمام» کرد
# (اپ برایش دکمه‌ی شروع ندارد)، پس نباید جزیره را قفل نگه دارد.
'playable_ids': playable_lesson_ids(
(lsn.id, lsn.question_total) for lsn in node.lessons.all()
),
})
position += 1
tree.append({
@@ -248,6 +258,11 @@ class HomeView(APIView):
# همه‌ی lesson_idهای کامل‌شده‌ی این کاربر در همین گرید — یک کوئری،
# مستقل از تعدادِ node (رفعِ N+1).
all_lesson_ids = [lid for unit in tree for node in unit['nodes'] for lid in node['lesson_ids']]
# مخرجِ درصدِ پیشرفت: فقط درس‌های سوال‌دار (همان که جزیره را تمام می‌کند).
all_playable_ids = [
lid for unit in tree for node in unit['nodes']
for lid in (node.get('playable_ids') or node['lesson_ids'])
]
completed_ids = set()
if all_lesson_ids:
completed_ids = set(
@@ -256,8 +271,15 @@ class HomeView(APIView):
).values_list('lesson_id', flat=True)
)
quiz_done = quiz_completed_node_ids(
user_id, [int(node['id']) for unit in tree for node in unit['nodes']],
tree_node_ids = [int(node['id']) for unit in tree for node in unit['nodes']]
quiz_done = quiz_completed_node_ids(user_id, tree_node_ids)
# جزیره‌ای که یک بار تمام شده، تمام می‌ماند. بدونِ این، اضافه شدنِ اولین
# سوال به یک درسِ بی‌سوال (از ادمین) جزیره‌ی تمام‌شده را باز می‌کرد و
# جزیره‌ی بعدیِ دانش‌آموز را دوباره قفل می‌کرد — پیشرفت عقب می‌رفت.
finished = quiz_done | set(
NodeProgress.objects.filter(
user_id=user_id, node_id__in=tree_node_ids, status='completed',
).values_list('node_id', flat=True)
)
units_payload = []
@@ -265,9 +287,14 @@ class HomeView(APIView):
node_payload = []
unlock_next = True # اولین نودِ قفل‌نشده در هر واحد، باز می‌مونه
for node in unit['nodes']:
playable = node.get('playable_ids') or node['lesson_ids']
node_status = _node_status(
node['lesson_ids'], completed_ids, unlock_next,
quiz_completed=int(node['id']) in quiz_done,
playable, completed_ids, unlock_next,
quiz_completed=int(node['id']) in finished,
)
next_lesson_id = next(
(lid for lid in playable if lid not in completed_ids),
playable[0] if playable else None,
)
if node_status != 'completed':
unlock_next = False
@@ -277,7 +304,11 @@ class HomeView(APIView):
'icon': node.get('icon', ''),
'color': node.get('color', ''),
'status': node_status,
'lessonIds': node['lesson_ids'],
'lessonIds': (
[next_lesson_id] + [lid for lid in node['lesson_ids'] if lid != next_lesson_id]
if next_lesson_id is not None else node['lesson_ids']
),
'nextLessonId': next_lesson_id,
})
units_payload.append({
'id': unit['id'],
@@ -290,14 +321,15 @@ class HomeView(APIView):
# همین داده‌ها: درس‌های تمام‌شده + همه‌ی درس‌های مبحث‌هایی که آزمونشان
# تمام شده. UserStats.progress_percent فقط در لحظه‌ی تکمیل به‌روز
# می‌شود و به پایه‌ی دیگر (?gradeId=) ربطی ندارد.
done_lessons = set(completed_ids)
playable_set = set(all_playable_ids)
done_lessons = set(completed_ids) & playable_set
for unit in tree:
for node in unit['nodes']:
if int(node['id']) in quiz_done:
done_lessons.update(node['lesson_ids'])
done_lessons.update(playable_set.intersection(node['lesson_ids']))
progress_percent = (
round(min(len(done_lessons) / len(all_lesson_ids), 1.0) * 100, 2)
if all_lesson_ids else 0
round(min(len(done_lessons) / len(playable_set), 1.0) * 100, 2)
if playable_set else 0
)
stats = UserStats.objects.filter(user_id=user_id).first()
@@ -410,6 +442,24 @@ class QuestionAnswerView(APIView):
# اتمیک: ثبت پاسخ + کسر هارت باید یک واحد باشند. UserHearts زیر قفل
# خوانده/کم می‌شود تا lost update در پاسخ‌های هم‌زمان رخ ندهد. قید یکتاییِ
# سطح DB (uniq_user_answer_idempotency_key) جلوی ثبت دوباره با همان کلید را می‌گیرد.
# قلبِ صفر = نمی‌شود جواب داد (مثلِ دولینگو). بدونِ این، «قلبِ نامحدود»
# (تنها مزیتِ واقعیِ اشتراک) فقط در ظاهرِ اپ وجود داشت و با صدا زدنِ
# مستقیمِ API دور زده می‌شد. پشتِ HEARTS_ENFORCED است چون فرانتِ فعلی
# برای این ۴۰۳ هنوز صفحه‌ی «قلب‌ها تمام شد» ندارد.
if settings.HEARTS_ENFORCED and not has_unlimited_hearts(user_id):
hearts = UserHearts.objects.filter(user_id=user_id).first()
if hearts is not None:
hearts.apply_regen()
if hearts.current_hearts <= 0:
return Response(
{
'detail': 'قلب‌هایت تمام شده؛ کمی صبر کن تا دوباره پر شوند.',
'code': 'no_hearts',
'nextRefillAt': hearts.next_refill_at,
},
status=status.HTTP_403_FORBIDDEN,
)
try:
with transaction.atomic():
UserAnswer.objects.create(
@@ -501,6 +551,11 @@ class LessonCompleteView(APIView):
)
})
# امتیاز فقط برای جواب‌هایی که واقعاً ثبت شده‌اند. پیش‌تر عددِ کلاینت
# (فقط با سقفِ تعدادِ سوال‌ها) پذیرفته می‌شد، پس صدا زدنِ مستقیمِ همین
# endpoint برای هر درس، بی‌جواب دادن، امتیاز و جدولِ رتبه‌بندی را پر می‌کرد.
correct_count = min(correct_count, verified_correct_count(request.user.id, lesson_id=lesson.id))
# ratio همیشه بر اساس actual_question_count محاسبه می‌شود، نه totalQuestions ارسالی کلاینت.
ratio = correct_count / (actual_question_count or 1)
stars = 3 if ratio >= 0.9 else 2 if ratio >= 0.6 else 1 if ratio > 0 else 0
@@ -844,6 +899,9 @@ class NodeQuizCompleteView(APIView):
'correctCount': 'correctCount نمی‌تواند از totalQuestions بیشتر باشد.',
})
# مثلِ پایانِ درس: فقط جواب‌های درستِ ثبت‌شده در همین مبحث شمرده می‌شوند.
correct_count = min(correct_count, verified_correct_count(request.user.id, node_id=node_id))
ratio = correct_count / total_questions
stars = 3 if ratio >= 0.9 else 2 if ratio >= 0.6 else 1 if ratio > 0 else 0
user_id = request.user.id