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

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