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:
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 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 — قدمهای یک مبحث برای نقشهی مهارت.
|
||||
|
||||
Reference in New Issue
Block a user