صفحههای فرانت خوانده شدند و با 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>
185 lines
7.7 KiB
Python
185 lines
7.7 KiB
Python
from drf_spectacular.utils import extend_schema_field
|
||
from rest_framework import serializers
|
||
|
||
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', 'lessonCount']
|
||
|
||
|
||
class GradesListResponseSerializer(serializers.Serializer):
|
||
grades = GradeSerializer(many=True)
|
||
|
||
|
||
# ---- Home (skill map + stats) ----
|
||
|
||
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 کار میکنن).
|
||
lessonIds = serializers.ListField(child=serializers.IntegerField())
|
||
|
||
|
||
class UnitWithNodesSerializer(serializers.Serializer):
|
||
id = serializers.CharField()
|
||
title = serializers.CharField()
|
||
blurb = serializers.CharField(allow_blank=True)
|
||
nodes = NodeStatusSerializer(many=True)
|
||
|
||
|
||
class StatsSerializer(serializers.Serializer):
|
||
progressPercent = serializers.FloatField()
|
||
xp = serializers.IntegerField()
|
||
coins = serializers.IntegerField()
|
||
|
||
|
||
class HomeResponseSerializer(serializers.Serializer):
|
||
units = UnitWithNodesSerializer(many=True)
|
||
stats = StatsSerializer()
|
||
|
||
|
||
# ---- Lesson intro ----
|
||
|
||
class LessonIntroResponseSerializer(serializers.Serializer):
|
||
lessonId = serializers.CharField()
|
||
title = serializers.CharField()
|
||
introText = serializers.CharField()
|
||
mascotAnimation = serializers.CharField()
|
||
# تعدادِ سوالهای آمادهی این درس. صفر یعنی محتوای آموزشی هست ولی آزمونش
|
||
# هنوز ساخته نشده؛ اپ باید «بهزودی» نشان دهد نه یک کویزِ خالی. بدونِ این
|
||
# فیلد فرانت مجبور بود اول 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 ----
|
||
|
||
class ChoiceOptionSerializer(serializers.Serializer):
|
||
id = serializers.CharField()
|
||
value = serializers.CharField()
|
||
|
||
|
||
class QuestionSerializer(serializers.Serializer):
|
||
id = serializers.CharField()
|
||
type = serializers.CharField()
|
||
prompt = serializers.CharField()
|
||
options = ChoiceOptionSerializer(many=True)
|
||
|
||
|
||
class LessonQuestionsResponseSerializer(serializers.Serializer):
|
||
questions = QuestionSerializer(many=True)
|
||
|
||
|
||
# ---- Answer submission ----
|
||
|
||
class AnswerRequestSerializer(serializers.Serializer):
|
||
selectedOptionId = serializers.IntegerField()
|
||
|
||
|
||
class AnswerResponseSerializer(serializers.Serializer):
|
||
correct = serializers.BooleanField()
|
||
correctOptionId = serializers.CharField()
|
||
xpEarned = serializers.IntegerField()
|
||
heartsRemaining = serializers.IntegerField()
|
||
# حالتِ انیمیشنِ ماسکوت («رُبو») برای فرانت: جوابِ درست → happy، غلط →
|
||
# encourage. همخوان با RobotMood در MathApp_frontend.
|
||
mascotMood = serializers.CharField()
|
||
|
||
|
||
# ---- Lesson completion ----
|
||
|
||
class LessonCompleteRequestSerializer(serializers.Serializer):
|
||
# 🔒 اصلاح امنیتی: قبلاً هیچ محدودیتی روی این دو فیلد نبود؛ کلاینت
|
||
# میتونست correctCount منفی یا totalQuestions صفر/منفی بفرسته.
|
||
# min_value ها فقط شرطِ سادهی «غیرمنفی/مثبت» رو تضمین میکنن؛ چکِ
|
||
# اصلیِ «correctCount نباید از تعدادِ واقعیِ سوالهای درس بیشتر باشه»
|
||
# در LessonCompleteView (جایی که به Lesson دسترسی داریم) انجام میشه.
|
||
correctCount = serializers.IntegerField(min_value=0)
|
||
totalQuestions = serializers.IntegerField(min_value=1)
|
||
|
||
|
||
class LessonCompleteResponseSerializer(serializers.Serializer):
|
||
stars = serializers.IntegerField()
|
||
xpEarned = serializers.IntegerField()
|
||
coinsEarned = serializers.IntegerField()
|
||
stats = StatsSerializer()
|
||
|
||
|
||
# ---- Node progress (تکمیلشده / نصفهرها / در حال انجام) ----
|
||
|
||
class NodeProgressSerializer(serializers.Serializer):
|
||
nodeId = serializers.CharField(source='node_id')
|
||
nodeTitle = serializers.CharField(source='node.title')
|
||
unitId = serializers.CharField(source='node.unit_id')
|
||
status = serializers.ChoiceField(
|
||
choices=['not_started', 'in_progress', 'completed', 'abandoned'],
|
||
)
|
||
lessonsCompleted = serializers.IntegerField(source='lessons_completed_count')
|
||
lessonsTotal = serializers.SerializerMethodField()
|
||
startedAt = serializers.DateTimeField(source='started_at', allow_null=True)
|
||
completedAt = serializers.DateTimeField(source='completed_at', allow_null=True)
|
||
lastActivityAt = serializers.DateTimeField(source='last_activity_at', allow_null=True)
|
||
|
||
@extend_schema_field(serializers.IntegerField)
|
||
def get_lessonsTotal(self, obj):
|
||
return obj.node.lessons.count()
|
||
|
||
|
||
class NodeProgressListResponseSerializer(serializers.Serializer):
|
||
progress = NodeProgressSerializer(many=True)
|
||
|
||
|
||
class QuestionReportRequestSerializer(serializers.Serializer):
|
||
"""دلیل اجباریست تا گزارشها قابلِ دستهبندی باشند؛ توضیح اختیاری."""
|
||
|
||
reason = serializers.ChoiceField(choices=QuestionReport.REASON_CHOICES)
|
||
note = serializers.CharField(required=False, allow_blank=True, max_length=1000)
|
||
|
||
|
||
class QuestionReportResponseSerializer(serializers.Serializer):
|
||
success = serializers.BooleanField()
|
||
reportId = serializers.IntegerField()
|
||
alreadyReported = serializers.BooleanField()
|