diff --git a/CLAUDE.md b/CLAUDE.md index b63fd79..afa3cdf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -237,6 +237,12 @@ quiz; `questionCount` is reported separately from `requested` so a thin node can like an error. 224 of 263 nodes hold a full four-question quiz, and every node in grades 8–12 does; the 39 that fall short are all in grades 3/6/7. +Finishing the quiz finishes the island: `POST /api/nodes/{id}/quiz/complete` marks the node +completed whatever the score (the owner's call on 2026-09-24: keep it simple) and returns +`nextNodeId`. Before it existed, a node only completed when every one of its 8–13 lessons +was completed one by one, so a student playing node quizzes was stuck after the first +island of every unit. Details in `docs/architecture.md`. + ## AI provider notes `OPENAI_BASE_URL` points at GapGPT (an OpenAI proxy reachable from Iran) and it exposes diff --git a/accounts/serializers.py b/accounts/serializers.py index e5c3f71..25a9bff 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -77,6 +77,7 @@ class RequestOTPRequestSerializer(serializers.Serializer): class GuestSessionResponseSerializer(serializers.Serializer): userId = serializers.CharField() token = serializers.CharField() + refreshToken = serializers.CharField() isNewUser = serializers.BooleanField() isGuest = serializers.BooleanField() diff --git a/accounts/tests_guest_refresh.py b/accounts/tests_guest_refresh.py new file mode 100644 index 0000000..5b5981e --- /dev/null +++ b/accounts/tests_guest_refresh.py @@ -0,0 +1,41 @@ +""" +مهمان هم refresh token می‌گیرد. + +قبلاً مهمان فقط توکنِ یک‌ساعته داشت؛ بعد از یک ساعت همه‌ی درخواست‌ها ۴۰۱ +می‌شدند و اپ که هنوز خودش را واردشده می‌دید، بی‌صدا خطا می‌خورد. +""" +from django.core.cache import cache +from django.test import TestCase + + +class GuestRefreshTokenTests(TestCase): + def setUp(self): + cache.clear() + + def _guest(self, device='device-abc'): + return self.client.post( + '/api/auth/session', {'type': 'guest', 'deviceId': device}, + content_type='application/json', + ).json() + + def test_guest_session_returns_a_refresh_token(self): + body = self._guest() + self.assertTrue(body['refreshToken']) + + def test_the_refresh_token_renews_the_same_guest(self): + body = self._guest() + + renewed = self.client.post( + '/api/auth/refresh', {'refreshToken': body['refreshToken']}, + content_type='application/json', + ) + + self.assertEqual(renewed.status_code, 200) + profile = self.client.get( + '/api/user/profile', HTTP_AUTHORIZATION=f"Bearer {renewed.json()['token']}", + ).json() + self.assertEqual(profile['userId'], body['userId']) + self.assertTrue(profile['isGuest']) + + def test_each_guest_session_gets_its_own_refresh_token(self): + self.assertNotEqual(self._guest()['refreshToken'], self._guest()['refreshToken']) diff --git a/accounts/views.py b/accounts/views.py index d9b67b0..6b3ed1d 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -89,9 +89,22 @@ class GuestSessionView(APIView): token = issue_access_token(user.id) + # مهمان هم refresh token می‌گیرد. قبلاً فقط توکنِ یک‌ساعته داشت؛ بعد از + # یک ساعت همه‌ی درخواست‌ها ۴۰۱ می‌شدند درحالی‌که اپ هنوز خودش را + # واردشده می‌دید. امنیتی کم نمی‌شود: خودِ deviceId از قبل برای گرفتنِ + # توکنِ تازه کافی بود. + refresh_token = secrets.token_hex(32) + Session.objects.create( + user=user, + refresh_token=refresh_token, + device_id=device_id, + expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), + ) + data = { 'userId': str(user.id), 'token': token, + 'refreshToken': refresh_token, 'isNewUser': is_new_user, 'isGuest': True, } diff --git a/ai_tutor/tests_grade_grounding.py b/ai_tutor/tests_grade_grounding.py new file mode 100644 index 0000000..3ddda3d --- /dev/null +++ b/ai_tutor/tests_grade_grounding.py @@ -0,0 +1,86 @@ +""" +«از پی بپرس» بدونِ lessonId هم به کتاب مستند می‌شود. + +آن صفحه از نوارِ پایینِ اپ باز می‌شود و درسی در کار نیست؛ قبلاً در این حالت +retrieval کلاً رد می‌شد و همه‌ی جواب‌ها عمومی بودند. حالا پایه‌ی خودِ کاربر +کتاب را معلوم می‌کند. +""" +from unittest import mock + +from django.core.cache import cache +from django.test import TestCase + +from accounts.authentication import issue_access_token +from accounts.models import User +from ai_tutor.models import Conversation +from content.models import Grade + + +class AskWithoutLessonUsesTheStudentsBookTests(TestCase): + def setUp(self): + cache.clear() + + def _ask(self, user, **extra): + return self.client.post( + '/api/ai/ask', {'userQuestion': 'توان چیست؟', **extra}, + content_type='application/json', + HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}', + ) + + def _patched(self): + retrieve = mock.patch( + 'rag.services.retrieval.retrieve_context', + return_value=([{'title': 'فصل', 'summary': 'متن'}], {'gradeTitle': 'نهم'}), + ) + generate = mock.patch( + 'rag.services.generation.generate_answer', return_value=('جواب', [], {}), + ) + configured = mock.patch('rag.services.config.rag_is_configured', return_value=True) + return retrieve, generate, configured + + def test_math_grade_picks_that_grades_book(self): + grade = Grade.objects.create(title='پایه نهم', icon='', color='') + user = User.objects.create(phone='09120000011', grade_id=grade.id) + retrieve, generate, configured = self._patched() + + with retrieve as retrieve_mock, generate, configured: + response = self._ask(user) + + self.assertEqual(response.status_code, 200) + kwargs = retrieve_mock.call_args.kwargs + self.assertEqual(kwargs['subject'], 'ریاضی') + self.assertEqual(kwargs['grade_title'], 'نهم') + self.assertEqual(response.json()['usedChunks'], [{'title': 'فصل', 'summary': 'متن'}]) + + def test_calculus_grades_use_the_calculus_book(self): + grade = Grade.objects.create(title='پایه دوازدهم', icon='', color='') + user = User.objects.create(phone='09120000012', grade_id=grade.id) + retrieve, generate, configured = self._patched() + + with retrieve as retrieve_mock, generate, configured: + self._ask(user) + + self.assertEqual(retrieve_mock.call_args.kwargs['subject'], 'حسابان') + + def test_the_follow_up_message_keeps_the_book(self): + grade = Grade.objects.create(title='پایه نهم', icon='', color='') + user = User.objects.create(phone='09120000013', grade_id=grade.id) + retrieve, generate, configured = self._patched() + + with retrieve as retrieve_mock, generate, configured: + first = self._ask(user).json() + self._ask(user, conversationId=first['conversationId']) + + self.assertEqual(retrieve_mock.call_args.kwargs['subject'], 'ریاضی') + conversation = Conversation.objects.get(pk=first['conversationId']) + self.assertEqual(conversation.subject, 'ریاضی') + + def test_a_user_without_a_grade_still_gets_an_answer(self): + user = User.objects.create(phone='09120000014') + retrieve, generate, configured = self._patched() + + with retrieve as retrieve_mock, generate, configured: + response = self._ask(user) + + self.assertEqual(response.status_code, 200) + self.assertEqual(retrieve_mock.call_args.kwargs['subject'], '') diff --git a/ai_tutor/views.py b/ai_tutor/views.py index 270f1ff..d62f9d0 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -287,6 +287,21 @@ class AskAIView(APIView): conversation.lesson_number = lesson_number conversation.save(update_fields=['subject', 'lesson_number']) + # نه درسی آمده نه subject — حالتِ عادیِ صفحه‌ی «از پی بپرس» که از نوارِ + # پایین باز می‌شود. پایه‌ی ذخیره‌شده‌ی خودِ کاربر کتاب را معلوم می‌کند؛ + # بدونِ این، جواب‌های آن صفحه هیچ‌وقت به کتاب مستند نمی‌شدند. + if not subject: + from rag.services.lesson_context import resolve_from_grade + + grade_id = grade_id or getattr(user, 'grade_id', None) + derived = resolve_from_grade(grade_id) + subject = derived.get('subject', '') + grade_title = grade_title or derived.get('gradeTitle', '') + if subject and not conversation.subject: + conversation.subject = subject + conversation.grade_id = conversation.grade_id or grade_id + conversation.save(update_fields=['subject', 'grade_id']) + # --- مسیرِ استریم (SSE) --- فقط وقتی stream=true و RAG پیکربندی‌شده. # قراردادِ JSONِ غیراستریم دست‌نخورده می‌ماند؛ Swagger که stream # نمی‌فرسته همون مسیرِ قبلی رو می‌گیره. diff --git a/config/settings.py b/config/settings.py index 497edb3..8d5135f 100644 --- a/config/settings.py +++ b/config/settings.py @@ -410,7 +410,10 @@ REST_FRAMEWORK = { 'token_refresh': '30/hour', 'question_answer': '60/min', 'question_report': '20/hour', - 'lesson_complete': '10/hour', + # هر درس اینجا یک نوع مسئله است با یکی-دو سوال، نه یک جلسه‌ی درسی؛ + # ۱۰ در ساعت را دانش‌آموزِ عادی می‌زد و پیشرفتش ۴۲۹ می‌گرفت. + 'lesson_complete': '120/hour', + 'node_complete': '60/hour', 'support_create': '30/hour', 'offer_redeem': '10/hour', 'discount_validate': '20/hour', diff --git a/content/migrations/0009_usernodecompletion.py b/content/migrations/0009_usernodecompletion.py new file mode 100644 index 0000000..6fa04e3 --- /dev/null +++ b/content/migrations/0009_usernodecompletion.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.17 on 2026-09-23 23:10 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('content', '0008_grade_id_equals_number'), + ] + + operations = [ + migrations.CreateModel( + name='UserNodeCompletion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(db_index=True)), + ('correct_count', models.PositiveIntegerField()), + ('total_questions', models.PositiveIntegerField()), + ('stars', models.PositiveSmallIntegerField()), + ('xp_earned', models.PositiveIntegerField(default=0)), + ('coins_earned', models.PositiveIntegerField(default=0)), + ('idempotency_key', models.CharField(blank=True, max_length=100, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('node', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='completions', to='content.node')), + ], + options={ + 'indexes': [models.Index(fields=['user_id', 'node'], name='content_use_user_id_f372a4_idx')], + 'constraints': [models.UniqueConstraint(condition=models.Q(('idempotency_key__isnull', False), models.Q(('idempotency_key', ''), _negated=True)), fields=('user_id', 'node', 'idempotency_key'), name='uniq_node_completion_idempotency_key')], + }, + ), + ] diff --git a/content/models.py b/content/models.py index 27c7780..5a54ea8 100644 --- a/content/models.py +++ b/content/models.py @@ -157,6 +157,43 @@ class UserLessonCompletion(models.Model): return f"completion-user{self.user_id}-lesson{self.lesson_id}" +class UserNodeCompletion(models.Model): + """ + پایانِ آزمونِ یک مبحث (جزیره‌ی روی نقشه) — POST /api/nodes/{id}/quiz/complete. + + چرا جدا از UserLessonCompletion: اپ در سطحِ مبحث بازی می‌کند (آزمونِ + چهارسوالیِ GET /nodes/{id}/quiz) ولی مبحث فقط وقتی «تمام» حساب می‌شد که + تک‌تکِ درس‌هایش (معمولاً ۸ تا ۱۳ تا) جدا تمام شده باشند. با آزمونِ مبحث + هیچ‌وقت به آن نمی‌رسید و جزیره‌ی بعدی قفل می‌ماند. قاعده عمداً ساده است: + آزمونِ جزیره که تمام شد، جزیره تمام است و بعدی باز می‌شود. نمره فقط + ستاره و امتیاز را تعیین می‌کند، نه باز شدنِ مسیر. + """ + user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User + node = models.ForeignKey(Node, on_delete=models.CASCADE, related_name='completions') + correct_count = models.PositiveIntegerField() + total_questions = models.PositiveIntegerField() + stars = models.PositiveSmallIntegerField() + xp_earned = models.PositiveIntegerField(default=0) + coins_earned = models.PositiveIntegerField(default=0) + # تکرارِ آزمون مجاز است (فقط بارِ اول جایزه دارد)؛ کلید فقط جلوی ردیفِ + # تکراریِ ناشی از دابل‌کلیک/رتریِ همان submit را می‌گیرد. + idempotency_key = models.CharField(max_length=100, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['user_id', 'node', 'idempotency_key'], + condition=models.Q(idempotency_key__isnull=False) & ~models.Q(idempotency_key=''), + name='uniq_node_completion_idempotency_key', + ), + ] + indexes = [models.Index(fields=['user_id', 'node'])] + + def __str__(self): + return f"node-completion-user{self.user_id}-node{self.node_id}" + + class NodeProgress(models.Model): """ وضعیتِ پیشرفتِ هر کاربر روی هر Node (نه Lesson و نه Unit). diff --git a/content/serializers.py b/content/serializers.py index 8dd302e..d8418ce 100644 --- a/content/serializers.py +++ b/content/serializers.py @@ -136,6 +136,23 @@ class NodeQuizResponseSerializer(serializers.Serializer): questions = QuizQuestionSerializer(many=True) +class NodeQuizCompleteRequestSerializer(serializers.Serializer): + # همان شکلِ lessons/{id}/complete تا فرانت یک الگو یاد بگیرد. سقفِ + # correctCount در view با تعدادِ واقعیِ سوال‌های مبحث چک می‌شود. + correctCount = serializers.IntegerField(min_value=0) + totalQuestions = serializers.IntegerField(min_value=1) + + +class NodeQuizCompleteResponseSerializer(serializers.Serializer): + nodeId = serializers.CharField() + stars = serializers.IntegerField() + xpEarned = serializers.IntegerField() + coinsEarned = serializers.IntegerField() + # جزیره‌ی بعدی که حالا باز است؛ null یعنی این آخرین جزیره‌ی پایه بود. + nextNodeId = serializers.CharField(allow_null=True) + stats = StatsSerializer() + + class LessonQuestionsResponseSerializer(serializers.Serializer): questions = QuestionSerializer(many=True) diff --git a/content/services.py b/content/services.py index 0857bf3..8b8c17c 100644 --- a/content/services.py +++ b/content/services.py @@ -10,7 +10,7 @@ from django.utils import timezone from statuslog.signals import status_changed -from .models import NodeProgress, UserLessonCompletion +from .models import Lesson, Node, NodeProgress, UserLessonCompletion, UserNodeCompletion def _get_or_create_progress(user_id, node): @@ -78,6 +78,11 @@ def update_node_progress_on_lesson_completion(user_id, lesson): if lesson_ids and completed_count >= len(lesson_ids): progress.status = 'completed' progress.completed_at = now + elif UserNodeCompletion.objects.filter(user_id=user_id, node=node).exists(): + # آزمونِ مبحث قبلاً تمام شده؛ درسِ تازه نباید مبحث را به + # in_progress برگرداند. + progress.status = 'completed' + progress.completed_at = progress.completed_at or now else: progress.status = 'in_progress' @@ -89,6 +94,73 @@ def update_node_progress_on_lesson_completion(user_id, lesson): return progress +def mark_node_completed_by_quiz(user_id, node): + """پایانِ آزمونِ مبحث → NodeProgress همان مبحث completed می‌شود.""" + with transaction.atomic(): + progress, _created = _get_or_create_progress(user_id, node) + old_status = progress.status + now = timezone.now() + progress.status = 'completed' + progress.started_at = progress.started_at or now + progress.completed_at = progress.completed_at or now + progress.last_activity_at = now + progress.save(update_fields=['status', 'started_at', 'completed_at', 'last_activity_at']) + _emit_if_changed(progress, old_status, event='node_quiz_completed', user_id=user_id) + return progress + + +def quiz_completed_node_ids(user_id, node_ids): + """از میانِ node_ids، آن‌هایی که کاربر آزمونشان را تمام کرده.""" + if not node_ids: + return set() + return set( + UserNodeCompletion.objects.filter(user_id=user_id, node_id__in=node_ids) + .values_list('node_id', flat=True) + ) + + +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) + ) + if not lesson_ids: + return 0 + done = set( + UserLessonCompletion.objects.filter(user_id=user_id, lesson_id__in=lesson_ids) + .values_list('lesson_id', flat=True) + ) + quiz_nodes = UserNodeCompletion.objects.filter( + 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)) + return round(min(len(done) / len(lesson_ids), 1.0) * 100, 2) + + +def next_node_after(node): + """مبحثِ بعدی روی نقشه: بعدی در همان فصل، وگرنه اولینِ فصلِ بعد، وگرنه None.""" + following = ( + Node.objects.filter(unit=node.unit, order__gt=node.order).order_by('order', 'id').first() + or Node.objects.filter(unit=node.unit, order=node.order, id__gt=node.id).order_by('id').first() + ) + if following: + return following + unit = node.unit + next_unit_node = ( + Node.objects.filter(unit__grade_id=unit.grade_id, unit__order__gt=unit.order) + .order_by('unit__order', 'unit_id', 'order', 'id').first() + ) + return next_unit_node + + def node_progress_summary(node): """برای سریالایزر: تعداد کل Lessonهای یک Node (لازم برای lessonsTotal).""" return node.lessons.count() diff --git a/content/tests_node_completion.py b/content/tests_node_completion.py new file mode 100644 index 0000000..801eee3 --- /dev/null +++ b/content/tests_node_completion.py @@ -0,0 +1,154 @@ +""" +پایانِ آزمونِ جزیره → جزیره‌ی بعدی باز می‌شود. + +شکافی که این تست‌ها می‌بندند: اپ با آزمونِ چهارسوالیِ هر جزیره بازی +می‌کند، ولی جزیره فقط وقتی «تمام» حساب می‌شد که تک‌تکِ درس‌هایش (۸ تا ۱۳ +درس) جدا تمام شده باشند؛ دانش‌آموز بعد از جزیره‌ی اولِ هر فصل گیر می‌کرد. +""" +from django.core.cache import cache +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, +) +from notifications.models import Notification +from progress.models import UserStats + + +class NodeQuizCompleteTests(TestCase): + def setUp(self): + cache.clear() + self.grade = Grade.objects.create(title='پایه نهم', icon='', color='') + self.unit = Unit.objects.create(grade=self.grade, title='فصل ۱', order=1) + self.next_unit = Unit.objects.create(grade=self.grade, title='فصل ۲', order=2) + self.first = self._node(self.unit, 'اول', 1, lessons=5) + self.second = self._node(self.unit, 'دوم', 2, lessons=3) + self.other_unit_first = self._node(self.next_unit, 'فصلِ بعد', 1, lessons=2) + + self.user = User.objects.create(phone='09120000009', grade_id=self.grade.id) + self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'} + + def _node(self, unit, title, order, lessons): + 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}') + question = Question.objects.create( + lesson=lesson, type='multiple_choice', prompt=f'سوال {i}') + Choice.objects.create(question=question, value='الف', is_correct=True) + Choice.objects.create(question=question, value='ب', is_correct=False) + return node + + def _complete(self, node, correct=3, total=4, **headers): + return self.client.post( + f'/api/nodes/{node.pk}/quiz/complete', + {'correctCount': correct, 'totalQuestions': total}, + content_type='application/json', **self.auth, **headers, + ) + + def _statuses(self): + units = self.client.get('/api/home', **self.auth).json()['units'] + return {n['title']: n['status'] for u in units for n in u['nodes']} + + def test_finishing_the_quiz_opens_the_next_island(self): + self.assertEqual(self._statuses()['دوم'], 'locked') + + response = self._complete(self.first) + + self.assertEqual(response.status_code, 200) + statuses = self._statuses() + self.assertEqual(statuses['اول'], 'completed') + self.assertEqual(statuses['دوم'], 'unlocked') + + def test_a_low_score_still_moves_on(self): + """قاعده عمداً ساده است: آزمون که تمام شد، جزیره تمام است.""" + self._complete(self.first, correct=0) + + self.assertEqual(self._statuses()['دوم'], 'unlocked') + + def test_it_names_the_next_island(self): + body = self._complete(self.first).json() + self.assertEqual(body['nextNodeId'], str(self.second.pk)) + + # آخرین جزیره‌ی فصل → اولین جزیره‌ی فصلِ بعد + body = self._complete(self.second, total=3).json() + self.assertEqual(body['nextNodeId'], str(self.other_unit_first.pk)) + + # آخرین جزیره‌ی پایه + body = self._complete(self.other_unit_first, correct=2, total=2).json() + self.assertIsNone(body['nextNodeId']) + + def test_rewards_only_the_first_time(self): + first = self._complete(self.first, correct=4).json() + again = self._complete(self.first, correct=4).json() + + self.assertEqual(first['xpEarned'], 40) + self.assertEqual(first['stars'], 3) + self.assertEqual(first['coinsEarned'], 15) + self.assertEqual(again['xpEarned'], 0) + self.assertEqual(again['coinsEarned'], 0) + self.assertEqual(UserStats.objects.get(user_id=self.user.id).xp, 40) + + 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() + + self.assertEqual(body['xpEarned'], 40) # همان نتیجه‌ی قبلی + self.assertEqual(UserStats.objects.get(user_id=self.user.id).xp, 40) + + def test_cannot_claim_more_questions_than_the_island_has(self): + response = self._complete(self.first, correct=9, total=9) + self.assertEqual(response.status_code, 400) + + def test_cannot_claim_more_correct_than_asked(self): + response = self._complete(self.first, correct=5, total=4) + self.assertEqual(response.status_code, 400) + + def test_requires_sign_in(self): + response = self.client.post( + f'/api/nodes/{self.first.pk}/quiz/complete', + {'correctCount': 1, 'totalQuestions': 4}, content_type='application/json', + ) + self.assertEqual(response.status_code, 401) + + def test_it_sends_a_notification_once(self): + self._complete(self.first) + self._complete(self.first) + + notes = Notification.objects.filter(user_id=self.user.id, type='node_completed') + self.assertEqual(notes.count(), 1) + self.assertIn('اول', notes.first().body) + + def test_node_progress_is_completed_and_stays_completed(self): + """درسِ تازه در همان جزیره نباید آن را به in_progress برگرداند.""" + self._complete(self.first) + lesson = self.first.lessons.first() + self.client.post( + f'/api/lessons/{lesson.pk}/complete', + {'correctCount': 1, 'totalQuestions': 1}, + content_type='application/json', **self.auth, + ) + + progress = NodeProgress.objects.get(user_id=self.user.id, node=self.first) + self.assertEqual(progress.status, 'completed') + + def test_progress_percent_counts_the_islands_lessons(self): + """جزیره‌ی ۵ درسی از ۱۰ درسِ پایه → ۵۰٪، و فقط در پایه‌ی خودِ کاربر.""" + Grade.objects.create(title='پایه دهم', icon='', color='') # مخرج را بزرگ نکند + body = self._complete(self.first).json() + + self.assertEqual(body['stats']['progressPercent'], 50.0) + home = self.client.get('/api/home', **self.auth).json() + self.assertEqual(home['stats']['progressPercent'], 50.0) + + def test_lesson_completion_progress_is_per_grade_too(self): + lesson = self.second.lessons.first() + body = self.client.post( + f'/api/lessons/{lesson.pk}/complete', + {'correctCount': 1, 'totalQuestions': 1}, + content_type='application/json', **self.auth, + ).json() + + self.assertEqual(body['stats']['progressPercent'], 10.0) # ۱ از ۱۰ درس + self.assertTrue(UserLessonCompletion.objects.filter(user_id=self.user.id).exists()) diff --git a/content/urls.py b/content/urls.py index 11e7e7a..d7f2b20 100644 --- a/content/urls.py +++ b/content/urls.py @@ -7,6 +7,7 @@ from .views import ( LessonIntroView, LessonQuestionsView, NodeLessonsView, + NodeQuizCompleteView, NodeQuizView, NodeProgressListView, QuestionAnswerView, @@ -23,5 +24,6 @@ urlpatterns = [ path('questions//report', QuestionReportView.as_view(), name='question-report'), path('nodes//lessons', NodeLessonsView.as_view(), name='node-lessons'), path('nodes//quiz', NodeQuizView.as_view(), name='node-quiz'), + path('nodes//quiz/complete', NodeQuizCompleteView.as_view(), name='node-quiz-complete'), path('progress/nodes', NodeProgressListView.as_view(), name='node-progress-list'), ] diff --git a/content/views.py b/content/views.py index bb89409..a5828dd 100644 --- a/content/views.py +++ b/content/views.py @@ -17,9 +17,13 @@ from rest_framework.views import APIView from progress.models import UserHearts, UserStats 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, +) from .models import ( Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, UserAnswer, - UserLessonCompletion, + UserLessonCompletion, UserNodeCompletion, ) from .serializers import ( AnswerRequestSerializer, @@ -31,6 +35,8 @@ from .serializers import ( LessonIntroResponseSerializer, LessonQuestionsResponseSerializer, NodeLessonsResponseSerializer, + NodeQuizCompleteRequestSerializer, + NodeQuizCompleteResponseSerializer, NodeQuizResponseSerializer, NodeProgressListResponseSerializer, QuestionReportRequestSerializer, @@ -157,7 +163,10 @@ class GradesListView(APIView): return Response(GradesListResponseSerializer(_grades_payload()).data) -def _node_status(lesson_ids, completed_ids, is_first_unlocked_candidate): +def _node_status(lesson_ids, completed_ids, is_first_unlocked_candidate, quiz_completed=False): + # آزمونِ مبحث تمام شده → مبحث تمام است، مستقل از تک‌تکِ درس‌ها. + if quiz_completed: + return 'completed' if not lesson_ids: return 'locked' done = sum(1 for lid in lesson_ids if lid in completed_ids) @@ -231,12 +240,19 @@ 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']], + ) + units_payload = [] for unit in tree: node_payload = [] unlock_next = True # اولین نودِ قفل‌نشده در هر واحد، باز می‌مونه for node in unit['nodes']: - node_status = _node_status(node['lesson_ids'], completed_ids, unlock_next) + node_status = _node_status( + node['lesson_ids'], completed_ids, unlock_next, + quiz_completed=int(node['id']) in quiz_done, + ) if node_status != 'completed': unlock_next = False node_payload.append({ @@ -254,9 +270,23 @@ class HomeView(APIView): 'nodes': node_payload, }) + # درصدِ پیشرفت برای همین پایه‌ای که نقشه‌اش نشان داده می‌شود، زنده از + # همین داده‌ها: درس‌های تمام‌شده + همه‌ی درس‌های مبحث‌هایی که آزمونشان + # تمام شده. UserStats.progress_percent فقط در لحظه‌ی تکمیل به‌روز + # می‌شود و به پایه‌ی دیگر (?gradeId=) ربطی ندارد. + done_lessons = set(completed_ids) + for unit in tree: + for node in unit['nodes']: + if int(node['id']) in quiz_done: + done_lessons.update(node['lesson_ids']) + progress_percent = ( + round(min(len(done_lessons) / len(all_lesson_ids), 1.0) * 100, 2) + if all_lesson_ids else 0 + ) + stats = UserStats.objects.filter(user_id=user_id).first() stats_payload = { - 'progressPercent': stats.progress_percent if stats else 0, + 'progressPercent': progress_percent, 'xp': stats.xp if stats else 0, 'coins': stats.coins if stats else 0, } @@ -513,11 +543,9 @@ class LessonCompleteView(APIView): stats.xp += xp_earned stats.coins += coins_earned - total_lessons = Lesson.objects.count() or 1 - completed_lessons = UserLessonCompletion.objects.filter( - user_id=user_id - ).values('lesson_id').distinct().count() - stats.progress_percent = round(min(completed_lessons / total_lessons, 1.0) * 100, 2) + stats.progress_percent = grade_progress_percent( + user_id, getattr(request.user, 'grade_id', None) or lesson.node.unit.grade_id, + ) stats.save(update_fields=['xp', 'coins', 'progress_percent']) # اعلانِ درسِ تکمیل‌شده. تا پیش از این، نوعِ 'lesson_completed' @@ -746,6 +774,139 @@ class NodeQuizView(APIView): }).data) +class NodeQuizCompleteView(APIView): + """ + POST /api/nodes/{id}/quiz/complete — پایانِ آزمونِ یک جزیره. + + قاعده ساده است: آزمون که تمام شد، جزیره تمام است و جزیره‌ی بعدی باز + می‌شود، هر نمره‌ای که گرفته باشد. نمره فقط ستاره و امتیاز را تعیین + می‌کند. تا پیش از این اندپوینت، جزیره فقط با تمام کردنِ تک‌تکِ درس‌هایش + (۸ تا ۱۳ درس) تمام می‌شد و اپ که با آزمونِ جزیره بازی می‌کند هیچ‌وقت به + آن نمی‌رسید؛ دانش‌آموز بعد از جزیره‌ی اولِ هر فصل گیر می‌کرد. + + فقط بارِ اول امتیاز و سکه می‌دهد (مثلِ lessons/{id}/complete)؛ تکرارِ + آزمون ثبت می‌شود ولی جایزه‌ی تازه ندارد. + """ + + permission_classes = [IsAuthenticated] + throttle_scope = 'node_complete' + throttle_classes = [GuestAwareContentThrottle] + + @extend_schema( + request=NodeQuizCompleteRequestSerializer, + responses=NodeQuizCompleteResponseSerializer, + parameters=[ + OpenApiParameter( + 'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False, + description='رتری/دابل‌کلیک با همان کلید ردیف یا جایزه‌ی تکراری نمی‌سازد.', + ), + ], + tags=['Curriculum (content)'], + ) + def post(self, request, node_id: int): + node = get_object_or_404(Node.objects.select_related('unit'), pk=node_id) + serializer = NodeQuizCompleteRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + correct_count = serializer.validated_data['correctCount'] + total_questions = serializer.validated_data['totalQuestions'] + + # سقف‌ها از روی خودِ مبحث، نه از روی حرفِ کلاینت، تا امتیازِ متورم + # ثبت نشود: آزمون بیشتر از استخرِ سوال‌های مبحث (و سقفِ NodeQuizView) + # سوال ندارد، و پاسخِ درست بیشتر از سوال‌ها نمی‌شود. + 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} سوال دارد.', + }) + if correct_count > total_questions: + raise ValidationError({ + 'correctCount': 'correctCount نمی‌تواند از totalQuestions بیشتر باشد.', + }) + + 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 + idempotency_key = request.headers.get('Idempotency-Key') or None + next_node = next_node_after(node) + + def respond(completion, stats): + return Response(NodeQuizCompleteResponseSerializer({ + 'nodeId': str(node.pk), + 'stars': completion.stars, + 'xpEarned': completion.xp_earned, + 'coinsEarned': completion.coins_earned, + 'nextNodeId': str(next_node.pk) if next_node else None, + 'stats': { + 'progressPercent': stats.progress_percent if stats else 0, + 'xp': stats.xp if stats else 0, + 'coins': stats.coins if stats else 0, + }, + }).data) + + if idempotency_key: + existing = UserNodeCompletion.objects.filter( + user_id=user_id, node=node, idempotency_key=idempotency_key, + ).first() + if existing is not None: + return respond(existing, UserStats.objects.filter(user_id=user_id).first()) + + try: + with transaction.atomic(): + UserStats.objects.get_or_create(user_id=user_id) + stats = UserStats.objects.select_for_update().get(user_id=user_id) + + is_first = not UserNodeCompletion.objects.filter( + user_id=user_id, node=node, + ).exists() + completion = UserNodeCompletion.objects.create( + user_id=user_id, + node=node, + correct_count=correct_count, + total_questions=total_questions, + stars=stars, + xp_earned=correct_count * 10 if is_first else 0, + coins_earned=stars * 5 if is_first else 0, + idempotency_key=idempotency_key, + ) + + stats.xp += completion.xp_earned + stats.coins += completion.coins_earned + stats.progress_percent = grade_progress_percent( + user_id, getattr(request.user, 'grade_id', None) or node.unit.grade_id, + ) + stats.save(update_fields=['xp', 'coins', 'progress_percent']) + + mark_node_completed_by_quiz(user_id, node) + + if is_first: + from notifications.services import notify + notify( + user_id=user_id, + type='node_completed', + title='جزیره را تمام کردی', + body=( + f'«{node.title}» را با {stars} ستاره تمام کردی و ' + f'{completion.xp_earned} امتیاز گرفتی.' + ), + related_id=node.id, + ) + except IntegrityError: + # درخواستِ هم‌زمان با همان کلید؛ نتیجه‌ی رقیب برگردانده می‌شود. + if not idempotency_key: + raise + completion = UserNodeCompletion.objects.filter( + user_id=user_id, node=node, idempotency_key=idempotency_key, + ).first() + if completion is None: + raise + stats = UserStats.objects.filter(user_id=user_id).first() + + return respond(completion, stats) + + class NodeLessonsView(APIView): """ GET /api/nodes/{id}/lessons — قدم‌های یک مبحث برای نقشه‌ی مهارت. diff --git a/docs/architecture.md b/docs/architecture.md index a044a7a..02b5085 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,6 +79,26 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م برمی‌گرداند. `questionCount` جدا از `requested` گزارش می‌شود تا یک نودِ کم‌سوال شبیهِ خطا به نظر نرسد. +**پایانِ آزمون، پایانِ جزیره است.** `POST /api/nodes/{id}/quiz/complete` با +`{correctCount, totalQuestions}` جزیره را تمام می‌کند و جزیره‌ی بعدی روی نقشه باز +می‌شود — با هر نمره‌ای؛ نمره فقط ستاره و امتیاز را تعیین می‌کند (تصمیمِ صاحبِ +پروژه، ۲۰۲۶-۰۹-۲۴: ساده نگهش داریم). پاسخ `nextNodeId` را هم می‌دهد. امتیاز و +سکه فقط بارِ اول. ردیف‌ها در `UserNodeCompletion`اند؛ `_node_status` نودی را که +آزمونش تمام شده `completed` می‌بیند، و مسیرِ قدیمیِ «همه‌ی درس‌ها تک‌تک» هم هنوز +کار می‌کند. + +`progressPercent` در `/api/home` برای همان پایه‌ای است که نقشه‌اش نشان داده +می‌شود: درس‌های تمام‌شده به‌اضافه‌ی همه‌ی درس‌های جزیره‌های تمام‌شده، تقسیم بر +درس‌های آن پایه. (قبلاً مخرج همه‌ی ۲٬۶۱۷ درسِ همه‌ی پایه‌ها بود.) + +## دستیار بدونِ درس + +صفحه‌ی «از پی بپرس» از نوارِ پایین باز می‌شود و `lessonId` ندارد. در این حالت +`/api/ai/ask` کتاب را از پایه‌ی ذخیره‌شده‌ی خودِ کاربر درمی‌آورد +(`rag.services.lesson_context.resolve_from_grade`) و در کلِ همان کتاب جستجو +می‌کند؛ قبلاً retrieval رد می‌شد و جواب عمومی بود. `lessonId` اگر بیاید همچنان +دقیق‌تر است، چون فصل را هم معلوم می‌کند. + ## دیتابیس هنوز SQLite است (`OPTIONS.timeout=20` تا قفل‌های هم‌زمانیِ نوشتن را تحمل کند). diff --git a/docs/operations.md b/docs/operations.md index fcaf83f..9d8d2f2 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -161,7 +161,8 @@ curl -X POST https://api.math.tadrisino2.ir/api/auth/login \ مسیرِ مهمان (`POST /api/auth/session` با یک `deviceId`) اصلاً به پیامک کار ندارد و همیشه کار می‌کند — هر رشته‌ای به‌عنوانِ `deviceId` یک حسابِ تازه -می‌سازد. +می‌سازد. از ۲۰۲۶-۰۹-۲۴ پاسخش `refreshToken` هم دارد (مثلِ ورود با شماره)، پس +مهمان بعد از انقضای توکنِ یک‌ساعته با `POST /api/auth/refresh` ادامه می‌دهد. یک `deviceId` مشخص روی پروداکشن از قبل داده دارد: diff --git a/notifications/migrations/0002_alter_notification_type.py b/notifications/migrations/0002_alter_notification_type.py new file mode 100644 index 0000000..b8552bf --- /dev/null +++ b/notifications/migrations/0002_alter_notification_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-09-23 23:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='notification', + name='type', + field=models.CharField(choices=[('xp_earned', 'امتیاز گرفتی'), ('lesson_completed', 'درس تکمیل شد'), ('node_completed', 'جزیره تمام شد'), ('order_paid', 'پرداخت موفق'), ('ticket_replied', 'پاسخ تیکت'), ('family_link_accepted', 'اتصال خانواده تأیید شد'), ('weekly_report_ready', 'گزارش هفتگی آماده شد')], max_length=30), + ), + ] diff --git a/notifications/models.py b/notifications/models.py index a617a10..6485834 100644 --- a/notifications/models.py +++ b/notifications/models.py @@ -9,6 +9,7 @@ class Notification(models.Model): TYPE_CHOICES = [ ('xp_earned', 'امتیاز گرفتی'), ('lesson_completed', 'درس تکمیل شد'), + ('node_completed', 'جزیره تمام شد'), ('order_paid', 'پرداخت موفق'), ('ticket_replied', 'پاسخ تیکت'), ('family_link_accepted', 'اتصال خانواده تأیید شد'), diff --git a/rag/services/lesson_context.py b/rag/services/lesson_context.py index fca65b2..da177c7 100644 --- a/rag/services/lesson_context.py +++ b/rag/services/lesson_context.py @@ -83,3 +83,24 @@ def resolve_from_lesson(lesson_id) -> dict: unit_title, grade_title, ) return {'gradeTitle': grade_title} + + +def resolve_from_grade(grade_id) -> dict: + """ + {'subject', 'gradeTitle'} برای وقتی که هیچ درسی در کار نیست. + + صفحه‌ی «از پی بپرس» از نوارِ پایینِ اپ باز می‌شود، نه از داخلِ یک درس؛ + پس نه lessonId دارد نه subject. بدونِ این تابع retrieval کلاً رد می‌شد و + همه‌ی جواب‌های آن صفحه عمومی بودند. هر پایه دقیقاً یک کتابِ ریاضی دارد، + پس پایه‌ی خودِ کاربر برای انتخابِ کتاب کافی‌ست؛ فصل معلوم نیست و جستجو + روی کلِ همان کتاب انجام می‌شود. + """ + from .config import resolve_grade_title + + grade_title = resolve_grade_title(grade_id) + if not grade_title: + return {} + for subject, grade in MATH_CHAPTERS: + if grade == grade_title: + return {'subject': subject, 'gradeTitle': grade_title} + return {'gradeTitle': grade_title} diff --git a/subscription/migrations/0005_seed_yearly_plan.py b/subscription/migrations/0005_seed_yearly_plan.py new file mode 100644 index 0000000..be73b22 --- /dev/null +++ b/subscription/migrations/0005_seed_yearly_plan.py @@ -0,0 +1,58 @@ +""" +پلنِ اشتراکِ یک‌ساله — اولین پلنِ واقعیِ سرور. + +تا این migration روی پروداکشن هیچ Planی نبود و تبِ «اشتراک» در اپ خالی +بود. از راهِ migration ساخته می‌شود نه دستی از ادمین، تا هر سرورِ تازه‌ای +با همان `deploy.sh` صاحبش شود. + +قیمت تصمیمِ محصولی است و از پنلِ ادمین قابلِ تغییر است؛ این migration فقط +وقتی می‌سازدش که هیچ پلنی وجود نداشته باشد، پس ویرایشِ بعدیِ ادمین را هرگز +بازنویسی نمی‌کند. مبنای ۲۹۹٬۰۰۰ تومان: اپ‌های خودآموزِ مشابه (مثلاً زبانشناس) +اشتراکِ سالانه را حدودِ ماهی ۴۰ هزار تومان می‌فروشند؛ پکیج‌های ویدیوییِ +کنکور چند میلیون‌اند و هم‌رده‌ی این اپ نیستند. + +⚠️ تنها مزیتی که اشتراک امروز در کد واقعاً می‌دهد «قلبِ نامحدود» است +(subscription.services.has_unlimited_hearts). بندهای دیگرِ features برای +همه در دسترس‌اند؛ اگر قرار است اشتراک چیزِ بیشتری بدهد، آن تصمیمِ محصولی است. +""" +from django.db import migrations + +YEARLY_PLAN = { + 'title': 'اشتراک یک‌ساله', + 'price': 299_000, + 'currency': 'IRT', + 'discount_percent': 0, + 'popular': True, + 'duration_days': 365, + 'badge': 'بهترین انتخاب', + 'features': [ + 'قلبِ نامحدود؛ هیچ‌وقت وسطِ تمرین متوقف نمی‌شی', + 'همه‌ی فصل‌ها و آزمون‌های پایه‌ات', + 'دستیارِ هوشمند بر اساسِ کتابِ درسی', + 'یک سال کامل دسترسی', + ], +} + + +def create_yearly_plan(apps, schema_editor): + Plan = apps.get_model('subscription', 'Plan') + if Plan.objects.exists(): + return + Plan.objects.create(**YEARLY_PLAN) + + +def remove_yearly_plan(apps, schema_editor): + Plan = apps.get_model('subscription', 'Plan') + # فقط پلنی که خودِ این migration ساخته و هنوز خریدی ندارد. + Plan.objects.filter(title=YEARLY_PLAN['title'], subscriptions__isnull=True).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('subscription', '0004_plan_badge_plan_features'), + ] + + operations = [ + migrations.RunPython(create_yearly_plan, remove_yearly_plan), + ] diff --git a/subscription/tests.py b/subscription/tests.py index b02b3db..9197148 100644 --- a/subscription/tests.py +++ b/subscription/tests.py @@ -527,6 +527,9 @@ class PlanCardContractTests(TestCase): def setUp(self): from django.core.cache import cache cache.clear() + # migrationِ 0005 پلنِ یک‌ساله را می‌سازد؛ این تست‌ها شکلِ یک ردیفِ + # مشخص را می‌سنجند و باید از جدولِ خالی شروع کنند. + Plan.objects.all().delete() def test_plans_carry_their_feature_list_and_badge(self): Plan.objects.create(title='طلایی', price=49000, popular=True, @@ -541,3 +544,31 @@ class PlanCardContractTests(TestCase): row = self.client.get('/api/subscription/plans').json()['plans'][0] self.assertEqual(row['features'], []) self.assertEqual(row['badge'], '') + + +class YearlyPlanSeedTests(TestCase): + """migrationِ 0005: سرورِ بی‌پلن یک پلنِ یک‌ساله می‌گیرد.""" + + def test_the_yearly_plan_exists_after_migrations(self): + plan = Plan.objects.get(title='اشتراک یک‌ساله') + self.assertEqual(plan.duration_days, 365) + self.assertEqual(plan.price, 299_000) + self.assertEqual(plan.currency, 'IRT') + self.assertTrue(plan.features) + + def test_it_is_served_to_the_app(self): + from django.core.cache import cache + cache.clear() + titles = [p['title'] for p in self.client.get('/api/subscription/plans').json()['plans']] + self.assertIn('اشتراک یک‌ساله', titles) + + def test_it_never_overwrites_existing_plans(self): + import importlib + from django.apps import apps as global_apps + seed = importlib.import_module('subscription.migrations.0005_seed_yearly_plan') + Plan.objects.all().delete() + Plan.objects.create(title='پلنِ ادمین', price=1000) + + seed.create_yearly_plan(global_apps, None) + + self.assertEqual(list(Plan.objects.values_list('title', flat=True)), ['پلنِ ادمین'])