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', 'number', '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 کار می‌کنن). # ⚠️ عضوِ اول همیشه nextLessonId است؛ بقیه به ترتیبِ کتاب. فرانتِ فعلی با # کلیک روی جزیره lessonIds[0] را باز می‌کند، پس با همین ترتیب هر بار درسِ # بعدی باز می‌شود — پیش‌تر همیشه درسِ اول باز می‌شد و جزیره هرگز تمام نمی‌شد. lessonIds = serializers.ListField( child=serializers.IntegerField(), help_text='عضوِ اول = nextLessonId؛ بقیه به ترتیبِ کتاب.', ) nextLessonId = serializers.IntegerField( allow_null=True, help_text=( 'درسی که با کلیک روی این جزیره باید باز شود: اولین درسِ تمام‌نشده‌ای ' 'که سوال دارد؛ اگر همه تمام شده‌اند، اولین درس (برای مرور).' ), ) 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 QuizQuestionSerializer(serializers.Serializer): """ مثلِ QuestionSerializer است به‌علاوه‌ی اینکه سوال از کدام درس آمده. چرا lessonId و lessonTitle: آزمونِ مبحث از چند درس نمونه برمی‌دارد، و وقتی دانش‌آموز غلط جواب داد باید بشود گفت «این مهارت را مرور کن». بدونِ این دو فیلد، فرانت فقط می‌داند «یک سوال غلط شد» و نمی‌داند کدام مبحثِ ریز. """ id = serializers.CharField() lessonId = serializers.CharField() lessonTitle = serializers.CharField() type = serializers.CharField() prompt = serializers.CharField() options = ChoiceOptionSerializer(many=True) class NodeQuizResponseSerializer(serializers.Serializer): nodeId = serializers.CharField() nodeTitle = serializers.CharField() unitTitle = serializers.CharField() # questionCount ممکن است از requested کمتر باشد؛ مبحث‌هایی هستند که هنوز # سوالِ کافی ندارند و فرانت باید بتواند این را ببیند نه اینکه حدس بزند. questionCount = serializers.IntegerField() requested = serializers.IntegerField() questions = QuizQuestionSerializer(many=True) class NodeQuizCompleteRequestSerializer(serializers.Serializer): # همان شکلِ lessons/{id}/complete تا فرانت یک الگو یاد بگیرد. سقفِ # correctCount در view با تعدادِ واقعیِ سوال‌های مبحث چک می‌شود. correctCount = serializers.IntegerField(min_value=0) totalQuestions = serializers.IntegerField(min_value=1) class NodeQuizCompleteResponseSerializer(serializers.Serializer): nodeId = serializers.CharField() stars = serializers.IntegerField() xpEarned = serializers.IntegerField() coinsEarned = serializers.IntegerField() # جزیره‌ی بعدی که حالا باز است؛ null یعنی این آخرین جزیره‌ی پایه بود. nextNodeId = serializers.CharField(allow_null=True) stats = StatsSerializer() 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()