180 lines
8.5 KiB
Python
180 lines
8.5 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)
|
||
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)
|
||
order = models.PositiveIntegerField(default=0)
|
||
|
||
class Meta:
|
||
ordering = ['order']
|
||
|
||
def __str__(self):
|
||
return self.title
|
||
|
||
|
||
class Lesson(models.Model):
|
||
node = models.ForeignKey(Node, on_delete=models.CASCADE, related_name='lessons')
|
||
title = models.CharField(max_length=150)
|
||
intro_text = models.TextField()
|
||
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}"
|