let a finished island quiz open the next island, and ground ask-AI in the student's book
The frontend plays node quizzes, but a node only completed once every one of its
8-13 lessons was completed separately, so students were stuck after the first
island of each unit. POST /api/nodes/{id}/quiz/complete now completes the node
(any score; score only sets stars/xp) and returns nextNodeId.
Also closes the other backend gaps found while auditing the frontend:
- /api/ai/ask without lessonId now picks the book from the user's grade instead
of skipping retrieval, so the "ask Pi" tab is grounded in the textbook.
- guest sessions return a refreshToken; they used to go silently 401 after 1h.
- lesson_complete throttle 10/hour -> 120/hour (a lesson here is 1-2 questions).
- progressPercent is per grade, not over all 2,617 lessons of every grade.
- a one-year plan (299,000 toman) is created by migration when no plan exists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
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.
|
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
|
## AI provider notes
|
||||||
|
|
||||||
`OPENAI_BASE_URL` points at GapGPT (an OpenAI proxy reachable from Iran) and it exposes
|
`OPENAI_BASE_URL` points at GapGPT (an OpenAI proxy reachable from Iran) and it exposes
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ class RequestOTPRequestSerializer(serializers.Serializer):
|
|||||||
class GuestSessionResponseSerializer(serializers.Serializer):
|
class GuestSessionResponseSerializer(serializers.Serializer):
|
||||||
userId = serializers.CharField()
|
userId = serializers.CharField()
|
||||||
token = serializers.CharField()
|
token = serializers.CharField()
|
||||||
|
refreshToken = serializers.CharField()
|
||||||
isNewUser = serializers.BooleanField()
|
isNewUser = serializers.BooleanField()
|
||||||
isGuest = serializers.BooleanField()
|
isGuest = serializers.BooleanField()
|
||||||
|
|
||||||
|
|||||||
41
accounts/tests_guest_refresh.py
Normal file
41
accounts/tests_guest_refresh.py
Normal file
@@ -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'])
|
||||||
@@ -89,9 +89,22 @@ class GuestSessionView(APIView):
|
|||||||
|
|
||||||
token = issue_access_token(user.id)
|
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 = {
|
data = {
|
||||||
'userId': str(user.id),
|
'userId': str(user.id),
|
||||||
'token': token,
|
'token': token,
|
||||||
|
'refreshToken': refresh_token,
|
||||||
'isNewUser': is_new_user,
|
'isNewUser': is_new_user,
|
||||||
'isGuest': True,
|
'isGuest': True,
|
||||||
}
|
}
|
||||||
|
|||||||
86
ai_tutor/tests_grade_grounding.py
Normal file
86
ai_tutor/tests_grade_grounding.py
Normal file
@@ -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'], '')
|
||||||
@@ -287,6 +287,21 @@ class AskAIView(APIView):
|
|||||||
conversation.lesson_number = lesson_number
|
conversation.lesson_number = lesson_number
|
||||||
conversation.save(update_fields=['subject', '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 پیکربندیشده.
|
# --- مسیرِ استریم (SSE) --- فقط وقتی stream=true و RAG پیکربندیشده.
|
||||||
# قراردادِ JSONِ غیراستریم دستنخورده میماند؛ Swagger که stream
|
# قراردادِ JSONِ غیراستریم دستنخورده میماند؛ Swagger که stream
|
||||||
# نمیفرسته همون مسیرِ قبلی رو میگیره.
|
# نمیفرسته همون مسیرِ قبلی رو میگیره.
|
||||||
|
|||||||
@@ -410,7 +410,10 @@ REST_FRAMEWORK = {
|
|||||||
'token_refresh': '30/hour',
|
'token_refresh': '30/hour',
|
||||||
'question_answer': '60/min',
|
'question_answer': '60/min',
|
||||||
'question_report': '20/hour',
|
'question_report': '20/hour',
|
||||||
'lesson_complete': '10/hour',
|
# هر درس اینجا یک نوع مسئله است با یکی-دو سوال، نه یک جلسهی درسی؛
|
||||||
|
# ۱۰ در ساعت را دانشآموزِ عادی میزد و پیشرفتش ۴۲۹ میگرفت.
|
||||||
|
'lesson_complete': '120/hour',
|
||||||
|
'node_complete': '60/hour',
|
||||||
'support_create': '30/hour',
|
'support_create': '30/hour',
|
||||||
'offer_redeem': '10/hour',
|
'offer_redeem': '10/hour',
|
||||||
'discount_validate': '20/hour',
|
'discount_validate': '20/hour',
|
||||||
|
|||||||
33
content/migrations/0009_usernodecompletion.py
Normal file
33
content/migrations/0009_usernodecompletion.py
Normal file
@@ -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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -157,6 +157,43 @@ class UserLessonCompletion(models.Model):
|
|||||||
return f"completion-user{self.user_id}-lesson{self.lesson_id}"
|
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):
|
class NodeProgress(models.Model):
|
||||||
"""
|
"""
|
||||||
وضعیتِ پیشرفتِ هر کاربر روی هر Node (نه Lesson و نه Unit).
|
وضعیتِ پیشرفتِ هر کاربر روی هر Node (نه Lesson و نه Unit).
|
||||||
|
|||||||
@@ -136,6 +136,23 @@ class NodeQuizResponseSerializer(serializers.Serializer):
|
|||||||
questions = QuizQuestionSerializer(many=True)
|
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):
|
class LessonQuestionsResponseSerializer(serializers.Serializer):
|
||||||
questions = QuestionSerializer(many=True)
|
questions = QuestionSerializer(many=True)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from django.utils import timezone
|
|||||||
|
|
||||||
from statuslog.signals import status_changed
|
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):
|
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):
|
if lesson_ids and completed_count >= len(lesson_ids):
|
||||||
progress.status = 'completed'
|
progress.status = 'completed'
|
||||||
progress.completed_at = now
|
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:
|
else:
|
||||||
progress.status = 'in_progress'
|
progress.status = 'in_progress'
|
||||||
|
|
||||||
@@ -89,6 +94,73 @@ def update_node_progress_on_lesson_completion(user_id, lesson):
|
|||||||
return progress
|
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):
|
def node_progress_summary(node):
|
||||||
"""برای سریالایزر: تعداد کل Lessonهای یک Node (لازم برای lessonsTotal)."""
|
"""برای سریالایزر: تعداد کل Lessonهای یک Node (لازم برای lessonsTotal)."""
|
||||||
return node.lessons.count()
|
return node.lessons.count()
|
||||||
|
|||||||
154
content/tests_node_completion.py
Normal file
154
content/tests_node_completion.py
Normal file
@@ -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())
|
||||||
@@ -7,6 +7,7 @@ from .views import (
|
|||||||
LessonIntroView,
|
LessonIntroView,
|
||||||
LessonQuestionsView,
|
LessonQuestionsView,
|
||||||
NodeLessonsView,
|
NodeLessonsView,
|
||||||
|
NodeQuizCompleteView,
|
||||||
NodeQuizView,
|
NodeQuizView,
|
||||||
NodeProgressListView,
|
NodeProgressListView,
|
||||||
QuestionAnswerView,
|
QuestionAnswerView,
|
||||||
@@ -23,5 +24,6 @@ urlpatterns = [
|
|||||||
path('questions/<int:question_id>/report', QuestionReportView.as_view(), name='question-report'),
|
path('questions/<int:question_id>/report', QuestionReportView.as_view(), name='question-report'),
|
||||||
path('nodes/<int:node_id>/lessons', NodeLessonsView.as_view(), name='node-lessons'),
|
path('nodes/<int:node_id>/lessons', NodeLessonsView.as_view(), name='node-lessons'),
|
||||||
path('nodes/<int:node_id>/quiz', NodeQuizView.as_view(), name='node-quiz'),
|
path('nodes/<int:node_id>/quiz', NodeQuizView.as_view(), name='node-quiz'),
|
||||||
|
path('nodes/<int:node_id>/quiz/complete', NodeQuizCompleteView.as_view(), name='node-quiz-complete'),
|
||||||
path('progress/nodes', NodeProgressListView.as_view(), name='node-progress-list'),
|
path('progress/nodes', NodeProgressListView.as_view(), name='node-progress-list'),
|
||||||
]
|
]
|
||||||
|
|||||||
179
content/views.py
179
content/views.py
@@ -17,9 +17,13 @@ from rest_framework.views import APIView
|
|||||||
from progress.models import UserHearts, UserStats
|
from progress.models import UserHearts, UserStats
|
||||||
from subscription.services import has_unlimited_hearts
|
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 (
|
from .models import (
|
||||||
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, UserAnswer,
|
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, UserAnswer,
|
||||||
UserLessonCompletion,
|
UserLessonCompletion, UserNodeCompletion,
|
||||||
)
|
)
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
AnswerRequestSerializer,
|
AnswerRequestSerializer,
|
||||||
@@ -31,6 +35,8 @@ from .serializers import (
|
|||||||
LessonIntroResponseSerializer,
|
LessonIntroResponseSerializer,
|
||||||
LessonQuestionsResponseSerializer,
|
LessonQuestionsResponseSerializer,
|
||||||
NodeLessonsResponseSerializer,
|
NodeLessonsResponseSerializer,
|
||||||
|
NodeQuizCompleteRequestSerializer,
|
||||||
|
NodeQuizCompleteResponseSerializer,
|
||||||
NodeQuizResponseSerializer,
|
NodeQuizResponseSerializer,
|
||||||
NodeProgressListResponseSerializer,
|
NodeProgressListResponseSerializer,
|
||||||
QuestionReportRequestSerializer,
|
QuestionReportRequestSerializer,
|
||||||
@@ -157,7 +163,10 @@ class GradesListView(APIView):
|
|||||||
return Response(GradesListResponseSerializer(_grades_payload()).data)
|
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:
|
if not lesson_ids:
|
||||||
return 'locked'
|
return 'locked'
|
||||||
done = sum(1 for lid in lesson_ids if lid in completed_ids)
|
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)
|
).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 = []
|
units_payload = []
|
||||||
for unit in tree:
|
for unit in tree:
|
||||||
node_payload = []
|
node_payload = []
|
||||||
unlock_next = True # اولین نودِ قفلنشده در هر واحد، باز میمونه
|
unlock_next = True # اولین نودِ قفلنشده در هر واحد، باز میمونه
|
||||||
for node in unit['nodes']:
|
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':
|
if node_status != 'completed':
|
||||||
unlock_next = False
|
unlock_next = False
|
||||||
node_payload.append({
|
node_payload.append({
|
||||||
@@ -254,9 +270,23 @@ class HomeView(APIView):
|
|||||||
'nodes': node_payload,
|
'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 = UserStats.objects.filter(user_id=user_id).first()
|
||||||
stats_payload = {
|
stats_payload = {
|
||||||
'progressPercent': stats.progress_percent if stats else 0,
|
'progressPercent': progress_percent,
|
||||||
'xp': stats.xp if stats else 0,
|
'xp': stats.xp if stats else 0,
|
||||||
'coins': stats.coins if stats else 0,
|
'coins': stats.coins if stats else 0,
|
||||||
}
|
}
|
||||||
@@ -513,11 +543,9 @@ class LessonCompleteView(APIView):
|
|||||||
stats.xp += xp_earned
|
stats.xp += xp_earned
|
||||||
stats.coins += coins_earned
|
stats.coins += coins_earned
|
||||||
|
|
||||||
total_lessons = Lesson.objects.count() or 1
|
stats.progress_percent = grade_progress_percent(
|
||||||
completed_lessons = UserLessonCompletion.objects.filter(
|
user_id, getattr(request.user, 'grade_id', None) or lesson.node.unit.grade_id,
|
||||||
user_id=user_id
|
)
|
||||||
).values('lesson_id').distinct().count()
|
|
||||||
stats.progress_percent = round(min(completed_lessons / total_lessons, 1.0) * 100, 2)
|
|
||||||
stats.save(update_fields=['xp', 'coins', 'progress_percent'])
|
stats.save(update_fields=['xp', 'coins', 'progress_percent'])
|
||||||
|
|
||||||
# اعلانِ درسِ تکمیلشده. تا پیش از این، نوعِ 'lesson_completed'
|
# اعلانِ درسِ تکمیلشده. تا پیش از این، نوعِ 'lesson_completed'
|
||||||
@@ -746,6 +774,139 @@ class NodeQuizView(APIView):
|
|||||||
}).data)
|
}).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):
|
class NodeLessonsView(APIView):
|
||||||
"""
|
"""
|
||||||
GET /api/nodes/{id}/lessons — قدمهای یک مبحث برای نقشهی مهارت.
|
GET /api/nodes/{id}/lessons — قدمهای یک مبحث برای نقشهی مهارت.
|
||||||
|
|||||||
@@ -79,6 +79,26 @@ PaymentRequest را میزند، `Order.gateway_authority` را ذخیره م
|
|||||||
برمیگرداند. `questionCount` جدا از `requested` گزارش میشود تا یک نودِ کمسوال
|
برمیگرداند. `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` تا قفلهای همزمانیِ نوشتن را تحمل کند).
|
هنوز SQLite است (`OPTIONS.timeout=20` تا قفلهای همزمانیِ نوشتن را تحمل کند).
|
||||||
|
|||||||
@@ -161,7 +161,8 @@ curl -X POST https://api.math.tadrisino2.ir/api/auth/login \
|
|||||||
|
|
||||||
مسیرِ مهمان (`POST /api/auth/session` با یک `deviceId`) اصلاً به پیامک کار
|
مسیرِ مهمان (`POST /api/auth/session` با یک `deviceId`) اصلاً به پیامک کار
|
||||||
ندارد و همیشه کار میکند — هر رشتهای بهعنوانِ `deviceId` یک حسابِ تازه
|
ندارد و همیشه کار میکند — هر رشتهای بهعنوانِ `deviceId` یک حسابِ تازه
|
||||||
میسازد.
|
میسازد. از ۲۰۲۶-۰۹-۲۴ پاسخش `refreshToken` هم دارد (مثلِ ورود با شماره)، پس
|
||||||
|
مهمان بعد از انقضای توکنِ یکساعته با `POST /api/auth/refresh` ادامه میدهد.
|
||||||
|
|
||||||
یک `deviceId` مشخص روی پروداکشن از قبل داده دارد:
|
یک `deviceId` مشخص روی پروداکشن از قبل داده دارد:
|
||||||
|
|
||||||
|
|||||||
18
notifications/migrations/0002_alter_notification_type.py
Normal file
18
notifications/migrations/0002_alter_notification_type.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -9,6 +9,7 @@ class Notification(models.Model):
|
|||||||
TYPE_CHOICES = [
|
TYPE_CHOICES = [
|
||||||
('xp_earned', 'امتیاز گرفتی'),
|
('xp_earned', 'امتیاز گرفتی'),
|
||||||
('lesson_completed', 'درس تکمیل شد'),
|
('lesson_completed', 'درس تکمیل شد'),
|
||||||
|
('node_completed', 'جزیره تمام شد'),
|
||||||
('order_paid', 'پرداخت موفق'),
|
('order_paid', 'پرداخت موفق'),
|
||||||
('ticket_replied', 'پاسخ تیکت'),
|
('ticket_replied', 'پاسخ تیکت'),
|
||||||
('family_link_accepted', 'اتصال خانواده تأیید شد'),
|
('family_link_accepted', 'اتصال خانواده تأیید شد'),
|
||||||
|
|||||||
@@ -83,3 +83,24 @@ def resolve_from_lesson(lesson_id) -> dict:
|
|||||||
unit_title, grade_title,
|
unit_title, grade_title,
|
||||||
)
|
)
|
||||||
return {'gradeTitle': 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}
|
||||||
|
|||||||
58
subscription/migrations/0005_seed_yearly_plan.py
Normal file
58
subscription/migrations/0005_seed_yearly_plan.py
Normal file
@@ -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),
|
||||||
|
]
|
||||||
@@ -527,6 +527,9 @@ class PlanCardContractTests(TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
cache.clear()
|
cache.clear()
|
||||||
|
# migrationِ 0005 پلنِ یکساله را میسازد؛ این تستها شکلِ یک ردیفِ
|
||||||
|
# مشخص را میسنجند و باید از جدولِ خالی شروع کنند.
|
||||||
|
Plan.objects.all().delete()
|
||||||
|
|
||||||
def test_plans_carry_their_feature_list_and_badge(self):
|
def test_plans_carry_their_feature_list_and_badge(self):
|
||||||
Plan.objects.create(title='طلایی', price=49000, popular=True,
|
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]
|
row = self.client.get('/api/subscription/plans').json()['plans'][0]
|
||||||
self.assertEqual(row['features'], [])
|
self.assertEqual(row['features'], [])
|
||||||
self.assertEqual(row['badge'], '')
|
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)), ['پلنِ ادمین'])
|
||||||
|
|||||||
Reference in New Issue
Block a user