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:
2026-09-24 02:49:02 +03:30
parent 0c6b08c9ff
commit b32194497c
21 changed files with 803 additions and 12 deletions

View File

@@ -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

View File

@@ -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()

View 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'])

View File

@@ -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,
}

View 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'], '')

View File

@@ -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
# نمی‌فرسته همون مسیرِ قبلی رو می‌گیره.

View File

@@ -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',

View 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')],
},
),
]

View File

@@ -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).

View File

@@ -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)

View File

@@ -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()

View 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())

View File

@@ -7,6 +7,7 @@ from .views import (
LessonIntroView,
LessonQuestionsView,
NodeLessonsView,
NodeQuizCompleteView,
NodeQuizView,
NodeProgressListView,
QuestionAnswerView,
@@ -23,5 +24,6 @@ urlpatterns = [
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>/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'),
]

View File

@@ -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 — قدم‌های یک مبحث برای نقشه‌ی مهارت.

View File

@@ -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` تا قفل‌های هم‌زمانیِ نوشتن را تحمل کند).

View File

@@ -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` مشخص روی پروداکشن از قبل داده دارد:

View 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),
),
]

View File

@@ -9,6 +9,7 @@ class Notification(models.Model):
TYPE_CHOICES = [
('xp_earned', 'امتیاز گرفتی'),
('lesson_completed', 'درس تکمیل شد'),
('node_completed', 'جزیره تمام شد'),
('order_paid', 'پرداخت موفق'),
('ticket_replied', 'پاسخ تیکت'),
('family_link_accepted', 'اتصال خانواده تأیید شد'),

View File

@@ -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}

View 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),
]

View File

@@ -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)), ['پلنِ ادمین'])