Files
MathApp/content/models.py

180 lines
8.5 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 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}"