232 lines
8.3 KiB
Python
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}"
|