You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

418 lines
14 KiB

import uuid
import random
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 _
from apps.profiles.models import Institution
class CourseCategory(models.TextChoices):
RAZAVI_TEACHINGS = 'razavi_teachings', _('Razavi Teachings & Lifestyle')
CULTURAL_DIPLOMACY = 'cultural_diplomacy', _('Cultural Diplomacy & Interfaith')
CENTER_MANAGEMENT = 'center_management', _('Center & Mosque Management')
INTERNATIONAL_MEDIA = 'international_media', _('International Media & Content')
SPECIALIZED_LANGUAGES = 'specialized_languages', _('Specialized Languages & Translation')
GENERAL = 'general', _('General Religious Studies')
class CourseLevel(models.TextChoices):
INTRODUCTORY = 'introductory', _('Introductory')
INTERMEDIATE = 'intermediate', _('Intermediate')
ADVANCED = 'advanced', _('Advanced')
class CourseLanguage(models.TextChoices):
FA = 'fa', _('Persian')
AR = 'ar', _('Arabic')
EN = 'en', _('English')
UR = 'ur', _('Urdu')
FR = 'fr', _('French')
class Course(models.Model):
title = models.CharField(max_length=255, verbose_name=_('Course Title'))
slug = models.SlugField(
max_length=280,
unique=True,
blank=True,
null=True,
allow_unicode=True,
verbose_name=_('Slug / URL Identifier')
)
category = models.CharField(
max_length=50,
choices=CourseCategory.choices,
default=CourseCategory.RAZAVI_TEACHINGS,
verbose_name=_('Category')
)
language = models.CharField(
max_length=10,
choices=CourseLanguage.choices,
default=CourseLanguage.EN,
verbose_name=_('Language')
)
level = models.CharField(
max_length=20,
choices=CourseLevel.choices,
default=CourseLevel.INTRODUCTORY,
verbose_name=_('Difficulty Level')
)
cover_image = models.ImageField(
upload_to='lms/covers/%Y/%m/',
blank=True,
null=True,
verbose_name=_('Cover Image')
)
instructor_name = models.CharField(max_length=255, verbose_name=_('Instructor Name'))
instructor_title = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name=_('Instructor Title / Specialty')
)
instructor_avatar = models.ImageField(
upload_to='lms/instructors/%Y/%m/',
blank=True,
null=True,
verbose_name=_('Instructor Avatar')
)
institution = models.ForeignKey(
Institution,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='courses',
verbose_name=_('Partner Institution / Center')
)
organization_name = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name=_('Organization / Academy Name')
)
description = models.TextField(verbose_name=_('Course Description & Syllabus Summary'))
duration_hours = models.FloatField(default=1.0, verbose_name=_('Duration (Hours)'))
rating = models.FloatField(default=5.0, verbose_name=_('Average Rating'))
reviews_count = models.PositiveIntegerField(default=0, verbose_name=_('Reviews Count'))
is_published = models.BooleanField(default=True, verbose_name=_('Is Published'))
is_featured = models.BooleanField(default=False, verbose_name=_('Is Featured'))
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_courses',
verbose_name=_('Created By')
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated At'))
class Meta:
ordering = ('-is_featured', '-created_at')
verbose_name = _('Course')
verbose_name_plural = _('Courses')
def __str__(self):
return f"{self.title} ({self.get_language_display()} - {self.get_level_display()})"
def save(self, *args, **kwargs):
if not self.slug:
base_slug = slugify(self.title, allow_unicode=True) or f"course-{random.randint(1000, 9999)}"
slug = base_slug
counter = 1
while Course.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
super().save(*args, **kwargs)
@property
def total_lessons_count(self):
return Lesson.objects.filter(module__course=self).count()
@property
def total_students_count(self):
return self.enrollments.count()
class CourseModule(models.Model):
course = models.ForeignKey(
Course,
on_delete=models.CASCADE,
related_name='modules',
verbose_name=_('Course')
)
title = models.CharField(max_length=255, verbose_name=_('Module Title'))
description = models.TextField(blank=True, null=True, verbose_name=_('Module Description'))
order = models.IntegerField(default=0, verbose_name=_('Display Order'))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At'))
class Meta:
ordering = ('order', 'id')
verbose_name = _('Course Module')
verbose_name_plural = _('Course Modules')
def __str__(self):
return f"{self.course.title} - Module {self.order + 1}: {self.title}"
class Lesson(models.Model):
class LessonType(models.TextChoices):
VIDEO = 'video', _('Video Lesson')
READING = 'reading', _('Reading & Text')
QUIZ = 'quiz', _('Interactive Quiz')
ASSIGNMENT = 'assignment', _('Practical Assignment')
module = models.ForeignKey(
CourseModule,
on_delete=models.CASCADE,
related_name='lessons',
verbose_name=_('Module')
)
title = models.CharField(max_length=255, verbose_name=_('Lesson Title'))
slug = models.SlugField(
max_length=280,
blank=True,
null=True,
allow_unicode=True,
verbose_name=_('Slug')
)
lesson_type = models.CharField(
max_length=20,
choices=LessonType.choices,
default=LessonType.VIDEO,
verbose_name=_('Lesson Type')
)
duration_minutes = models.PositiveIntegerField(default=15, verbose_name=_('Duration (Minutes)'))
video_url = models.URLField(
max_length=500,
blank=True,
null=True,
verbose_name=_('Video URL (YouTube, Vimeo, HLS, MP4)')
)
content = models.TextField(
blank=True,
null=True,
verbose_name=_('Lesson Content / Notes (Markdown)')
)
slides_attachment = models.FileField(
upload_to='lms/slides/%Y/%m/',
blank=True,
null=True,
verbose_name=_('Slides / PDF Handout')
)
order = models.IntegerField(default=0, verbose_name=_('Display Order'))
is_preview = models.BooleanField(
default=False,
verbose_name=_('Is Free Preview (Public)')
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At'))
class Meta:
ordering = ('order', 'id')
verbose_name = _('Lesson')
verbose_name_plural = _('Lessons')
def __str__(self):
return f"{self.module.course.title} -> {self.title} ({self.get_lesson_type_display()})"
def save(self, *args, **kwargs):
if not self.slug:
base_slug = slugify(self.title, allow_unicode=True) or f"lesson-{random.randint(1000, 9999)}"
self.slug = base_slug
super().save(*args, **kwargs)
class QuizQuestion(models.Model):
lesson = models.ForeignKey(
Lesson,
on_delete=models.CASCADE,
related_name='quiz_questions',
verbose_name=_('Lesson')
)
question_text = models.TextField(verbose_name=_('Question Text'))
options = models.JSONField(
default=list,
verbose_name=_('Options Array'),
help_text=_('List of multiple-choice strings: ["Option A", "Option B", "Option C", "Option D"]')
)
correct_option_index = models.PositiveSmallIntegerField(
default=0,
verbose_name=_('Correct Option Index (0-indexed)')
)
explanation = models.TextField(
blank=True,
null=True,
verbose_name=_('Explanation / Reference for Correct Answer')
)
order = models.IntegerField(default=0, verbose_name=_('Display Order'))
class Meta:
ordering = ('order', 'id')
verbose_name = _('Quiz Question')
verbose_name_plural = _('Quiz Questions')
def __str__(self):
return f"Q{self.order + 1}: {self.question_text[:60]}"
class CourseEnrollment(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='course_enrollments',
verbose_name=_('User')
)
course = models.ForeignKey(
Course,
on_delete=models.CASCADE,
related_name='enrollments',
verbose_name=_('Course')
)
enrolled_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Enrolled At'))
completed_at = models.DateTimeField(null=True, blank=True, verbose_name=_('Completed At'))
progress_percentage = models.PositiveSmallIntegerField(
default=0,
verbose_name=_('Progress Percentage (0-100)')
)
class Meta:
unique_together = ('user', 'course')
ordering = ('-enrolled_at',)
verbose_name = _('Course Enrollment')
verbose_name_plural = _('Course Enrollments')
def __str__(self):
return f"{self.user} enrolled in {self.course.title} ({self.progress_percentage}%)"
def recalculate_progress(self):
"""
Recalculates progress percentage based on completed lessons for this course.
If progress reaches 100%, marks completed_at and issues digital certificate.
"""
all_lesson_ids = Lesson.objects.filter(module__course=self.course).values_list('id', flat=True)
total_count = len(all_lesson_ids)
if total_count == 0:
self.progress_percentage = 100
else:
completed_count = LessonProgress.objects.filter(
user=self.user,
lesson_id__in=all_lesson_ids,
is_completed=True
).count()
self.progress_percentage = int((completed_count / total_count) * 100)
if self.progress_percentage >= 100 and not self.completed_at:
self.completed_at = timezone.now()
# Auto-issue certificate if not already issued
DigitalCertificate.issue_for_enrollment(self)
self.save()
return self.progress_percentage
class LessonProgress(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='lesson_progresses',
verbose_name=_('User')
)
lesson = models.ForeignKey(
Lesson,
on_delete=models.CASCADE,
related_name='user_progresses',
verbose_name=_('Lesson')
)
is_completed = models.BooleanField(default=False, verbose_name=_('Is Completed'))
completed_at = models.DateTimeField(null=True, blank=True, verbose_name=_('Completed At'))
quiz_score = models.FloatField(
null=True,
blank=True,
verbose_name=_('Quiz Score (Percentage)')
)
class Meta:
unique_together = ('user', 'lesson')
verbose_name = _('Lesson Progress')
verbose_name_plural = _('Lesson Progresses')
def __str__(self):
status_str = "Completed" if self.is_completed else "In Progress"
return f"{self.user} - {self.lesson.title}: {status_str}"
class DigitalCertificate(models.Model):
certificate_code = models.CharField(
max_length=100,
unique=True,
verbose_name=_('Verification Code'),
help_text=_('e.g. CERT-RAZAVI-2026-AB12CD')
)
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, verbose_name=_('Unique UUID'))
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='certificates',
verbose_name=_('Student / User')
)
course = models.ForeignKey(
Course,
on_delete=models.CASCADE,
related_name='certificates',
verbose_name=_('Course')
)
issue_date = models.DateField(default=timezone.now, verbose_name=_('Issue Date'))
final_score = models.FloatField(default=100.0, verbose_name=_('Final Score (%)'))
certificate_file = models.FileField(
upload_to='lms/certificates/%Y/%m/',
blank=True,
null=True,
verbose_name=_('Certificate PDF Document')
)
is_revoked = models.BooleanField(default=False, verbose_name=_('Is Revoked'))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At'))
class Meta:
unique_together = ('user', 'course')
ordering = ('-created_at',)
verbose_name = _('Digital Certificate')
verbose_name_plural = _('Digital Certificates')
def __str__(self):
return f"Certificate {self.certificate_code} ({self.user} - {self.course.title})"
@classmethod
def issue_for_enrollment(cls, enrollment):
"""
Creates a new certificate for a finished course enrollment if none exists.
"""
existing = cls.objects.filter(user=enrollment.user, course=enrollment.course).first()
if existing:
return existing
year = timezone.now().year
rand_code = ''.join(random.choices('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', k=6))
code = f"CERT-RAZAVI-{year}-{rand_code}"
# Calculate average quiz score if any quizzes were taken
lesson_ids = Lesson.objects.filter(module__course=enrollment.course).values_list('id', flat=True)
quiz_progresses = LessonProgress.objects.filter(
user=enrollment.user,
lesson_id__in=lesson_ids,
quiz_score__isnull=False
)
if quiz_progresses.exists():
avg_score = sum(p.quiz_score for p in quiz_progresses) / quiz_progresses.count()
else:
avg_score = 100.0
certificate = cls.objects.create(
certificate_code=code,
user=enrollment.user,
course=enrollment.course,
issue_date=timezone.now().date(),
final_score=round(avg_score, 1),
)
return certificate