Files
MathApp/content/serializers.py
Zareian 28b8527760 Security review: staff admin lockdown, unicode-safe login/phone/grade inputs, demo guest not upgradable, home cache not floodable
- accounts admin: phone/is_guest read-only for non-superusers, device_id filter lookups refused,
  auth.User/Group admin writable only by superusers
- campaigns: delivery log needs view permission
- phone/OTP/birth date: any Unicode digit normalised or rejected with 400; unknown gradeId is 400
- shared demo guest is never turned into a person's account by logging in
- /api/home with unknown gradeId is no longer cached (LocMemCache flood reset throttle counters)
- /api/progress/nodes: one query, huge unit is 400
- BazaarCredential token filter lookups refused

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 09:34:42 +03:30

238 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.IntegerField(source='lessons_total')
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)
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()