serve what the frontend screens actually need

صفحه‌های فرانت خوانده شدند و با API مقایسه شدند. ساختارِ ما از کتابِ واقعی
درآمده و درست‌تر است، پس مدل عوض نشد — ولی چند چیز کم داشت.

مهم‌ترینش یک باگ بود: /api/home پارامتر را `grade` می‌خواند و کلاینتی که به
فرانت فرستادیم `gradeId` می‌فرستد. پارامترِ ناشناخته بی‌صدا نادیده گرفته
می‌شد، یعنی کاربر پایه‌ای را انتخاب می‌کرد و نقشه‌ی پایه‌ی دیگری می‌دید
بدونِ هیچ خطایی. حالا هر دو نام پذیرفته می‌شوند (`gradeId` نامِ درست است،
با بقیه‌ی API یکی). تستِ سرتاسریِ قبلی این را نگرفته بود چون home را بدونِ
آرگومان صدا زده بود.

/api/grades حالا lessonCount می‌دهد. فهرستِ پایه‌های فرانت تا دوازدهم است
ولی محتوای ما تا نهم؛ بدونِ این عدد، کاربر پایه‌ی خالی را انتخاب می‌کند و
صفحه‌ی خالی می‌بیند بی‌آنکه بفهمد چرا.

GET /api/nodes/{id}/lessons اضافه شد. /api/home فقط شناسه‌ی درس‌ها را
می‌دهد، ولی نقشه‌ی مهارت برای هر قدم عنوان، نوع، زیرنویس و «انجام شد» لازم
دارد — بدونش فرانت باید برای هر درس یک intro جدا می‌گرفت.

Lesson حالا kind (آزمون/ویدیو) و متنِ سه‌تکه دارد: توضیح، مثال، حلِ
گام‌به‌گام. ویدیو از قبل سوالِ بازِ محصول بود و فرانت آیتمش را دارد؛ حالا
API می‌تواند بیانش کند، حتی اگر محتوایش بعداً بیاید. Unit یک blurb گرفت و
Node آیکون و رنگ، تا فرانت رنگ‌ها را هاردکد نکند. هیچ‌کدام اجباری نیست، پس
محتوای موجود دست‌نخورده کار می‌کند.

PATCH /api/user/profile حالا age را هم می‌پذیرد (صفحه‌ی ثبت‌نامِ فرانت سن
می‌پرسد). ذخیره‌سازی همچنان birthYear است چون کهنه نمی‌شود؛ این فقط ورودی
را ترجمه می‌کند تا دو طرف حسابِ متفاوت درنیاورند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 01:42:24 +03:30
parent 8effe5b60b
commit 99a0cafe24
7 changed files with 386 additions and 7 deletions

View File

@@ -21,6 +21,18 @@ class SetGradeRequestSerializer(serializers.Serializer):
gradeId = serializers.IntegerField() gradeId = serializers.IntegerField()
def _jalali_year_now() -> int:
"""
سالِ جاریِ شمسی. تبدیلِ سن به سالِ تولد بدونِ این، سالِ میلادی می‌داد و
اعتبارسنجیِ birthYear (بازه‌ی ۱۳۰۰ تا ۱۵۰۰) ردش می‌کرد.
"""
from django.utils import timezone
today = timezone.localdate()
# فروردین ۱ برابرِ ۲۱ مارس است؛ پیش از آن هنوز سالِ شمسیِ قبلی است.
return today.year - 622 if (today.month, today.day) >= (3, 21) else today.year - 623
class UpdateProfileRequestSerializer(serializers.Serializer): class UpdateProfileRequestSerializer(serializers.Serializer):
""" """
ورودی PATCH /api/user/profile — همه‌ی فیلدها اختیاری‌اند و فقط همان‌هایی ورودی PATCH /api/user/profile — همه‌ی فیلدها اختیاری‌اند و فقط همان‌هایی
@@ -35,10 +47,23 @@ class UpdateProfileRequestSerializer(serializers.Serializer):
min_value=1300, max_value=1500) min_value=1300, max_value=1500)
parentPhone = serializers.CharField(max_length=15, required=False, allow_blank=True) parentPhone = serializers.CharField(max_length=15, required=False, allow_blank=True)
bio = serializers.CharField(max_length=200, required=False, allow_blank=True) bio = serializers.CharField(max_length=200, required=False, allow_blank=True)
# صفحه‌ی ثبت‌نامِ فرانت سن می‌پرسد، نه سالِ تولد. ذخیره‌سازی همچنان
# birthYear است چون کهنه نمی‌شود؛ این فقط ورودی را ترجمه می‌کند تا فرانت
# مجبور نباشد خودش تبدیل کند و دو طرف حسابِ متفاوت درنیاورند.
age = serializers.IntegerField(required=False, allow_null=True,
min_value=5, max_value=99, write_only=True)
def validate(self, attrs): def validate(self, attrs):
if not attrs: if not attrs:
raise serializers.ValidationError('حداقل یکی از فیلدها باید ارسال شود.') raise serializers.ValidationError('حداقل یکی از فیلدها باید ارسال شود.')
age = attrs.pop('age', None)
if age is not None:
if attrs.get('birthYear') is not None:
raise serializers.ValidationError(
'یکی از age یا birthYear را بفرستید، نه هر دو را.',
)
attrs['birthYear'] = _jalali_year_now() - age
return attrs return attrs

