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,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),
]

View File

@@ -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)), ['پلنِ ادمین'])