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:
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
152
content/tests_frontend_contract.py
Normal file
152
content/tests_frontend_contract.py
Normal 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)
|
||||
@@ -6,6 +6,7 @@ from .views import (
|
||||
LessonCompleteView,
|
||||
LessonIntroView,
|
||||
LessonQuestionsView,
|
||||
NodeLessonsView,
|
||||
NodeProgressListView,
|
||||
QuestionAnswerView,
|
||||
QuestionReportView,
|
||||
@@ -19,5 +20,6 @@ urlpatterns = [
|
||||
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>/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'),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user