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.
 
 
 
 

604 lines
22 KiB

import os
from decimal import Decimal
import math
from django.db import models
from django.db.models import TextChoices
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:
for tr in field_value:
if isinstance(tr, dict) and tr.get('language_code') == lang:
return tr.get('title') or tr.get('text') or tr.get('value') or tr.get('name')
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/{instance.slug}/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/{instance.course.slug}/attachments/{filename}")
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'))
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)
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 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)
if not self.slug or self.slug == []:
try:
self.slug = generate_language_slugs(self.title)
except Exception:
self.slug = []
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)})
en_timings = []
ru_timings = []
fa_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 ''
en_timings.append({
"day": WEEKDAY_TRANSLATIONS.get(day_key, {}).get('en', item.get('day', '')),
"time": time_val
})
ru_timings.append({
"day": WEEKDAY_TRANSLATIONS.get(day_key, {}).get('ru', item.get('day', '')),
"time": time_val
})
fa_timings.append({
"day": WEEKDAY_TRANSLATIONS.get(day_key, {}).get('fa', item.get('day', '')),
"time": time_val
})
self.timing = [
{"language_code": "fa", "title": json.dumps(fa_timings)},
{"language_code": "en", "title": json.dumps(en_timings)},
{"language_code": "ru", "title": json.dumps(ru_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 * self.discount_percentage) / 100
rub_discount_amount = (rub_price * self.discount_percentage) / 100
usd_final_price = usd_price - usd_discount_amount
rub_final_price = rub_price - rub_discount_amount
self.final_price = Decimal(math.ceil(usd_final_price)).quantize(Decimal('0.00'))
self.final_price_rub = Decimal(math.ceil(rub_final_price)).quantize(Decimal('0.00'))
else:
self.final_price = Decimal(math.ceil(usd_price)).quantize(Decimal('0.00'))
self.final_price_rub = Decimal(math.ceil(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']),
]