from django.conf import settings from django.db import models from django.utils import timezone from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ # Ensure timezone is referenced (used by Course.discount_is_active) _ = _ class Level(models.TextChoices): BEGINNER = "beginner", _("Beginner") INTERMEDIATE = "intermediate", _("Intermediate") ADVANCED = "advanced", _("Advanced") ALL = "all", _("All levels") class CourseStatus(models.TextChoices): DRAFT = "draft", _("Draft") PUBLISHED = "published", _("Published") class LessonKind(models.TextChoices): VIDEO = "video", _("Video") TEXT = "text", _("Text") PDF = "pdf", _("PDF") TASK = "task", _("Task") QUIZ = "quiz", _("Quiz") class LessonStatus(models.TextChoices): DRAFT = "draft", _("Draft") PUBLISHED = "published", _("Published") class Category(models.Model): slug = models.SlugField(_("slug"), max_length=64, unique=True) name = models.CharField(_("name"), max_length=120) order = models.PositiveIntegerField(_("order"), default=0) class Meta: verbose_name = _("category") verbose_name_plural = _("categories") ordering = ["order", "slug"] def __str__(self): return self.name or self.slug class Course(models.Model): instructor = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="courses", limit_choices_to={"role": "teacher"}, ) category = models.ForeignKey( Category, on_delete=models.SET_NULL, related_name="courses", null=True, blank=True, ) slug = models.SlugField(_("slug"), max_length=80, unique=True) title = models.CharField(_("title"), max_length=200) tagline = models.CharField(_("tagline"), max_length=300, blank=True) description = models.TextField(_("description"), blank=True) level = models.CharField( _("level"), max_length=16, choices=Level.choices, default=Level.BEGINNER ) language = models.CharField(_("language"), max_length=8, default="fa") cover_image = models.ImageField(_("cover image"), upload_to="courses/covers/", blank=True, null=True) color = models.CharField(_("color"), max_length=64, blank=True, help_text=_("CSS color token used by the UI.")) is_coming_soon = models.BooleanField( _("coming soon"), default=False, help_text=_("Show a 'coming soon' badge on the catalog (e.g. course is being prepared but is already listed)."), ) price_toman = models.PositiveIntegerField( _("price (Toman)"), null=True, blank=True, help_text=_("Null or 0 = free course."), ) original_price_toman = models.PositiveIntegerField( _("original price (Toman)"), null=True, blank=True, help_text=_("Optional strike-through price for displaying a discount."), ) discount_starts_at = models.DateTimeField( _("discount starts at"), null=True, blank=True, help_text=_("If set together with discount_ends_at, the catalog shows an active discount countdown."), ) discount_ends_at = models.DateTimeField( _("discount ends at"), null=True, blank=True, ) status = models.CharField( _("status"), max_length=16, choices=CourseStatus.choices, default=CourseStatus.DRAFT ) published_at = models.DateTimeField(_("published at"), null=True, blank=True) created_at = models.DateTimeField(_("created at"), auto_now_add=True) updated_at = models.DateTimeField(_("updated at"), auto_now=True) class Meta: verbose_name = _("course") verbose_name_plural = _("courses") ordering = ["-created_at"] indexes = [ models.Index(fields=["instructor", "status"]), models.Index(fields=["status", "-created_at"]), ] def __str__(self): return self.title or self.slug def save(self, *args, **kwargs): if not self.slug: self.slug = self._generate_slug() super().save(*args, **kwargs) def _generate_slug(self) -> str: base = slugify(self.title or "course", allow_unicode=False) or "course" candidate = base[:64] n = 2 while Course.objects.filter(slug=candidate).exclude(pk=self.pk).exists(): suffix = f"-{n}" candidate = (base[: 64 - len(suffix)]) + suffix n += 1 return candidate @property def is_published(self) -> bool: return self.status == CourseStatus.PUBLISHED @property def is_free(self) -> bool: return not self.price_toman @property def discount_is_active(self) -> bool: if not (self.original_price_toman and self.price_toman and self.original_price_toman > self.price_toman): return False now = timezone.now() if self.discount_starts_at and now < self.discount_starts_at: return False if self.discount_ends_at and now >= self.discount_ends_at: return False return True def publish(self): if self.status != CourseStatus.PUBLISHED: self.status = CourseStatus.PUBLISHED self.published_at = self.published_at or timezone.now() self.save(update_fields=["status", "published_at", "updated_at"]) def unpublish(self): if self.status != CourseStatus.DRAFT: self.status = CourseStatus.DRAFT self.save(update_fields=["status", "updated_at"]) class CourseHighlight(models.Model): course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="highlights") text = models.CharField(_("text"), max_length=200) order = models.PositiveIntegerField(_("order"), default=0) class Meta: verbose_name = _("highlight") verbose_name_plural = _("highlights") ordering = ["order", "id"] def __str__(self): return self.text[:60] class CourseFAQ(models.Model): course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="faqs") question = models.CharField(_("question"), max_length=300) answer = models.TextField(_("answer")) order = models.PositiveIntegerField(_("order"), default=0) class Meta: verbose_name = _("FAQ") verbose_name_plural = _("FAQs") ordering = ["order", "id"] def __str__(self): return self.question[:60] class Section(models.Model): course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="sections") title = models.CharField(_("title"), max_length=200) order = models.PositiveIntegerField(_("order"), default=0) class Meta: verbose_name = _("section") verbose_name_plural = _("sections") ordering = ["order", "id"] indexes = [models.Index(fields=["course", "order"])] def __str__(self): return self.title or f"section #{self.pk}" class Lesson(models.Model): section = models.ForeignKey(Section, on_delete=models.CASCADE, related_name="lessons") title = models.CharField(_("title"), max_length=200) kind = models.CharField(_("kind"), max_length=16, choices=LessonKind.choices) body = models.TextField(_("body"), blank=True, help_text=_("Markdown body for text/task lessons.")) video_url = models.URLField(_("video URL"), blank=True, max_length=500) pdf_file = models.FileField(_("PDF"), upload_to="courses/pdfs/", blank=True, null=True) duration_seconds = models.PositiveIntegerField( _("duration (seconds)"), null=True, blank=True ) is_preview = models.BooleanField(_("free preview"), default=False) is_downloadable = models.BooleanField( _("downloadable"), default=False, help_text=_("If true, students can download the asset (mostly for video lessons)."), ) status = models.CharField( _("status"), max_length=16, choices=LessonStatus.choices, default=LessonStatus.DRAFT ) order = models.PositiveIntegerField(_("order"), default=0) created_at = models.DateTimeField(_("created at"), auto_now_add=True) updated_at = models.DateTimeField(_("updated at"), auto_now=True) class Meta: verbose_name = _("lesson") verbose_name_plural = _("lessons") ordering = ["order", "id"] indexes = [models.Index(fields=["section", "order"])] def __str__(self): return self.title or f"lesson #{self.pk}"