The frontend plays node quizzes, but a node only completed once every one of its
8-13 lessons was completed separately, so students were stuck after the first
island of each unit. POST /api/nodes/{id}/quiz/complete now completes the node
(any score; score only sets stars/xp) and returns nextNodeId.
Also closes the other backend gaps found while auditing the frontend:
- /api/ai/ask without lessonId now picks the book from the user's grade instead
of skipping retrieval, so the "ask Pi" tab is grounded in the textbook.
- guest sessions return a refreshToken; they used to go silently 401 after 1h.
- lesson_complete throttle 10/hour -> 120/hour (a lesson here is 1-2 questions).
- progressPercent is per grade, not over all 2,617 lessons of every grade.
- a one-year plan (299,000 toman) is created by migration when no plan exists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
321 lines
16 KiB
Python
321 lines
16 KiB
Python
import re
|
||
|
||
from django.db import models
|
||
|
||
# «پایه هشتم» → ۸. فقط دوازده پایه وجود دارد، پس شمارهی پایه یک کلیدِ طبیعی و
|
||
# پایدار است؛ id هر پایه همین شماره است (پایین، `Grade.save`).
|
||
GRADE_NUMBERS = {
|
||
'اول': 1, 'دوم': 2, 'سوم': 3, 'چهارم': 4, 'پنجم': 5, 'ششم': 6,
|
||
'هفتم': 7, 'هشتم': 8, 'نهم': 9, 'دهم': 10, 'یازدهم': 11, 'دوازدهم': 12,
|
||
}
|
||
_GRADE_TITLE = re.compile(r'^\s*پایه[\sٔ]*(\S+)\s*$')
|
||
|
||
|
||
def grade_number_from_title(title: str):
|
||
"""«پایه نهم» → 9؛ عنوانی که دقیقاً این شکل را ندارد ([DEMO]، [SEED]، …) → None."""
|
||
match = _GRADE_TITLE.match(title or '')
|
||
return GRADE_NUMBERS.get(match.group(1)) if match else None
|
||
|
||
|
||
class Grade(models.Model):
|
||
title = models.CharField(max_length=50)
|
||
icon = models.CharField(max_length=100)
|
||
color = models.CharField(max_length=10)
|
||
# شمارهی پایه (۱ تا ۱۲) و همزمان id آن. بدونِ این، id ترتیبِ واردشدنِ
|
||
# کتابها بود (نهم=۱، هشتم=۵) و همهجا — ادمین، فیلترِ سوالها، API — پایهها
|
||
# نامرتب نمایش داده میشدند.
|
||
number = models.PositiveSmallIntegerField(
|
||
null=True, blank=True, unique=True,
|
||
help_text='شمارهی پایه، ۱ تا ۱۲. خالی بماند از عنوان حدس زده میشود.',
|
||
)
|
||
|
||
class Meta:
|
||
ordering = [models.F('number').asc(nulls_last=True), 'id']
|
||
|
||
def save(self, *args, **kwargs):
|
||
if self.number is None:
|
||
guess = grade_number_from_title(self.title)
|
||
if guess and not Grade.objects.filter(number=guess).exclude(pk=self.pk).exists():
|
||
self.number = guess
|
||
# پایهی تازه id را از شمارهاش میگیرد، نه از شمارندهی دیتابیس؛ وگرنه
|
||
# «پایه چهارم» که بعداً اضافه شود id=13 میگرفت.
|
||
if (self._state.adding and self.pk is None and self.number
|
||
and not Grade.objects.filter(pk=self.number).exists()):
|
||
self.pk = self.number
|
||
super().save(*args, **kwargs)
|
||
|
||
def __str__(self):
|
||
return self.title
|
||
|
||
|
||
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:
|
||
ordering = ['order']
|
||
|
||
def __str__(self):
|
||
return self.title
|
||
|
||
|
||
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:
|
||
ordering = ['order']
|
||
|
||
def __str__(self):
|
||
return self.title
|
||
|
||
|
||
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):
|
||
return self.title
|
||
|
||
|
||
class Question(models.Model):
|
||
TYPE_CHOICES = [('multiple_choice', 'چهارگزینهای'), ('count_objects', 'شمارش اشیاء')]
|
||
|
||
lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='questions')
|
||
type = models.CharField(max_length=30, choices=TYPE_CHOICES)
|
||
prompt = models.TextField()
|
||
asset_group = models.CharField(max_length=100, blank=True)
|
||
|
||
def __str__(self):
|
||
return self.prompt[:50]
|
||
|
||
|
||
class Choice(models.Model):
|
||
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='choices')
|
||
value = models.CharField(max_length=100)
|
||
is_correct = models.BooleanField(default=False)
|
||
|
||
def __str__(self):
|
||
return self.value
|
||
|
||
|
||
class UserLessonCompletion(models.Model):
|
||
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
|
||
lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='completions')
|
||
correct_count = models.PositiveIntegerField()
|
||
stars = models.PositiveSmallIntegerField()
|
||
xp_earned = models.PositiveIntegerField(default=0)
|
||
coins_earned = models.PositiveIntegerField(default=0)
|
||
# تکرار درس مجاز است (فقط بار اول xp/coins واقعی میگیرد)، پس قید یکتایی
|
||
# سخت روی (user_id, lesson) درست نیست. idempotency_key فقط جلوی رکورد
|
||
# تکراریِ ناشی از دابلکلیک/رتری روی همان submit را میگیرد.
|
||
idempotency_key = models.CharField(max_length=100, null=True, blank=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=['user_id', 'lesson', 'idempotency_key'],
|
||
condition=models.Q(idempotency_key__isnull=False) & ~models.Q(idempotency_key=''),
|
||
name='uniq_lesson_completion_idempotency_key',
|
||
),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"completion-user{self.user_id}-lesson{self.lesson_id}"
|
||
|
||
|
||
class UserNodeCompletion(models.Model):
|
||
"""
|
||
پایانِ آزمونِ یک مبحث (جزیرهی روی نقشه) — POST /api/nodes/{id}/quiz/complete.
|
||
|
||
چرا جدا از UserLessonCompletion: اپ در سطحِ مبحث بازی میکند (آزمونِ
|
||
چهارسوالیِ GET /nodes/{id}/quiz) ولی مبحث فقط وقتی «تمام» حساب میشد که
|
||
تکتکِ درسهایش (معمولاً ۸ تا ۱۳ تا) جدا تمام شده باشند. با آزمونِ مبحث
|
||
هیچوقت به آن نمیرسید و جزیرهی بعدی قفل میماند. قاعده عمداً ساده است:
|
||
آزمونِ جزیره که تمام شد، جزیره تمام است و بعدی باز میشود. نمره فقط
|
||
ستاره و امتیاز را تعیین میکند، نه باز شدنِ مسیر.
|
||
"""
|
||
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
|
||
node = models.ForeignKey(Node, on_delete=models.CASCADE, related_name='completions')
|
||
correct_count = models.PositiveIntegerField()
|
||
total_questions = models.PositiveIntegerField()
|
||
stars = models.PositiveSmallIntegerField()
|
||
xp_earned = models.PositiveIntegerField(default=0)
|
||
coins_earned = models.PositiveIntegerField(default=0)
|
||
# تکرارِ آزمون مجاز است (فقط بارِ اول جایزه دارد)؛ کلید فقط جلوی ردیفِ
|
||
# تکراریِ ناشی از دابلکلیک/رتریِ همان submit را میگیرد.
|
||
idempotency_key = models.CharField(max_length=100, null=True, blank=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=['user_id', 'node', 'idempotency_key'],
|
||
condition=models.Q(idempotency_key__isnull=False) & ~models.Q(idempotency_key=''),
|
||
name='uniq_node_completion_idempotency_key',
|
||
),
|
||
]
|
||
indexes = [models.Index(fields=['user_id', 'node'])]
|
||
|
||
def __str__(self):
|
||
return f"node-completion-user{self.user_id}-node{self.node_id}"
|
||
|
||
|
||
class NodeProgress(models.Model):
|
||
"""
|
||
وضعیتِ پیشرفتِ هر کاربر روی هر Node (نه Lesson و نه Unit).
|
||
|
||
چرا سطح Node و نه Lesson/Unit:
|
||
- Lesson خیلی ریزه: هر Lesson خودش با UserLessonCompletion دقیقاً
|
||
«انجامشده / نشده» ثبت میشه؛ مفهوم «نصفهرها» برای یک درسِ چند
|
||
دقیقهای معنی نداره (یا تمومش کرده یا نه).
|
||
- Unit خیلی کلیه (چند Node/چند هفته درس)؛ اگه status روی این سطح
|
||
باشه، کاربری که یک هفته از ۳ هفتهی یک Unit رو غیرفعال بوده هم
|
||
«نصفهرها» میشه که گمراهکنندهست.
|
||
- Node (چند Lesson مرتبط، معادل «مبحث»/skill) دقیقاً سطحیه که
|
||
اپهای مشابه (Duolingo: پیشرفتِ هر skill/unit روی نقشهی مهارت،
|
||
Khan Academy: mastery روی هر topic/skill) روش status نشون
|
||
میدن؛ برای اپِ ریاضیِ ابتدایی هم واحدِ معنادار «مبحث» همینه.
|
||
|
||
استاتوسها (طبق درخواست: تکمیلشده / نصفهرها / در حال انجام؛ + یک
|
||
حالت پایهی چهارم که در اپهای مشابه هم هست):
|
||
- not_started: هنوز هیچ فعالیتی (نه پاسخ به سوال، نه تکمیل درس)
|
||
روی هیچکدوم از Lessonهای این Node نداشته. (معادل 'locked'/
|
||
'unlocked بدون فعالیت' در نقشهی مهارتِ HomeView؛ اون یکی برای
|
||
UI نمایش نقشهست، این یکی برای ردیابی/گزارشگیریه — عمداً جدا
|
||
نگه داشته شده تا منطق قفل/باز نقشه به این وابسته نشه.)
|
||
- in_progress: حداقل یک فعالیت داشته ولی هنوز همهی Lessonهای
|
||
Node تکمیل نشدن.
|
||
- completed: همهی Lessonهای Node تکمیل شدن.
|
||
- abandoned («نصفهرها»): در حال انجام بوده ولی به مدت
|
||
NODE_PROGRESS_ABANDON_DAYS (پیشفرض ۱۴ روز — بلندتر از حالت
|
||
معمولِ اپهای مشابه چون با توجه به شرایط اینترنت/دیتای موبایل در
|
||
ایران، وقفهی چند روزهی کاربر لزوماً به معنی رهاکردنِ درس نیست؛
|
||
تشخیصش با management command زمانبندیشده، نه سیگنال، چون به
|
||
گذر زمانِ بدون رویداد نیاز داره) فعالیتی نداشته. اگه دوباره
|
||
فعالیت کنه، برمیگرده به in_progress.
|
||
"""
|
||
|
||
STATUS_CHOICES = [
|
||
('not_started', 'شروعنشده'),
|
||
('in_progress', 'در حال انجام'),
|
||
('completed', 'تکمیلشده'),
|
||
('abandoned', 'نصفهرها'),
|
||
]
|
||
|
||
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
|
||
node = models.ForeignKey(Node, on_delete=models.CASCADE, related_name='progress_records')
|
||
status = models.CharField(max_length=15, choices=STATUS_CHOICES, default='not_started')
|
||
lessons_completed_count = models.PositiveIntegerField(default=0)
|
||
started_at = models.DateTimeField(null=True, blank=True)
|
||
completed_at = models.DateTimeField(null=True, blank=True)
|
||
# آخرین فعالیت (پاسخ به سوال یا تکمیل درس) روی این Node — مبنای تشخیص
|
||
# abandoned. عمداً بهجای auto_now دستی ست میشه (فقط داخل content/services.py).
|
||
last_activity_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||
|
||
class Meta:
|
||
constraints = [
|
||
models.UniqueConstraint(fields=['user_id', 'node'], name='unique_node_progress_per_user')
|
||
]
|
||
indexes = [
|
||
models.Index(fields=['status', 'last_activity_at']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"node-progress-user{self.user_id}-node{self.node_id}-{self.status}"
|
||
|
||
|
||
class UserAnswer(models.Model):
|
||
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
|
||
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='answers')
|
||
choice = models.ForeignKey(Choice, on_delete=models.CASCADE, related_name='answers')
|
||
correct = models.BooleanField()
|
||
xp_earned = models.PositiveIntegerField(default=0)
|
||
# کلید اختیاری برای idempotency: رتری روی همان submit رکورد تکراری/کسر دوبارهی هارت نمیسازد.
|
||
idempotency_key = models.CharField(max_length=100, null=True, blank=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
constraints = [
|
||
# قید فقط وقتی idempotency_key پر باشد اعمال میشود، تا پاسخهای بدون کلید یکتا نشوند.
|
||
models.UniqueConstraint(
|
||
fields=['user_id', 'question', 'idempotency_key'],
|
||
condition=models.Q(idempotency_key__isnull=False) & ~models.Q(idempotency_key=''),
|
||
name='uniq_user_answer_idempotency_key',
|
||
),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"answer-user{self.user_id}-q{self.question_id}"
|
||
|
||
|
||
class QuestionReport(models.Model):
|
||
"""
|
||
گزارشِ ایراد روی یک سوالِ مشخص.
|
||
|
||
چرا جدا از support.BugReport: آن یکی گزارشِ کلیِ اپ است و به سوال وصل
|
||
نمیشود. تا وقتی گزارش به خودِ سوال نچسبد، کسی که محتوا را نگه میدارد
|
||
نمیفهمد کدام سوال ایراد دارد — باید متنِ گزارشها را میخواند و دستی
|
||
پیدا میکرد.
|
||
|
||
سیگنالِ ضمنی (چند درصد غلط جواب دادهاند) از UserAnswer درمیآید و مدلِ
|
||
جدا لازم ندارد؛ این مدل فقط برای گزارشِ صریحِ کاربر است.
|
||
"""
|
||
|
||
REASON_CHOICES = [
|
||
('wrong_answer', 'گزینهی درست اشتباه است'),
|
||
('unclear', 'صورتِ سوال گنگ است'),
|
||
('typo', 'غلطِ املایی/نگارشی'),
|
||
('too_hard', 'خیلی سخت است'),
|
||
('other', 'موردِ دیگر'),
|
||
]
|
||
STATUS_CHOICES = [('open', 'باز'), ('resolved', 'بررسیشده'), ('rejected', 'رد شد')]
|
||
|
||
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='reports')
|
||
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
|
||
reason = models.CharField(max_length=20, choices=REASON_CHOICES, default='other')
|
||
note = models.TextField(blank=True)
|
||
status = models.CharField(max_length=15, choices=STATUS_CHOICES, default='open', db_index=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
resolved_at = models.DateTimeField(null=True, blank=True)
|
||
|
||
class Meta:
|
||
ordering = ['-created_at']
|
||
indexes = [models.Index(fields=['question', 'status'])]
|
||
|
||
def __str__(self):
|
||
return f'report-{self.pk} on q{self.question_id} ({self.get_reason_display()})'
|