پیدا کردنِ «سوالهای فلان مبحث» در فهرستِ پیشفرضِ جنگو عملاً ممکن نبود: باید
بینِ چند صفحهی جدا با دراپداونهای هزارتایی بالا و پایین میرفتی.
/admin/content/question/browse/ چهار ستونِ آبشاری دارد — پایه → فصل → درس →
مبحث — و هر ستون فقط وقتی پر میشود که قبلی انتخاب شده باشد، پس هیچوقت
فهرستِ هزارتایی جلوی چشم نیست. تهاش سوالهای همان مبحث با گزینههایشان
میآیند و از همانجا ویرایش یا اضافه میشوند.
کنارِ هر سوال دو عددِ تصمیمساز: گزارشهای بازِ کاربران، و درصدِ پاسخِ غلط.
درصد زیرِ ۵ پاسخ نشان داده نمیشود — یک نمونهی کوچک سوالِ سالم را
«مشکلدار» جلوه میدهد. همین دو ستون به فهرستِ سوالها هم اضافه شدند.
content.QuestionReport و POST /api/questions/{id}/report تازهاند: تنها
بازخوردی که تا امروز جمع میشد مالِ چتِ دستیار بود، و روی سوالهای مسیرِ
آموزشی هیچ راهی نبود که کاربر بگوید گزینهی درست غلط است. گزارشِ بازِ تکراریِ
همان کاربر ردیفِ تازه نمیسازد تا دابلکلیک آمار را بههم نزند.
وابستگیِ تازهای اضافه نشد: چند اندپوینتِ کوچکِ JSON و JavaScript ساده.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
218 lines
10 KiB
Python
218 lines
10 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}"
|
||
|
||
|
||
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()})'
|