diff --git a/accounts/serializers.py b/accounts/serializers.py index fbe4ef9..e5c3f71 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -21,6 +21,18 @@ class SetGradeRequestSerializer(serializers.Serializer): 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): """ ورودی PATCH /api/user/profile — همه‌ی فیلدها اختیاری‌اند و فقط همان‌هایی @@ -35,10 +47,23 @@ class UpdateProfileRequestSerializer(serializers.Serializer): min_value=1300, max_value=1500) parentPhone = serializers.CharField(max_length=15, 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): if not attrs: 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 diff --git a/content/migrations/0006_lesson_duration_minutes_lesson_example_lesson_kind_and_more.py b/content/migrations/0006_lesson_duration_minutes_lesson_example_lesson_kind_and_more.py new file mode 100644 index 0000000..0aff183 --- /dev/null +++ b/content/migrations/0006_lesson_duration_minutes_lesson_example_lesson_kind_and_more.py @@ -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), + ), + ] diff --git a/content/models.py b/content/models.py index 8a9552a..9409600 100644 --- a/content/models.py +++ b/content/models.py @@ -13,6 +13,8 @@ class Grade(models.Model): class Unit(models.Model): grade = models.ForeignKey(Grade, on_delete=models.CASCADE, related_name='units') title = models.CharField(max_length=100) + # یک جمله‌ی کوتاه زیرِ عنوانِ فصل در صفحه‌ی دسته‌بندیِ درس‌ها. + blurb = models.CharField(max_length=120, blank=True) order = models.PositiveIntegerField(default=0) class Meta: @@ -25,6 +27,10 @@ class Unit(models.Model): class Node(models.Model): unit = models.ForeignKey(Unit, on_delete=models.CASCADE, related_name='nodes') 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) class Meta: @@ -35,9 +41,31 @@ class Node(models.Model): class Lesson(models.Model): + """ + یک قدم در مسیرِ یادگیری: یا آزمون است یا ویدیو. + + تا پیش از این فقط آزمون بود و متنِ درس هم یک بلوکِ تخت (`intro_text`). + صفحه‌ی درسِ فرانت سه تکه می‌خواهد — توضیح، مثال، و حلِ گام‌به‌گام — و + نقشه‌ی مهارت هم آیتمِ ویدیویی دارد. هر دو اینجا اضافه شده‌اند و هیچ‌کدام + اجباری نیست، پس محتوای موجود دست‌نخورده کار می‌کند. + """ + + KIND_CHOICES = [('quiz', 'آزمون'), ('video', 'ویدیو')] + node = models.ForeignKey(Node, on_delete=models.CASCADE, related_name='lessons') title = models.CharField(max_length=150) + kind = models.CharField(max_length=10, choices=KIND_CHOICES, default='quiz') + + # متنِ درس، سه‌تکه. intro_text همان «توضیح» است و اسمش عوض نشد تا + # محتوای ساخته‌شده و کدِ موجود نشکند. 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') def __str__(self): diff --git a/content/serializers.py b/content/serializers.py index 486ee5a..fc7b09d 100644 --- a/content/serializers.py +++ b/content/serializers.py @@ -7,9 +7,13 @@ from .models import Grade, QuestionReport # ---- Grades ---- class GradeSerializer(serializers.ModelSerializer): + # بدونِ این، کاربر پایه‌ای را انتخاب می‌کند که هنوز هیچ محتوایی ندارد و + # صفحه‌ی خالی می‌بیند. فرانت با همین می‌تواند آن گزینه را غیرفعال کند. + lessonCount = serializers.IntegerField(read_only=True) + class Meta: model = Grade - fields = ['id', 'title', 'icon', 'color'] + fields = ['id', 'title', 'icon', 'color', 'lessonCount'] class GradesListResponseSerializer(serializers.Serializer): @@ -21,6 +25,10 @@ class GradesListResponseSerializer(serializers.Serializer): class NodeStatusSerializer(serializers.Serializer): id = serializers.CharField() title = serializers.CharField() + # کارتِ مبحث در صفحه‌ی «مباحث» رنگ و آیکون دارد؛ با این دو، فرانت لازم + # نیست رنگ‌ها را خودش هاردکد کند. + icon = serializers.CharField(allow_blank=True) + color = serializers.CharField(allow_blank=True) status = serializers.ChoiceField(choices=['locked', 'unlocked', 'completed']) # شناسه‌ی درس‌های همین گره تا کلاینت بدونه با کلیک روی گره کدوم درس رو # باز کنه (endpointهای lessons/{id}/... مستقیم با همین id کار می‌کنن). @@ -30,6 +38,7 @@ class NodeStatusSerializer(serializers.Serializer): class UnitWithNodesSerializer(serializers.Serializer): id = serializers.CharField() title = serializers.CharField() + blurb = serializers.CharField(allow_blank=True) nodes = NodeStatusSerializer(many=True) @@ -56,6 +65,33 @@ class LessonIntroResponseSerializer(serializers.Serializer): # فیلد فرانت مجبور بود اول questions را بگیرد تا بفهمد چیزی هست یا نه، و # `complete` هم totalQuestions=0 را رد می‌کند (درست است، ولی گیج‌کننده). 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 ---- diff --git a/content/tests_frontend_contract.py b/content/tests_frontend_contract.py new file mode 100644 index 0000000..f5a72da --- /dev/null +++ b/content/tests_frontend_contract.py @@ -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) diff --git a/content/urls.py b/content/urls.py index 9ac21a9..88d7058 100644 --- a/content/urls.py +++ b/content/urls.py @@ -6,6 +6,7 @@ from .views import ( LessonCompleteView, LessonIntroView, LessonQuestionsView, + NodeLessonsView, NodeProgressListView, QuestionAnswerView, QuestionReportView, @@ -19,5 +20,6 @@ urlpatterns = [ path('lessons//complete', LessonCompleteView.as_view(), name='lesson-complete'), path('questions//answer', QuestionAnswerView.as_view(), name='question-answer'), path('questions//report', QuestionReportView.as_view(), name='question-report'), + path('nodes//lessons', NodeLessonsView.as_view(), name='node-lessons'), path('progress/nodes', NodeProgressListView.as_view(), name='node-progress-list'), ] diff --git a/content/views.py b/content/views.py index 117b6a4..c316f1d 100644 --- a/content/views.py +++ b/content/views.py @@ -1,5 +1,6 @@ from django.core.cache import cache from django.db import IntegrityError, transaction +from django.db.models import Count from django.http import Http404 from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes @@ -15,7 +16,7 @@ from progress.models import UserHearts, UserStats from subscription.services import has_unlimited_hearts from .models import ( - Choice, Grade, Lesson, NodeProgress, Question, QuestionReport, UserAnswer, + Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, UserAnswer, UserLessonCompletion, ) from .serializers import ( @@ -27,6 +28,7 @@ from .serializers import ( LessonCompleteResponseSerializer, LessonIntroResponseSerializer, LessonQuestionsResponseSerializer, + NodeLessonsResponseSerializer, NodeProgressListResponseSerializer, QuestionReportRequestSerializer, QuestionReportResponseSerializer, @@ -78,9 +80,15 @@ def _grades_payload() -> dict: key = f'content:grades:v{curriculum_version()}' def build(): + # lessonCount در یک کوئری می‌آید؛ بدونش فرانت نمی‌فهمد کدام پایه + # هنوز خالی است و کاربر صفحه‌ی خالی می‌بیند. + grades = Grade.objects.annotate( + _lessons=Count('units__nodes__lessons', distinct=True), + ).order_by('id') return {'grades': [ - {'id': g.id, 'title': g.title, 'icon': g.icon, 'color': g.color} - for g in Grade.objects.all().order_by('id') + {'id': g.id, 'title': g.title, 'icon': g.icon, 'color': g.color, + 'lessonCount': g._lessons} + for g in grades ]} return cache.get_or_set(key, build, _CACHE_TTL) @@ -106,10 +114,13 @@ def _grade_tree(grade_id: int): { 'id': str(unit.id), 'title': unit.title, + 'blurb': unit.blurb, 'nodes': [ { 'id': str(node.id), 'title': node.title, + 'icon': node.icon, + 'color': node.color, 'lesson_ids': [lsn.id for lsn in node.lessons.all()], } for node in unit.nodes.all() @@ -153,7 +164,11 @@ class HomeView(APIView): @extend_schema( 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, 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: try: grade_id = int(grade_param) @@ -207,10 +228,17 @@ class HomeView(APIView): node_payload.append({ 'id': node['id'], 'title': node['title'], + 'icon': node.get('icon', ''), + 'color': node.get('color', ''), 'status': node_status, '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_payload = { @@ -232,7 +260,12 @@ class LessonIntroView(APIView): data = { 'lessonId': str(lesson.id), 'title': lesson.title, + 'kind': lesson.kind, 'introText': lesson.intro_text, + 'example': lesson.example, + 'steps': lesson.steps or [], + 'videoUrl': lesson.video_url, + 'durationMinutes': lesson.duration_minutes, 'mascotAnimation': lesson.mascot_animation, 'questionCount': lesson.questions.count(), } @@ -589,3 +622,53 @@ class QuestionReportView(APIView): }).data, 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)