View File

@@ -0,0 +1,53 @@
# Generated by Django 5.2.17 on 2026-09-13 22:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0005_questionreport'),
]
operations = [
migrations.AddField(
model_name='lesson',
name='duration_minutes',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='lesson',
name='example',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='lesson',
name='kind',
field=models.CharField(choices=[('quiz', 'آزمون'), ('video', 'ویدیو')], default='quiz', max_length=10),
),
migrations.AddField(
model_name='lesson',
name='steps',
field=models.JSONField(blank=True, default=list),
),
migrations.AddField(
model_name='lesson',
name='video_url',
field=models.URLField(blank=True),
),
migrations.AddField(
model_name='node',
name='color',
field=models.CharField(blank=True, max_length=10),
),
migrations.AddField(
model_name='node',
name='icon',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='unit',
name='blurb',
field=models.CharField(blank=True, max_length=120),
),
]

View File

@@ -13,6 +13,8 @@ class Grade(models.Model):
class Unit(models.Model): class Unit(models.Model):
grade = models.ForeignKey(Grade, on_delete=models.CASCADE, related_name='units') grade = models.ForeignKey(Grade, on_delete=models.CASCADE, related_name='units')
title = models.CharField(max_length=100) title = models.CharField(max_length=100)
# یک جمله‌ی کوتاه زیرِ عنوانِ فصل در صفحه‌ی دسته‌بندیِ درس‌ها.
blurb = models.CharField(max_length=120, blank=True)
order = models.PositiveIntegerField(default=0) order = models.PositiveIntegerField(default=0)
class Meta: class Meta:
@@ -25,6 +27,10 @@ class Unit(models.Model):
class Node(models.Model): class Node(models.Model):
unit = models.ForeignKey(Unit, on_delete=models.CASCADE, related_name='nodes') unit = models.ForeignKey(Unit, on_delete=models.CASCADE, related_name='nodes')
title = models.CharField(max_length=150) title = models.CharField(max_length=150)
# مبحث در صفحه‌ی «مباحث» کارتِ رنگی دارد؛ Grade هم همین دو فیلد را دارد،
# پس فرانت لازم نیست رنگ‌ها را خودش هاردکد کند.
icon = models.CharField(max_length=100, blank=True)
color = models.CharField(max_length=10, blank=True)
order = models.PositiveIntegerField(default=0) order = models.PositiveIntegerField(default=0)
class Meta: class Meta:
@@ -35,9 +41,31 @@ class Node(models.Model):
class Lesson(models.Model): class Lesson(models.Model):
"""
یک قدم در مسیرِ یادگیری: یا آزمون است یا ویدیو.
تا پیش از این فقط آزمون بود و متنِ درس هم یک بلوکِ تخت (`intro_text`).
صفحه‌ی درسِ فرانت سه تکه می‌خواهد — توضیح، مثال، و حلِ گام‌به‌گام — و
نقشه‌ی مهارت هم آیتمِ ویدیویی دارد. هر دو اینجا اضافه شده‌اند و هیچ‌کدام
اجباری نیست، پس محتوای موجود دست‌نخورده کار می‌کند.
"""
KIND_CHOICES = [('quiz', 'آزمون'), ('video', 'ویدیو')]
node = models.ForeignKey(Node, on_delete=models.CASCADE, related_name='lessons') node = models.ForeignKey(Node, on_delete=models.CASCADE, related_name='lessons')
title = models.CharField(max_length=150) title = models.CharField(max_length=150)
kind = models.CharField(max_length=10, choices=KIND_CHOICES, default='quiz')
# متنِ درس، سه‌تکه. intro_text همان «توضیح» است و اسمش عوض نشد تا
# محتوای ساخته‌شده و کدِ موجود نشکند.
intro_text = models.TextField() intro_text = models.TextField()
example = models.TextField(blank=True)
steps = models.JSONField(default=list, blank=True)
# فقط برای kind='video'
video_url = models.URLField(blank=True)
duration_minutes = models.PositiveIntegerField(null=True, blank=True)
mascot_animation = models.CharField(max_length=50, default='explain') mascot_animation = models.CharField(max_length=50, default='explain')
def __str__(self): def __str__(self):

