import os from decimal import Decimal import math from django.db import models 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 = "/static/images/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) 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)}) 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 * 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']), ]