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:
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}"
|
||||
|
||||
|
||||
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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
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,
|
||||
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'),
|
||||
]
|
||||
|
||||
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