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) # فقط برای 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') 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()})'