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

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