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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
41
accounts/tests_guest_refresh.py
Normal file
41
accounts/tests_guest_refresh.py
Normal 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'])
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
86
ai_tutor/tests_grade_grounding.py
Normal file
86
ai_tutor/tests_grade_grounding.py
Normal 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'], '')
|
||||
@@ -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
|
||||
# نمیفرسته همون مسیرِ قبلی رو میگیره.
|
||||
|
||||
@@ -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',
|
||||
|
||||
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 — قدمهای یک مبحث برای نقشهی مهارت.
|
||||
|
||||
@@ -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` تا قفلهای همزمانیِ نوشتن را تحمل کند).
|
||||
|
||||
@@ -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` مشخص روی پروداکشن از قبل داده دارد:
|
||||
|
||||
|
||||
18
notifications/migrations/0002_alter_notification_type.py
Normal file
18
notifications/migrations/0002_alter_notification_type.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -9,6 +9,7 @@ class Notification(models.Model):
|
||||
TYPE_CHOICES = [
|
||||
('xp_earned', 'امتیاز گرفتی'),
|
||||
('lesson_completed', 'درس تکمیل شد'),
|
||||
('node_completed', 'جزیره تمام شد'),
|
||||
('order_paid', 'پرداخت موفق'),
|
||||
('ticket_replied', 'پاسخ تیکت'),
|
||||
('family_link_accepted', 'اتصال خانواده تأیید شد'),
|
||||
|
||||
@@ -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}
|
||||
|
||||
58
subscription/migrations/0005_seed_yearly_plan.py
Normal file
58
subscription/migrations/0005_seed_yearly_plan.py
Normal 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),
|
||||
]
|
||||
@@ -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)), ['پلنِ ادمین'])
|
||||
|
||||
Reference in New Issue
Block a user