View File

@@ -7,9 +7,13 @@ from .models import Grade, QuestionReport
# ---- Grades ---- # ---- Grades ----
class GradeSerializer(serializers.ModelSerializer): class GradeSerializer(serializers.ModelSerializer):
# بدونِ این، کاربر پایه‌ای را انتخاب می‌کند که هنوز هیچ محتوایی ندارد و
# صفحه‌ی خالی می‌بیند. فرانت با همین می‌تواند آن گزینه را غیرفعال کند.
lessonCount = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Grade model = Grade
fields = ['id', 'title', 'icon', 'color'] fields = ['id', 'title', 'icon', 'color', 'lessonCount']
class GradesListResponseSerializer(serializers.Serializer): class GradesListResponseSerializer(serializers.Serializer):
@@ -21,6 +25,10 @@ class GradesListResponseSerializer(serializers.Serializer):
class NodeStatusSerializer(serializers.Serializer): class NodeStatusSerializer(serializers.Serializer):
id = serializers.CharField() id = serializers.CharField()
title = serializers.CharField() title = serializers.CharField()
# کارتِ مبحث در صفحه‌ی «مباحث» رنگ و آیکون دارد؛ با این دو، فرانت لازم
# نیست رنگ‌ها را خودش هاردکد کند.
icon = serializers.CharField(allow_blank=True)
color = serializers.CharField(allow_blank=True)
status = serializers.ChoiceField(choices=['locked', 'unlocked', 'completed']) status = serializers.ChoiceField(choices=['locked', 'unlocked', 'completed'])
# شناسه‌ی درس‌های همین گره تا کلاینت بدونه با کلیک روی گره کدوم درس رو # شناسه‌ی درس‌های همین گره تا کلاینت بدونه با کلیک روی گره کدوم درس رو
# باز کنه (endpointهای lessons/{id}/... مستقیم با همین id کار می‌کنن). # باز کنه (endpointهای lessons/{id}/... مستقیم با همین id کار می‌کنن).
@@ -30,6 +38,7 @@ class NodeStatusSerializer(serializers.Serializer):
class UnitWithNodesSerializer(serializers.Serializer): class UnitWithNodesSerializer(serializers.Serializer):
id = serializers.CharField() id = serializers.CharField()
title = serializers.CharField() title = serializers.CharField()
blurb = serializers.CharField(allow_blank=True)
nodes = NodeStatusSerializer(many=True) nodes = NodeStatusSerializer(many=True)
@@ -56,6 +65,33 @@ class LessonIntroResponseSerializer(serializers.Serializer):
# فیلد فرانت مجبور بود اول questions را بگیرد تا بفهمد چیزی هست یا نه، و # فیلد فرانت مجبور بود اول questions را بگیرد تا بفهمد چیزی هست یا نه، و
# `complete` هم totalQuestions=0 را رد می‌کند (درست است، ولی گیج‌کننده). # `complete` هم totalQuestions=0 را رد می‌کند (درست است، ولی گیج‌کننده).
questionCount = serializers.IntegerField() questionCount = serializers.IntegerField()
# نوعِ قدم در مسیر: آزمون یا ویدیو.
kind = serializers.ChoiceField(choices=['quiz', 'video'])
# متنِ درس سه‌تکه است: توضیح (همان introText)، مثال، و حلِ گام‌به‌گام.
example = serializers.CharField(allow_blank=True)
steps = serializers.ListField(child=serializers.CharField())
# فقط برای kind='video'
videoUrl = serializers.CharField(allow_blank=True)
durationMinutes = serializers.IntegerField(allow_null=True)
# ---- Node lessons (نقشه‌ی مهارت) ----
class NodeLessonSerializer(serializers.Serializer):
"""یک قدم در نقشه‌ی مهارت — همان چیزی که کارتِ درس نشان می‌دهد."""
id = serializers.CharField()
kind = serializers.ChoiceField(choices=['quiz', 'video'])
title = serializers.CharField()
# زیرنویسِ کارت: «۵ سوال» برای آزمون، «۱۲ دقیقه» برای ویدیو.
meta = serializers.CharField(allow_blank=True)
done = serializers.BooleanField()
class NodeLessonsResponseSerializer(serializers.Serializer):
nodeId = serializers.CharField()
title = serializers.CharField()
lessons = NodeLessonSerializer(many=True)
# ---- Lesson questions ---- # ---- Lesson questions ----

