صفحههای فرانت خوانده شدند و با 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>
246 lines
12 KiB
Python
246 lines
12 KiB
Python
from django.db import models
|
||
|
||
|
||
class Grade(models.Model):
|
||
title = models.CharField(max_length=50)
|
||
icon = models.CharField(max_length=100)
|
||
color = models.CharField(max_length=10)
|
||
|
||
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 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()})'
|