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.
 
 
 
 

702 lines
25 KiB

import os
from decimal import Decimal
import math
from django.db import models
from django.conf import settings
from django.db.models import TextChoices
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from apps.account.models import ProfessorUser
from utils.schema import default_timing
from utils import generate_slug_for_model, generate_language_slugs
from django.core.validators import MinValueValidator, MaxValueValidator
def extract_text_from_json(value):
if not value:
return ""
if isinstance(value, list):
for item in value:
if isinstance(item, dict):
text = item.get('title') or item.get('value') or item.get('text') or item.get('name')
if text:
return str(text)
else:
if item:
return str(item)
return ""
if isinstance(value, dict):
for lang in ("fa", "en", "ru"):
if lang in value and value[lang]:
v = value[lang]
if isinstance(v, dict):
return str(v.get('title') or v.get('value') or v.get('text') or v.get('name') or "")
return str(v)
for v in value.values():
if isinstance(v, dict):
txt = v.get('title') or v.get('value') or v.get('text') or v.get('name')
if txt:
return str(txt)
elif v:
return str(v)
return ""
if isinstance(value, (str, int, float)):
return str(value)
return ""
def is_multilingual_list(value):
if not isinstance(value, list):
return False
if len(value) == 0:
return True
return all(isinstance(item, dict) and 'language_code' in item for item in value)
def format_multilingual_field(value):
if value is None:
return []
if is_multilingual_list(value):
return value
if isinstance(value, dict) and any(k in value for k in ("fa", "en", "ru", "ar", "tr")):
return [{"title": v, "language_code": k} for k, v in value.items() if v]
return [{"title": value, "language_code": "en"}]
def get_localized_field(lang, field_value):
try:
if isinstance(field_value, list) and field_value:
requested_lang = str(lang or "").lower()
fallback_map = {}
for tr in field_value:
if not isinstance(tr, dict):
continue
text = tr.get('title') or tr.get('text') or tr.get('value') or tr.get('name')
language_code = str(tr.get('language_code') or "").lower()
if language_code:
fallback_map[language_code] = text
if language_code == requested_lang and text:
return text
if fallback_map.get('en'):
return fallback_map['en']
if fallback_map.get('ru'):
return fallback_map['ru']
for tr in reversed(field_value):
if isinstance(tr, dict):
text = tr.get('title') or tr.get('text') or tr.get('value') or tr.get('name')
if text:
return text
return extract_text_from_json(field_value)
return extract_text_from_json(field_value)
except Exception as exp:
print(f'---> Error in get_localized_field: {exp}')
return None
def course_file_upload_to(instance, filename):
return os.path.join(f"courses/{get_course_storage_slug(instance)}/videos/{filename}")
def attachment_file_upload_to(instance, filename):
return os.path.join(f"attachments/{filename}")
def course_attachment_file_upload_to(instance, filename):
return os.path.join(
f"courses/{get_course_storage_slug(instance.course)}/attachments/{filename}"
)
def get_course_storage_slug(instance):
slug_source = instance.slug
if isinstance(slug_source, list):
for lang in ("en", "fa", "ru"):
for item in slug_source:
if isinstance(item, dict) and item.get("language_code") == lang:
localized_slug = item.get("title") or item.get("value") or item.get("text") or item.get("name")
safe_slug = slugify(str(localized_slug or ""))
if safe_slug:
return safe_slug
safe_slug = slugify(extract_text_from_json(slug_source))
if safe_slug:
return safe_slug
safe_title = slugify(extract_text_from_json(instance.title))
if safe_title:
return safe_title
if instance.pk:
return f"course-{instance.pk}"
return "course-draft"
class CourseCategory(models.Model):
name = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Category Name'))
slug = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Slug'))
icon = models.CharField(max_length=500, null=True, blank=True, verbose_name=_('Icon URL'))
def __str__(self):
return extract_text_from_json(self.name)
def save(self, *args, **kwargs):
self.name = format_multilingual_field(self.name)
if not self.slug or self.slug == []:
try:
self.slug = generate_language_slugs(self.name)
except Exception:
self.slug = []
else:
self.slug = format_multilingual_field(self.slug)
if not self.icon or str(self.icon).strip() == "":
self.icon = "/media/Frame.svg"
super().save(*args, **kwargs)
@property
def course_count(self):
return self.courses.exclude(status="inactive").count()
LEVEL_TRANSLATIONS = {
'beginner': {
'en': 'Beginner',
'ru': 'Начальный',
},
'mid': {
'en': 'Mid Level',
'ru': 'Средний',
},
'advanced': {
'en': 'Advanced',
'ru': 'Продвинутый',
}
}
STATUS_TRANSLATIONS = {
'inactive': {
'en': 'Inactive',
'ru': 'Неактивен',
},
'upcoming': {
'en': 'Upcoming',
'ru': 'Предстоящий',
},
'registering': {
'en': 'Registering',
'ru': 'Регистрация',
},
'ongoing': {
'en': 'Ongoing',
'ru': 'В процессе',
},
'finished': {
'en': 'Finished',
'ru': 'Завершен',
}
}
CURRENCY_USD = 'USD'
CURRENCY_RUB = 'RUB'
def normalize_level(value):
if not value:
return 'beginner'
text = extract_text_from_json(value).lower().strip()
if 'beginner' in text or 'начальн' in text or 'начинающ' in text:
return 'beginner'
if 'mid' in text or 'средн' in text:
return 'mid'
if 'advanced' in text or 'продвинут' in text:
return 'advanced'
return 'beginner'
def normalize_status(value):
if not value:
return 'inactive'
text = extract_text_from_json(value).lower().strip()
if 'inactive' in text or 'неактив' in text:
return 'inactive'
if 'upcoming' in text or 'предстоящ' in text:
return 'upcoming'
if 'registering' in text or 'регистрац' in text:
return 'registering'
if 'ongoing' in text or 'в процессе' in text:
return 'ongoing'
if 'finished' in text or 'заверш' in text or 'законч' in text:
return 'finished'
return 'inactive'
WEEKDAY_TRANSLATIONS = {
'monday': {
'en': 'Monday',
'ru': 'Понедельник',
'fa': 'دوشنبه',
},
'tuesday': {
'en': 'Tuesday',
'ru': 'Вторник',
'fa': 'سه‌شنبه',
},
'wednesday': {
'en': 'Wednesday',
'ru': 'Среда',
'fa': 'چهارشنبه',
},
'thursday': {
'en': 'Thursday',
'ru': 'Четверг',
'fa': 'پنج‌شنبه',
},
'friday': {
'en': 'Friday',
'ru': 'Пятница',
'fa': 'جمعه',
},
'saturday': {
'en': 'Saturday',
'ru': 'Суббота',
'fa': 'شنبه',
},
'sunday': {
'en': 'Sunday',
'ru': 'Воскресенье',
'fa': 'یک‌شنبه',
}
}
def get_weekday_label(day_key, lang='en'):
normalized_lang = (lang or 'en').lower()
if normalized_lang.startswith('fa'):
lang_key = 'fa'
elif normalized_lang.startswith('ru'):
lang_key = 'ru'
else:
lang_key = 'en'
return WEEKDAY_TRANSLATIONS.get(day_key, {}).get(lang_key, day_key)
def normalize_weekday_to_key(day_value):
if not day_value:
return 'saturday'
txt = str(day_value).lower().strip()
if 'monday' in txt or 'понедельник' in txt or 'دوشنبه' in txt:
return 'monday'
if 'tuesday' in txt or 'вторник' in txt or 'سه شنبه' in txt or 'سه‌شنبه' in txt:
return 'tuesday'
if 'wednesday' in txt or 'среда' in txt or 'چهارشنبه' in txt:
return 'wednesday'
if 'thursday' in txt or 'четверг' in txt or 'پنج شنبه' in txt or 'پنج‌شنبه' in txt:
return 'thursday'
if 'friday' in txt or 'пятница' in txt or 'جمعه' in txt:
return 'friday'
if 'saturday' in txt or 'суббота' in txt or 'شنبه' in txt:
return 'saturday'
if 'sunday' in txt or 'воскресенье' in txt or 'یک شنبه' in txt or 'یک‌شنبه' in txt:
return 'sunday'
return 'saturday'
class Course(models.Model):
class LevelChoices(TextChoices):
BEGINNER = 'beginner', _('Beginner')
MID = 'mid', _('Mid Level')
ADVANCED = 'advanced', _('Advanced')
class StatusChoices(TextChoices):
INACTIVE = 'inactive', _('Inactive') # Not Active (does not show)
UPCOMING = 'upcoming', _('Upcoming') # Upcoming (visible but registration not allowed)-Предстоящие
REGISTERING = 'registering', _('Registering') # Registering (registration is open)-регистрация
ONGOING = 'ongoing', _('Ongoing') # Ongoing (course has started, registration closed)-В процессе
FINISHED = 'finished', _('Finished') # Finished (course has ended)-закончился
class VedioTypeChoices(models.TextChoices):
YOUTUBE_LINK = 'youtube_link', _('Youtube Link')
VIDEO_FILE = 'video_file', _('Video File')
title = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Course Title'))
slug = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Slug'))
category = models.ForeignKey(CourseCategory, on_delete=models.CASCADE, related_name='courses', verbose_name=_('Category'))
professor = models.ForeignKey(
ProfessorUser,
on_delete=models.CASCADE,
related_name="courses",
verbose_name=_("Professor")
)
thumbnail = models.ImageField(upload_to="courses/thumbnails/", verbose_name=_('Thumbnail'))
video_type = models.CharField(
max_length=20,
choices=VedioTypeChoices.choices,
verbose_name=_('Preview Video Type (YouTube Link or File Upload)')
)
video_file = models.FileField(
upload_to=course_file_upload_to,
null=True,
blank=True,
verbose_name=_("Video File")
)
video_link = models.CharField(max_length=500, null=True, blank=True, verbose_name=_("Video Link"))
is_online = models.BooleanField(default=False, verbose_name=_('Is Online Course'))
online_link = models.CharField(max_length=500, null=True, blank=True, verbose_name=_('Online Class Link'))
level = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Course Level'))
duration = models.PositiveIntegerField(verbose_name=_('Duration (in hours)'))
lessons_count = models.PositiveIntegerField(verbose_name=_('Number of Lessons'))
description = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Course Description'))
short_description = models.JSONField(default=list, null=True, blank=True, verbose_name=_("Short Description"))
status = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Course Status'))
is_free = models.BooleanField(default=True, verbose_name=_('Is Free'))
price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name=_('Course Price'))
price_rub = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name=_('Course Price (RUB)'))
discount_percentage = models.PositiveIntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(100)], verbose_name=_('Discount Percentage'))
final_price = models.DecimalField(
verbose_name=_('Course Final Price'), decimal_places=2, max_digits=10, default=0.00, blank=True,
help_text=_('This field is automatically calculated based on the discount percentage.')
)
final_price_rub = models.DecimalField(
verbose_name=_('Course Final Price (RUB)'), decimal_places=2, max_digits=10, default=0.00, blank=True,
help_text=_('This field is automatically calculated based on the discount percentage.')
)
is_group_chat_locked = models.BooleanField(
default=False,
verbose_name=_('Lock Group Chat')
)
is_professor_chat_locked = models.BooleanField(
default=False,
verbose_name=_('Lock Private Chats with Professor')
)
timing = models.JSONField(blank=True, null=True, default=list, verbose_name=_("Timing"))
features = models.JSONField(verbose_name=_('Course features'), default=list, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
def __str__(self):
return extract_text_from_json(self.title)
def get_completed_lessons_count(self, student):
return self.lessons.filter(completions__student=student).count()
def is_student_participant(self, student):
return self.participants.filter(student=student).exists()
@classmethod
def recalculate_lessons_count_for_course(cls, course_id):
if not course_id:
return 0
from apps.course.models.lesson import CourseLesson
lessons_count = CourseLesson.objects.filter(
course_id=course_id,
is_active=True,
).count()
cls.objects.filter(pk=course_id).update(lessons_count=lessons_count)
return lessons_count
def recalculate_lessons_count(self):
return self.__class__.recalculate_lessons_count_for_course(self.pk)
def get_currency_for_language(self, lang):
normalized = (lang or 'en').lower()
if normalized.startswith('ru'):
return CURRENCY_RUB
return CURRENCY_USD
def get_price_for_language(self, lang):
if self.is_free:
return Decimal('0.00')
if self.get_currency_for_language(lang) == CURRENCY_RUB:
return Decimal(self.price_rub or 0)
return Decimal(self.price or 0)
def get_final_price_for_language(self, lang):
if self.is_free:
return Decimal('0.00')
if self.get_currency_for_language(lang) == CURRENCY_RUB:
return Decimal(self.final_price_rub or 0)
return Decimal(self.final_price or 0)
def save(self, *args, **kwargs):
self.title = format_multilingual_field(self.title)
# Check if the title has changed (or if this is a new course)
title_changed = False
if not self.pk:
title_changed = True
else:
try:
original = self.__class__.objects.get(pk=self.pk)
title_changed = original.title != self.title
except self.__class__.DoesNotExist:
title_changed = True
if title_changed:
# Extract the English title
english_title = ""
if isinstance(self.title, list):
for tr in self.title:
if isinstance(tr, dict) and tr.get("language_code") == "en":
english_title = tr.get("title") or tr.get("text") or tr.get("value") or ""
break
# Fallback to any non-empty title if English title isn't found
if not english_title:
for tr in self.title:
if isinstance(tr, dict):
english_title = tr.get("title") or tr.get("text") or tr.get("value") or ""
if english_title:
break
base_slug = slugify(str(english_title or ""))
if not base_slug:
base_slug = "course"
val = base_slug
counter = 1
# Resolve global uniqueness collision (across other courses)
while True:
qs = self.__class__.objects.filter(slug__contains=[{"title": val}])
if self.pk:
qs = qs.exclude(pk=self.pk)
if qs.exists():
val = f"{base_slug}-{counter}"
counter += 1
else:
break
self.slug = [{"language_code": "en", "title": val}]
else:
self.slug = format_multilingual_field(self.slug)
level_key = normalize_level(self.level)
self.level = [
{"title": LEVEL_TRANSLATIONS[level_key]["en"], "language_code": "en"},
{"title": LEVEL_TRANSLATIONS[level_key]["ru"], "language_code": "ru"},
]
status_key = normalize_status(self.status)
self.status = [
{"title": STATUS_TRANSLATIONS[status_key]["en"], "language_code": "en"},
{"title": STATUS_TRANSLATIONS[status_key]["ru"], "language_code": "ru"},
]
self.description = format_multilingual_field(self.description)
self.short_description = format_multilingual_field(self.short_description)
# Process timing (translate weekdays)
import json
raw_timings = []
val = self.timing
if val:
if isinstance(val, str):
try:
val = json.loads(val)
except Exception:
pass
if isinstance(val, list) and all(isinstance(x, dict) and 'language_code' in x for x in val):
# We can extract the timing list from any language item
extracted = []
for x in val:
title_val = x.get('title')
if title_val:
if isinstance(title_val, str):
try:
title_val = json.loads(title_val)
except Exception:
pass
if isinstance(title_val, list):
extracted = title_val
break
raw_timings = extracted
elif isinstance(val, list):
raw_timings = val
elif isinstance(val, dict):
raw_timings = []
for k, v in val.items():
raw_timings.append({"day": k, "time": str(v)})
normalized_timings = []
for item in raw_timings:
if isinstance(item, dict):
day_key = normalize_weekday_to_key(item.get('day'))
time_val = item.get('time') or item.get('timing') or ''
normalized_timings.append({
"day": day_key,
"time": str(time_val).strip(),
})
self.timing = normalized_timings
self.features = format_multilingual_field(self.features)
usd_price = Decimal(self.price or 0)
rub_price = Decimal(self.price_rub or 0)
# Ensure consistency: if both prices are 0, set is_free to True and discount_percentage to 0
if usd_price == 0 and rub_price == 0:
self.is_free = True
self.discount_percentage = 0
self.final_price = Decimal('0.00')
self.final_price_rub = Decimal('0.00')
elif self.is_free:
self.price = Decimal('0.00')
self.price_rub = Decimal('0.00')
self.discount_percentage = 0
self.final_price = Decimal('0.00')
self.final_price_rub = Decimal('0.00')
elif self.discount_percentage > 0:
usd_discount_amount = (usd_price * Decimal(self.discount_percentage)) / Decimal('100')
rub_discount_amount = (rub_price * Decimal(self.discount_percentage)) / Decimal('100')
usd_final_price = usd_price - usd_discount_amount
rub_final_price = rub_price - rub_discount_amount
self.final_price = usd_final_price.quantize(Decimal('0.00'))
self.final_price_rub = rub_final_price.quantize(Decimal('0.00'))
else:
self.final_price = usd_price.quantize(Decimal('0.00'))
self.final_price_rub = rub_price.quantize(Decimal('0.00'))
super().save(*args, **kwargs)
class Meta:
verbose_name = _("Course")
verbose_name_plural = _("Courses")
indexes = [
models.Index(fields=['is_free']),
models.Index(fields=['created_at']),
]
class Glossary(models.Model):
"""
Base Glossary model that contains the actual content
"""
title = models.CharField(max_length=555, verbose_name=_('Glossary Title'))
description = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Description'))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
def __str__(self):
return self.title
def save(self, *args, **kwargs):
self.description = format_multilingual_field(self.description)
super().save(*args, **kwargs)
class Meta:
verbose_name = _("Glossary")
verbose_name_plural = _("Glossaries")
class CourseGlossary(models.Model):
"""
Intermediate model that connects Course with Glossary
"""
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='glossaries', verbose_name=_('Course'))
glossary = models.ForeignKey(Glossary, on_delete=models.CASCADE, related_name='course_glossaries', verbose_name=_('Glossary'))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
def __str__(self):
return f"{extract_text_from_json(self.course.title)} - {self.glossary.title}"
@property
def title(self):
return self.glossary.title
@property
def description(self):
return self.glossary.description
class Meta:
ordering = ("-id",)
verbose_name = _("Course Glossary")
verbose_name_plural = _("Course Glossaries")
class Attachment(models.Model):
"""
Base Attachment model that contains the actual file
"""
title = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Attachment Title'))
file = models.FileField(
upload_to=attachment_file_upload_to,
verbose_name=_('Attachment File')
)
file_size = models.PositiveIntegerField(verbose_name=_('File Size (in bytes)'), null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
def save(self, *args, **kwargs):
self.title = format_multilingual_field(self.title)
# Calculate the file size before saving
if self.file and not self.file_size:
self.file_size = self.file.size
super().save(*args, **kwargs)
def __str__(self):
return extract_text_from_json(self.title)
class Meta:
verbose_name = _("Attachment")
verbose_name_plural = _("Attachments")
class CourseAttachment(models.Model):
"""
Intermediate model that connects Course with Attachment
"""
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='attachments', verbose_name=_('Course'))
attachment = models.ForeignKey(Attachment, on_delete=models.CASCADE, related_name='course_attachments', verbose_name=_('Attachment'))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
def __str__(self):
return f"{extract_text_from_json(self.course.title)} - {extract_text_from_json(self.attachment.title)}"
@property
def title(self):
return self.attachment.title
@property
def file(self):
return self.attachment.file
@property
def file_size(self):
return self.attachment.file_size
class Meta:
ordering = ("-id",)
verbose_name = _("Course Attachment")
verbose_name_plural = _("Course Attachments")
indexes = [
models.Index(fields=['course']),
models.Index(fields=['attachment']),
]