View File

@@ -0,0 +1,152 @@
"""
تست‌های قراردادِ API با صفحه‌های فرانت.
این‌ها از روی خودِ کدِ فرانت نوشته شده‌اند، نه از روی حدس: هر صفحه داده‌ی
مشخصی هاردکد داشت و اینجا بررسی می‌شود که API همان شکل را می‌دهد. بدونِ
این‌ها، هر بارِ بعدی که سریالایزری عوض شود، شکستن فقط در مرورگر معلوم می‌شد.
"""
from django.core.cache import cache
from django.test import TestCase
from django.urls import reverse
from accounts.models import User
from content.models import (
Choice, Grade, Lesson, Node, Question, Unit, UserLessonCompletion,
)
class GradePickerTests(TestCase):
"""صفحه‌ی انتخابِ پایه نباید پایه‌ی خالی را مثلِ پایه‌ی پر نشان دهد."""
def setUp(self):
cache.clear()
def test_grades_report_whether_they_have_any_content(self):
full = Grade.objects.create(title='پایه نهم', icon='g9', color='#FF9800')
Grade.objects.create(title='پایه یازدهم', icon='g11', color='#111111')
unit = Unit.objects.create(grade=full, title='فصل ۱', order=1)
node = Node.objects.create(unit=unit, title='درس ۱', order=1)
Lesson.objects.create(node=node, title='نوع ۱', intro_text='x')
rows = {g['title']: g['lessonCount'] for g in self.client.get('/api/grades').json()['grades']}
self.assertEqual(rows['پایه نهم'], 1)
self.assertEqual(rows['پایه یازدهم'], 0)
class SkillMapTests(TestCase):
"""نقشه‌ی مهارت برای هر قدم عنوان، نوع، زیرنویس و «انجام شد» می‌خواهد."""
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
unit = Unit.objects.create(grade=grade, title='فصل ۴', blurb='توان و ریشه', order=4)
self.node = Node.objects.create(unit=unit, title='توان صحیح',
icon='power', color='#ED6956', order=1)
self.quiz = Lesson.objects.create(node=self.node, title='آزمون توان',
intro_text='x', kind='quiz')
self.video = Lesson.objects.create(node=self.node, title='ویدیوی توان',
intro_text='y', kind='video',
video_url='https://example.com/v.mp4',
duration_minutes=12)
q = Question.objects.create(lesson=self.quiz, type='multiple_choice', prompt='؟')
Choice.objects.create(question=q, value='۱', is_correct=True)
self.user = User.objects.create(phone='09120000001')
from accounts.authentication import issue_access_token
token = issue_access_token(self.user.id)
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {token}'}
def _lessons(self, **extra):
url = reverse('node-lessons', args=[self.node.pk])
return self.client.get(url, **extra).json()
def test_each_step_carries_what_the_card_shows(self):
rows = {r['title']: r for r in self._lessons()['lessons']}
self.assertEqual(rows['آزمون توان']['kind'], 'quiz')
self.assertEqual(rows['آزمون توان']['meta'], '1 سوال')
self.assertEqual(rows['ویدیوی توان']['kind'], 'video')
self.assertEqual(rows['ویدیوی توان']['meta'], '12 دقیقه')
def test_done_is_false_for_a_guest_and_true_after_completion(self):
self.assertFalse(any(r['done'] for r in self._lessons()['lessons']))
UserLessonCompletion.objects.create(user_id=self.user.id, lesson=self.quiz,
correct_count=1, stars=3)
rows = {r['title']: r for r in self._lessons(**self.auth)['lessons']}
self.assertTrue(rows['آزمون توان']['done'])
self.assertFalse(rows['ویدیوی توان']['done'])
def test_home_carries_the_visual_fields_the_topic_cards_need(self):
response = self.client.get(f'/api/home?gradeId={self.node.unit.grade_id}',
**self.auth)
unit = response.json()['units'][0]
self.assertEqual(unit['blurb'], 'توان و ریشه')
self.assertEqual(unit['nodes'][0]['icon'], 'power')
self.assertEqual(unit['nodes'][0]['color'], '#ED6956')
class LessonContentTests(TestCase):
"""صفحه‌ی درس سه تکه می‌خواهد: توضیح، مثال، حلِ گام‌به‌گام."""
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
node = Node.objects.create(unit=unit, title='درس ۱', order=1)
self.lesson = Lesson.objects.create(
node=node, title='بردار', intro_text='یک بردار پیکان است.',
example='a = (۳, ۴) → |a| = ۵',
steps=['مؤلفه‌ها را بنویس', 'اندازه را حساب کن'],
)
def test_intro_returns_all_three_parts(self):
data = self.client.get(f'/api/lessons/{self.lesson.pk}/intro').json()
self.assertEqual(data['introText'], 'یک بردار پیکان است.')
self.assertEqual(data['example'], 'a = (۳, ۴) → |a| = ۵')
self.assertEqual(len(data['steps']), 2)
self.assertEqual(data['kind'], 'quiz')
def test_existing_content_without_the_new_fields_still_works(self):
"""محتوای ساخته‌شده example و steps ندارد؛ نباید بشکند."""
bare = Lesson.objects.create(node=self.lesson.node, title='قدیمی', intro_text='z')
data = self.client.get(f'/api/lessons/{bare.pk}/intro').json()
self.assertEqual(data['example'], '')
self.assertEqual(data['steps'], [])
self.assertIsNone(data['durationMinutes'])
class HomeGradeParamTests(TestCase):
"""
کلاینتِ فرانت `?gradeId=` می‌فرستد ولی نسخه‌ی اولِ این اندپوینت `?grade=`
می‌خواند — و پارامترِ ناشناخته بی‌صدا نادیده گرفته می‌شد، یعنی کاربر نقشه‌ی
پایه‌ی دیگری را می‌دید بدونِ هیچ خطایی.
"""
def setUp(self):
cache.clear()
self.a = Grade.objects.create(title='پایه هفتم', icon='', color='')
self.b = Grade.objects.create(title='پایه نهم', icon='', color='')
for grade, unit_title in ((self.a, 'فصلِ هفتم'), (self.b, 'فصلِ نهم')):
unit = Unit.objects.create(grade=grade, title=unit_title, order=1)
node = Node.objects.create(unit=unit, title='درس', order=1)
Lesson.objects.create(node=node, title='نوع', intro_text='x')
user = User.objects.create(phone='09120000002', grade_id=self.a.id)
from accounts.authentication import issue_access_token
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(user.id)}'}
def _titles(self, query=''):
data = self.client.get(f'/api/home{query}', **self.auth).json()
return [u['title'] for u in data['units']]
def test_grade_id_selects_that_grade(self):
self.assertEqual(self._titles(f'?gradeId={self.b.id}'), ['فصلِ نهم'])
def test_the_old_grade_name_still_works(self):
self.assertEqual(self._titles(f'?grade={self.b.id}'), ['فصلِ نهم'])
def test_without_a_parameter_the_users_own_grade_is_used(self):
self.assertEqual(self._titles(), ['فصلِ هفتم'])
def test_an_unknown_grade_is_a_clean_404_not_a_silent_fallback(self):
self.assertEqual(self.client.get('/api/home?gradeId=999999', **self.auth).status_code, 404)

