Files
MathApp/content/models.py
2026-08-19 17:15:55 +03:30

209 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 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):
"""
⚠️ اصلاح نسبت به سند اولیه: دیاگرام معماری فقط فیلد `value` رو برای
Choice مشخص کرده بود و فیلد تشخیص گزینه‌ی درست (is_correct) رو نداشت
(این نکته توی سند models هم صراحتاً به‌عنوان یک ایراد/نقص علامت‌گذاری
شده بود: "یا این فیلد سهواً جا افتاده یا تشخیص جای دیگه‌ای انجام می‌شه").
چون بدون این فیلد امکان پیاده‌سازی POST /api/questions/{id}/answer
(که باید correct را برگردونه) وجود نداره، این فیلد اینجا اضافه شده.
اگه منطق تشخیص پاسخ درست قراره جای دیگه‌ای (سرویس content-authoring)
انجام بشه، این فیلد رو می‌شه بعداً حذف/جابه‌جا کرد.
"""
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)
# ⚠️ افزوده شده برای رفع race condition: طبق طراحیِ فعلی، کاربر مجاز به
# تکرارِ یک درس هست (فقط بارِ اول xp/coins واقعی می‌گیره، نگاه کنید به
# is_first_completion در LessonCompleteView)، پس یک UniqueConstraint
# سختِ (user_id, lesson) درست نیست — جلوی retryِ مشروع رو هم می‌گیره.
# مشکلِ واقعی، دابل‌کلیک/رتریِ شبکه‌ایِ کلاینت روی *همون* submitِ
# تکمیل درسه که می‌تونست دو ردیف برای یک اتفاق بسازه. راه‌حل، همون
# الگوی Idempotency-Key هست که پروژه از قبل در payment/services.py
# برای create_order داره: کلاینت (اختیاری) یک کلید یکتا برای هر submit
# واقعی می‌فرسته و رتری‌ها با همون کلید idempotent می‌شن.
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 در POST /api/questions/{id}/answer:
# قبلاً هیچ مکانیزمی برای تشخیصِ رتریِ شبکه‌ای/کلاینتِ همون submit وجود
# نداشت — یک retry دقیقاً همون درخواست رو دوباره می‌فرستاد و یک ردیفِ
# UserAnswer تکراری می‌ساخت و (در حالت پاسخ غلط) هارت رو دوبار کم
# می‌کرد. دقیقاً همون الگویی که UserLessonCompletion.idempotency_key
# برای LessonCompleteView داره: کلاینت (اختیاری) یک کلید یکتا برای هر
# submit واقعی می‌فرسته و رتری‌ها با همون کلید idempotent می‌شن؛ بدونِ
# کلید (مقدار None)، پاسخ‌های مشروعِ تکراری (کاربر واقعاً چند بار به یک
# سوال پاسخ می‌ده) همچنان مجازن.
idempotency_key = models.CharField(max_length=100, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
# 🔒 قید یکتاییِ سطح DB: به‌اندازه‌ی کافی محدود (user_id +
# question + idempotency_key) تا کاربر و سوال رو از هم تفکیک
# کنه — یعنی همون کلید توسط کاربرِ دیگه یا برای سوالِ دیگه هیچ
# تداخلی نداره. condition تضمین می‌کنه این قید فقط وقتی
# 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}"