Files
MathApp/content/models.py

349 lines
19 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
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)
# شکل‌های ثابتِ درس: [{"where": "intro"|"example"|"step", "index": n, "diagram": {...}}].
# یک بار با `gen_lesson_figures` ساخته و با چشم تأیید می‌شود و بعد همین‌جا و در
# curriculum.json می‌ماند؛ هنگامِ نمایش هیچ فراخوانیِ مدل و هزینه‌ای نیست.
# `index` فقط برای where='step' است (از صفر). شکل با `rag.services.diagram.validate` گذشته است.
figures = 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')
# ۳۰۰: یک گزینه‌ی کتاب ۱۲۲ نویسه بود و SQLite طول را اجرا نمی‌کرد؛ Postgres رد می‌کرد.
value = models.CharField(max_length=300)
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()})'
class SeedRecord(models.Model):
"""
چیزی که `seed_content` یک بار از فایل‌های ریپو تحویل داده است (content/seed_sync.py).
چرا: پیش از ۲۰۲۶-۱۰-۰۳ هر دیپلوی هر چه در فایل بود و در دیتابیس پیدا نمی‌شد را دوباره
می‌ساخت. سوالی که ادمین پاک کرده بود برمی‌گشت، سوالی که صورتش را اصلاح کرده بود با صورتِ
قدیم کنارش تکرار می‌شد، فصلی که نامش عوض شده بود با همه‌ی درس‌ها و سوال‌هایش دوباره ساخته
می‌شد، و متنِ توضیحِ درس که در ادمین اصلاح شده بود با متنِ فایل بازنویسی می‌شد. این دفتر
می‌گوید «این را قبلاً داده‌ایم»؛ اگر حالا نیست، کارِ ادمین بوده و دوباره ساخته نمی‌شود.
`value` فقط برای متنِ توضیحِ درس است: هشِ آخرین متنی که سید نوشت. اگر متنِ دیتابیس هنوز
همان است، ادمین دستش نزده و متنِ تازه‌ی فایل جایش می‌نشیند؛ وگرنه متنِ ادمین می‌ماند.
"""
key = models.CharField(max_length=40, unique=True) # sha1 از نوع و مسیرِ عنوان
kind = models.CharField(max_length=10)
value = models.CharField(max_length=40, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f'seed-{self.kind}-{self.key[:8]}'