View File

@@ -6,6 +6,7 @@ from .views import (
LessonCompleteView, LessonCompleteView,
LessonIntroView, LessonIntroView,
LessonQuestionsView, LessonQuestionsView,
NodeLessonsView,
NodeProgressListView, NodeProgressListView,
QuestionAnswerView, QuestionAnswerView,
QuestionReportView, QuestionReportView,
@@ -19,5 +20,6 @@ urlpatterns = [
path('lessons/<int:lesson_id>/complete', LessonCompleteView.as_view(), name='lesson-complete'), path('lessons/<int:lesson_id>/complete', LessonCompleteView.as_view(), name='lesson-complete'),
path('questions/<int:question_id>/answer', QuestionAnswerView.as_view(), name='question-answer'), path('questions/<int:question_id>/answer', QuestionAnswerView.as_view(), name='question-answer'),
path('questions/<int:question_id>/report', QuestionReportView.as_view(), name='question-report'), 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('progress/nodes', NodeProgressListView.as_view(), name='node-progress-list'), path('progress/nodes', NodeProgressListView.as_view(), name='node-progress-list'),
] ]

View File

@@ -1,5 +1,6 @@
from django.core.cache import cache from django.core.cache import cache
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.db.models import Count
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
@@ -15,7 +16,7 @@ from progress.models import UserHearts, UserStats
from subscription.services import has_unlimited_hearts from subscription.services import has_unlimited_hearts
from .models import ( from .models import (
Choice, Grade, Lesson, NodeProgress, Question, QuestionReport, UserAnswer, Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, UserAnswer,
UserLessonCompletion, UserLessonCompletion,
) )
from .serializers import ( from .serializers import (
@@ -27,6 +28,7 @@ from .serializers import (
LessonCompleteResponseSerializer, LessonCompleteResponseSerializer,
LessonIntroResponseSerializer, LessonIntroResponseSerializer,
LessonQuestionsResponseSerializer, LessonQuestionsResponseSerializer,
NodeLessonsResponseSerializer,
NodeProgressListResponseSerializer, NodeProgressListResponseSerializer,
QuestionReportRequestSerializer, QuestionReportRequestSerializer,
QuestionReportResponseSerializer, QuestionReportResponseSerializer,
@@ -78,9 +80,15 @@ def _grades_payload() -> dict:
key = f'content:grades:v{curriculum_version()}' key = f'content:grades:v{curriculum_version()}'
def build(): def build():
# lessonCount در یک کوئری می‌آید؛ بدونش فرانت نمی‌فهمد کدام پایه
# هنوز خالی است و کاربر صفحه‌ی خالی می‌بیند.
grades = Grade.objects.annotate(
_lessons=Count('units__nodes__lessons', distinct=True),
).order_by('id')
return {'grades': [ return {'grades': [
{'id': g.id, 'title': g.title, 'icon': g.icon, 'color': g.color} {'id': g.id, 'title': g.title, 'icon': g.icon, 'color': g.color,
for g in Grade.objects.all().order_by('id') 'lessonCount': g._lessons}
for g in grades
]} ]}
return cache.get_or_set(key, build, _CACHE_TTL) return cache.get_or_set(key, build, _CACHE_TTL)
@@ -106,10 +114,13 @@ def _grade_tree(grade_id: int):
{ {
'id': str(unit.id), 'id': str(unit.id),
'title': unit.title, 'title': unit.title,
'blurb': unit.blurb,
'nodes': [ 'nodes': [
{ {
'id': str(node.id), 'id': str(node.id),
'title': node.title, 'title': node.title,
'icon': node.icon,
'color': node.color,
'lesson_ids': [lsn.id for lsn in node.lessons.all()], 'lesson_ids': [lsn.id for lsn in node.lessons.all()],
} }
for node in unit.nodes.all() for node in unit.nodes.all()
@@ -153,7 +164,11 @@ class HomeView(APIView):
@extend_schema( @extend_schema(
parameters=[ parameters=[
OpenApiParameter('grade', OpenApiTypes.INT, required=False), OpenApiParameter('gradeId', OpenApiTypes.INT, required=False,
description='پایه‌ای که نقشه‌اش خواسته می‌شود؛ '
'بدونِ آن، پایه‌ی ذخیره‌شده‌ی خودِ کاربر.'),
OpenApiParameter('grade', OpenApiTypes.INT, required=False,
deprecated=True, description='نامِ قدیمیِ gradeId.'),
], ],
responses=HomeResponseSerializer, responses=HomeResponseSerializer,
tags=['Curriculum (content)'], tags=['Curriculum (content)'],
@@ -165,7 +180,13 @@ class HomeView(APIView):
# خودِ کاربر نادیده می‌ماند؛ نتیجه این بود که اپ در «آشنایی» پایه را ثبت # خودِ کاربر نادیده می‌ماند؛ نتیجه این بود که اپ در «آشنایی» پایه را ثبت
# می‌کرد و بعد صفحه‌ی خانه خالی برمی‌گشت. حالا پارامتر اولویت دارد (برای # می‌کرد و بعد صفحه‌ی خانه خالی برمی‌گشت. حالا پارامتر اولویت دارد (برای
# مرورِ پایه‌ی دیگر) و در نبودش پایه‌ی خودِ کاربر ملاک است. # مرورِ پایه‌ی دیگر) و در نبودش پایه‌ی خودِ کاربر ملاک است.
grade_param = request.query_params.get('grade') # هر دو نام پذیرفته می‌شوند. نامِ درست `gradeId` است — با بقیه‌ی API
# یکی است (profile و /user/grade هم gradeId می‌گویند) — ولی نسخه‌ی
# اولِ این اندپوینت `grade` بود و کلاینت‌هایی با همان بیرون رفته‌اند.
# پذیرفتنِ هر دو ارزان است؛ نپذیرفتن یعنی انتخابِ پایه بی‌صدا نادیده
# گرفته می‌شود و کاربر نقشه‌ی پایه‌ی دیگری را می‌بیند.
grade_param = (request.query_params.get('gradeId')
or request.query_params.get('grade'))
if grade_param: if grade_param:
try: try:
grade_id = int(grade_param) grade_id = int(grade_param)
@@ -207,10 +228,17 @@ class HomeView(APIView):
node_payload.append({ node_payload.append({
'id': node['id'], 'id': node['id'],
'title': node['title'], 'title': node['title'],
'icon': node.get('icon', ''),
'color': node.get('color', ''),
'status': node_status, 'status': node_status,
'lessonIds': node['lesson_ids'], 'lessonIds': node['lesson_ids'],
}) })
units_payload.append({'id': unit['id'], 'title': unit['title'], 'nodes': node_payload}) units_payload.append({
'id': unit['id'],
'title': unit['title'],
'blurb': unit.get('blurb', ''),
'nodes': node_payload,
})
stats = UserStats.objects.filter(user_id=user_id).first() stats = UserStats.objects.filter(user_id=user_id).first()
stats_payload = { stats_payload = {
@@ -232,7 +260,12 @@ class LessonIntroView(APIView):
data = { data = {
'lessonId': str(lesson.id), 'lessonId': str(lesson.id),
'title': lesson.title, 'title': lesson.title,
'kind': lesson.kind,
'introText': lesson.intro_text, 'introText': lesson.intro_text,
'example': lesson.example,
'steps': lesson.steps or [],
'videoUrl': lesson.video_url,
'durationMinutes': lesson.duration_minutes,
'mascotAnimation': lesson.mascot_animation, 'mascotAnimation': lesson.mascot_animation,
'questionCount': lesson.questions.count(), 'questionCount': lesson.questions.count(),
} }
@@ -589,3 +622,53 @@ class QuestionReportView(APIView):
}).data, }).data,
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
) )
class NodeLessonsView(APIView):
"""
GET /api/nodes/{id}/lessons — قدم‌های یک مبحث برای نقشه‌ی مهارت.
چرا لازم بود: /api/home کلِ درخت را می‌دهد ولی فقط شناسه‌ی درس‌ها را؛
صفحه‌ی نقشه‌ی مهارت برای هر قدم عنوان، نوع (آزمون/ویدیو)، زیرنویس و
«انجام شد یا نه» می‌خواهد. بدونِ این، فرانت باید برای هر درس یک
درخواستِ intro جدا می‌زد — یعنی ده‌ها درخواست برای یک صفحه.
"""
throttle_scope = 'read'
throttle_classes = [GuestAwareContentThrottle]
@extend_schema(responses=NodeLessonsResponseSerializer, tags=['Curriculum (content)'])
def get(self, request, node_id: int):
node = get_object_or_404(
Node.objects.prefetch_related('lessons__questions'), pk=node_id,
)
completed = set()
user_id = getattr(request.user, 'id', None)
if user_id is not None:
completed = set(
UserLessonCompletion.objects
.filter(user_id=user_id, lesson__node_id=node_id)
.values_list('lesson_id', flat=True)
)
lessons = []
for lesson in node.lessons.all():
if lesson.kind == 'video':
meta = f'{lesson.duration_minutes} دقیقه' if lesson.duration_minutes else ''
else:
count = lesson.questions.count()
meta = f'{count} سوال' if count else ''
lessons.append({
'id': str(lesson.id),
'kind': lesson.kind,
'title': lesson.title,
'meta': meta,
'done': lesson.id in completed,
})
return Response(NodeLessonsResponseSerializer({
'nodeId': str(node.id),
'title': node.title,
'lessons': lessons,
}).data)