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:
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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('_', ''))
|
||||
|
||||
|
||||
96
content/tests_island_progress.py
Normal file
96
content/tests_island_progress.py
Normal 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')
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user