Ilo/apps/courses/models.py
2026-05-02 20:01:30 +03:30

232 lines
8.3 KiB
Python

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}"