diff --git a/apps/course/admin/course.py b/apps/course/admin/course.py index dfb409b..04904b8 100644 --- a/apps/course/admin/course.py +++ b/apps/course/admin/course.py @@ -276,8 +276,6 @@ class CourseAdmin(DirectCourseAdmin): radio_fields = { "video_type": admin.HORIZONTAL, - "status": admin.HORIZONTAL, - "level": admin.HORIZONTAL, } conditional_fields = { diff --git a/apps/course/admin/lesson.py b/apps/course/admin/lesson.py index d616cf3..5683499 100644 --- a/apps/course/admin/lesson.py +++ b/apps/course/admin/lesson.py @@ -32,19 +32,9 @@ class CourseLessonForm(forms.ModelForm): class LessonAdmin(ModelAdmin): form = LessonForm list_display = ('title', 'display_duration', 'content_type') - list_filter = ( - ('content_type', ChoicesDropdownFilter), - ) search_fields = ('title',) ordering = ('title',) list_filter_submit = True - radio_fields = { - "content_type": admin.HORIZONTAL, - } - conditional_fields = { - 'content_file': "content_type == 'video_file'", - 'video_link': "content_type == 'youtube_link'", - } fieldsets = ( (None, { @@ -55,20 +45,6 @@ class LessonAdmin(ModelAdmin): }), ) - def get_form(self, request, obj=None, change=False, **kwargs): - form = super().get_form(request, obj, change, **kwargs) - form.base_fields["content_type"].widget = UnfoldAdminRadioSelectWidget( - choices=Lesson.ContentTypeChoices.choices, - radio_style=admin.HORIZONTAL, - attrs={ - "class": "radio-inline flex gap-4 p-2 rounded-lg bg-gray-50 shadow-sm", - "option_class": "flex items-center p-2 rounded-md hover:bg-white hover:shadow-sm transition-all duration-200", - "label_class": "ml-2 font-medium text-gray-700 cursor-pointer", - "input_class": "form-radio h-5 w-5 text-blue-600 transition duration-150 ease-in-out cursor-pointer", - }, - ) - return form - @display(description=_("Duration")) def display_duration(self, obj): return format_html('{} {}', obj.duration, _("min")) diff --git a/apps/course/migrations/0008_remove_course_course_cour_status_57ffd9_idx_and_more.py b/apps/course/migrations/0008_remove_course_course_cour_status_57ffd9_idx_and_more.py new file mode 100644 index 0000000..7f6ca47 --- /dev/null +++ b/apps/course/migrations/0008_remove_course_course_cour_status_57ffd9_idx_and_more.py @@ -0,0 +1,247 @@ +# Generated by Django 5.2.12 on 2026-06-03 15:30 + +import json +from django.db import migrations, models + + +def convert_to_json_format(apps, schema_editor): + Attachment = apps.get_model('course', 'Attachment') + Course = apps.get_model('course', 'Course') + CourseCategory = apps.get_model('course', 'CourseCategory') + CourseChapter = apps.get_model('course', 'CourseChapter') + CourseLesson = apps.get_model('course', 'CourseLesson') + Glossary = apps.get_model('course', 'Glossary') + Lesson = apps.get_model('course', 'Lesson') + + def wrap_val(val): + if not val: + return "[]" + if isinstance(val, str): + stripped = val.strip() + if stripped.startswith('[') and stripped.endswith(']'): + try: + # Already JSON + json.loads(val) + return val + except Exception: + pass + # Wrap as JSON string + return json.dumps([{"title": val, "language_code": "en"}], ensure_ascii=False) + return json.dumps([{"title": str(val), "language_code": "en"}], ensure_ascii=False) + + # 1. Attachment + for obj in Attachment.objects.all(): + obj.title = wrap_val(obj.title) + obj.save() + + # 2. Course + for obj in Course.objects.all(): + obj.title = wrap_val(obj.title) + obj.slug = wrap_val(obj.slug) + obj.level = wrap_val(obj.level) + obj.status = wrap_val(obj.status) + obj.timing = wrap_val(obj.timing) + obj.features = wrap_val(obj.features) + obj.save() + + # 3. CourseCategory + for obj in CourseCategory.objects.all(): + obj.name = wrap_val(obj.name) + obj.slug = wrap_val(obj.slug) + obj.save() + + # 4. CourseChapter + for obj in CourseChapter.objects.all(): + obj.title = wrap_val(obj.title) + obj.save() + + # 5. CourseLesson + for obj in CourseLesson.objects.all(): + obj.title = wrap_val(obj.title) + obj.save() + + # 6. Glossary + for obj in Glossary.objects.all(): + obj.description = wrap_val(obj.description) + obj.save() + + # 7. Lesson + for obj in Lesson.objects.all(): + obj.title = wrap_val(obj.title) + obj.content_type = wrap_val(obj.content_type) + obj.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0007_coursechapter_alter_courselesson_options_and_more'), + ] + + operations = [ + # Remove indexes + migrations.RemoveIndex( + model_name='course', + name='course_cour_status_57ffd9_idx', + ), + migrations.RemoveIndex( + model_name='course', + name='course_cour_slug_235a66_idx', + ), + migrations.RemoveIndex( + model_name='course', + name='course_cour_status_bfcd24_idx', + ), + migrations.RemoveIndex( + model_name='course', + name='course_cour_categor_26bb4d_idx', + ), + migrations.RemoveIndex( + model_name='course', + name='course_cour_profess_5eae9a_idx', + ), + migrations.RemoveIndex( + model_name='lesson', + name='course_less_content_e1cf57_idx', + ), + + # Phase 1: Temporary convert columns to TextField to lift length limits + migrations.AlterField( + model_name='attachment', + name='title', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='course', + name='title', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='course', + name='slug', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='course', + name='level', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='course', + name='status', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='coursecategory', + name='name', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='coursecategory', + name='slug', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='coursechapter', + name='title', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='courselesson', + name='title', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='glossary', + name='description', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='lesson', + name='content_type', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='lesson', + name='title', + field=models.TextField(blank=True, null=True), + ), + + # Phase 2: Run Python conversion logic to format as JSON strings + migrations.RunPython( + convert_to_json_format, + reverse_code=migrations.RunPython.noop + ), + + # Phase 3: Final conversion to JSONField database columns + migrations.AlterField( + model_name='attachment', + name='title', + field=models.JSONField(default=list, verbose_name='Attachment Title'), + ), + migrations.AlterField( + model_name='course', + name='features', + field=models.JSONField(blank=True, default=list, null=True, verbose_name='Course features'), + ), + migrations.AlterField( + model_name='course', + name='level', + field=models.JSONField(blank=True, default=list, null=True, verbose_name='Course Level'), + ), + migrations.AlterField( + model_name='course', + name='slug', + field=models.JSONField(blank=True, default=list, null=True, verbose_name='Slug'), + ), + migrations.AlterField( + model_name='course', + name='status', + field=models.JSONField(blank=True, default=list, null=True, verbose_name='Course Status'), + ), + migrations.AlterField( + model_name='course', + name='timing', + field=models.JSONField(blank=True, default=list, null=True, verbose_name='Timing'), + ), + migrations.AlterField( + model_name='course', + name='title', + field=models.JSONField(default=list, verbose_name='Course Title'), + ), + migrations.AlterField( + model_name='coursecategory', + name='name', + field=models.JSONField(default=list, verbose_name='Category Name'), + ), + migrations.AlterField( + model_name='coursecategory', + name='slug', + field=models.JSONField(blank=True, default=list, null=True, verbose_name='Slug'), + ), + migrations.AlterField( + model_name='coursechapter', + name='title', + field=models.JSONField(default=list, verbose_name='Chapter Title'), + ), + migrations.AlterField( + model_name='courselesson', + name='title', + field=models.JSONField(blank=True, default=list, null=True, verbose_name='Sub-lesson Title'), + ), + migrations.AlterField( + model_name='glossary', + name='description', + field=models.JSONField(default=list, verbose_name='Description'), + ), + migrations.AlterField( + model_name='lesson', + name='content_type', + field=models.JSONField(default=list, verbose_name='Content Type'), + ), + migrations.AlterField( + model_name='lesson', + name='title', + field=models.JSONField(default=list, verbose_name='Lesson Title'), + ), + ] diff --git a/apps/course/models/course.py b/apps/course/models/course.py index dc66e9e..b0bef47 100644 --- a/apps/course/models/course.py +++ b/apps/course/models/course.py @@ -7,9 +7,73 @@ 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 +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): @@ -27,16 +91,22 @@ def course_attachment_file_upload_to(instance, filename): class CourseCategory(models.Model): - name = models.CharField(max_length=255, verbose_name=_('Category Name')) + name = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Category Name')) - slug = models.SlugField(unique=True, max_length=255) + slug = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Slug')) def __str__(self): - return self.name + return extract_text_from_json(self.name) def save(self, *args, **kwargs): - if not self.slug: - self.slug = generate_slug_for_model(CourseCategory, self.name) + 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 @@ -63,8 +133,8 @@ class Course(models.Model): - title = models.CharField(max_length=255, verbose_name=_('Course Title')) - slug = models.SlugField(allow_unicode=True, unique=True) + 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, @@ -89,13 +159,13 @@ class Course(models.Model): 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.CharField(max_length=10, choices=LevelChoices.choices, verbose_name=_('Course Level')) + 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.TextField(verbose_name=_('Course Description')) short_description = models.CharField(max_length=500, blank=True, null=True, verbose_name=_("Short Description")) - status = models.CharField(max_length=15, choices=StatusChoices.choices, default=StatusChoices.INACTIVE, verbose_name=_('Course Status')) + 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')) discount_percentage = models.PositiveIntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(100)], verbose_name=_('Discount Percentage')) @@ -113,14 +183,14 @@ class Course(models.Model): verbose_name=_('Lock Private Chats with Professor') ) - timing = models.JSONField(blank=True, null=True, default=default_timing, verbose_name=_("Timing")) - features = models.JSONField(verbose_name=_('Course features'), default=dict, blank=True, null=True) + 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 self.title + return extract_text_from_json(self.title) def get_completed_lessons_count(self, student): return self.lessons.filter(completions__student=student).count() @@ -147,8 +217,18 @@ class Course(models.Model): def save(self, *args, **kwargs): - if not self.slug: - self.slug = generate_slug_for_model(Course, self.title) + 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) + self.level = format_multilingual_field(self.level) + self.status = format_multilingual_field(self.status) + self.timing = format_multilingual_field(self.timing) + self.features = format_multilingual_field(self.features) # Ensure consistency: if price is 0, set is_free to True and discount_percentage to 0 if self.price == 0: @@ -174,13 +254,8 @@ class Course(models.Model): verbose_name_plural = _("Courses") indexes = [ - models.Index(fields=['status']), models.Index(fields=['is_free']), models.Index(fields=['created_at']), - models.Index(fields=['slug']), - models.Index(fields=['status', 'created_at']), - models.Index(fields=['category', 'status']), - models.Index(fields=['professor', 'status']), ] @@ -189,13 +264,17 @@ class Glossary(models.Model): Base Glossary model that contains the actual content """ title = models.CharField(max_length=555, verbose_name=_('Glossary Title')) - description = models.TextField(verbose_name=_('Description')) + 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") @@ -212,7 +291,7 @@ class CourseGlossary(models.Model): updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) def __str__(self): - return f"{self.course.title} - {self.glossary.title}" + return f"{extract_text_from_json(self.course.title)} - {self.glossary.title}" @property def title(self): @@ -233,7 +312,7 @@ class Attachment(models.Model): """ Base Attachment model that contains the actual file """ - title = models.CharField(max_length=255, verbose_name=_('Attachment Title')) + 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') @@ -243,13 +322,14 @@ class Attachment(models.Model): 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 self.title + return extract_text_from_json(self.title) class Meta: verbose_name = _("Attachment") @@ -267,7 +347,7 @@ class CourseAttachment(models.Model): updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) def __str__(self): - return f"{self.course.title} - {self.attachment.title}" + return f"{extract_text_from_json(self.course.title)} - {extract_text_from_json(self.attachment.title)}" @property def title(self): diff --git a/apps/course/models/lesson.py b/apps/course/models/lesson.py index 15b72a4..adac636 100644 --- a/apps/course/models/lesson.py +++ b/apps/course/models/lesson.py @@ -6,6 +6,7 @@ from filer.fields.image import FilerImageField from filer.fields.file import FilerFileField from apps.account.models import StudentUser +from apps.course.models.course import extract_text_from_json, format_multilingual_field, get_localized_field def lesson_file_upload_to(instance, filename): @@ -21,8 +22,8 @@ class Lesson(models.Model): YOUTUBE_LINK = 'youtube_link', _('Youtube Link') VIDEO_FILE = 'video_file', _('Video File') - title = models.CharField(max_length=255, verbose_name=_('Lesson Title')) - content_type = models.CharField(max_length=50, choices=ContentTypeChoices.choices, verbose_name=_('Content Type')) + title = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Lesson Title')) + content_type = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Content Type')) content_file = models.FileField( null=True, @@ -36,14 +37,18 @@ class Lesson(models.Model): updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) def __str__(self): - return self.title + return extract_text_from_json(self.title) + + def save(self, *args, **kwargs): + self.title = format_multilingual_field(self.title) + self.content_type = format_multilingual_field(self.content_type) + super().save(*args, **kwargs) class Meta: verbose_name = _("Lesson") verbose_name_plural = _("Lessons") indexes = [ - models.Index(fields=['content_type']), models.Index(fields=['created_at']), ] @@ -52,16 +57,17 @@ class CourseChapter(models.Model): V2 Grouping Layer: Groups lessons together into chapters/sections. """ course = models.ForeignKey("course.Course", on_delete=models.CASCADE, related_name='chapters', verbose_name=_('Course')) - title = models.CharField(max_length=255, verbose_name=_('Chapter Title')) + title = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Chapter Title')) priority = models.IntegerField(null=True, blank=True, verbose_name=_('Priority')) is_active = models.BooleanField(default=True, verbose_name=_('Is Active')) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def __str__(self): - return f"{self.course.title} - {self.title}" + return f"{extract_text_from_json(self.course.title)} - {extract_text_from_json(self.title)}" def save(self, *args, **kwargs): + self.title = format_multilingual_field(self.title) if self.priority is None: max_priority = self.course.chapters.aggregate(max_p=models.Max('priority'))['max_p'] self.priority = (max_priority or 0) + 1 @@ -85,7 +91,7 @@ class CourseLesson(models.Model): course = models.ForeignKey("course.Course", on_delete=models.CASCADE, related_name='lessons', verbose_name=_('Course')) chapter = models.ForeignKey(CourseChapter, on_delete=models.CASCADE, related_name='lessons', verbose_name=_('Chapter') , null=True , blank=True) lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='course_lessons', verbose_name=_('Lesson')) - title = models.CharField(max_length=255, verbose_name=_('Sub-lesson Title'), null=True, blank=True) + title = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Sub-lesson Title')) priority = models.IntegerField(null=True, blank=True, verbose_name=_('Priority')) is_active = models.BooleanField(default=True, verbose_name=_('Is Active')) @@ -93,8 +99,8 @@ class CourseLesson(models.Model): updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) def __str__(self): - title = self.title or self.lesson.title - return f"{self.chapter.title} - {title}" + title = extract_text_from_json(self.title) or extract_text_from_json(self.lesson.title) + return f"{extract_text_from_json(self.chapter.title)} - {title}" def is_completed_by(self, student): return self.completions.filter(student=student).exists() @@ -120,9 +126,11 @@ class CourseLesson(models.Model): if self.chapter and getattr(self, 'course_id', None) != self.chapter.course_id: self.course = self.chapter.course - if not self.title: + if not self.title or self.title == []: self.title = self.lesson.title + self.title = format_multilingual_field(self.title) + if self.priority is None: max_priority = self.course.lessons.aggregate(max_priority=models.Max('priority'))['max_priority'] self.priority = (max_priority or 0) + 1 diff --git a/apps/course/serializers/admin.py b/apps/course/serializers/admin.py index 8f540a5..5474599 100644 --- a/apps/course/serializers/admin.py +++ b/apps/course/serializers/admin.py @@ -15,6 +15,7 @@ from apps.course.models import ( LiveSessionRecording ) from apps.account.models import ProfessorUser +from apps.course.models.course import extract_text_from_json class AdminLessonSerializer(serializers.ModelSerializer): class Meta: @@ -40,14 +41,14 @@ def serialize_file(value, serializer): return field.to_representation(value) class AdminCourseCategorySerializer(serializers.ModelSerializer): - slug = serializers.SlugField(required=False, allow_blank=True) + slug = serializers.JSONField(required=False, allow_null=True) class Meta: model = CourseCategory fields = ['id', 'name', 'slug'] class AdminCourseListSerializer(serializers.ModelSerializer): - category_name = serializers.CharField(source='category.name', read_only=True) + category_name = serializers.SerializerMethodField() professor_name = serializers.CharField(source='professor.fullname', read_only=True) participant_count = serializers.SerializerMethodField() thumbnail = serializers.SerializerMethodField() @@ -82,10 +83,13 @@ class AdminCourseListSerializer(serializers.ModelSerializer): def get_thumbnail(self, obj): return get_thumbs(obj.thumbnail, self.context.get('request')) + def get_category_name(self, obj): + return extract_text_from_json(obj.category.name) if obj.category else "" + class AdminCourseDetailSerializer(serializers.ModelSerializer): - slug = serializers.SlugField(required=False, allow_blank=True) - category_name = serializers.CharField(source='category.name', read_only=True) + slug = serializers.JSONField(required=False, allow_null=True) + category_name = serializers.SerializerMethodField() professor_name = serializers.CharField(source='professor.fullname', read_only=True) thumbnail = FileFieldSerializer(required=False, allow_null=True) video_file = FileFieldSerializer(required=False, allow_null=True) @@ -130,6 +134,9 @@ class AdminCourseDetailSerializer(serializers.ModelSerializer): def get_participant_count(self, obj): return obj.participants.count() + def get_category_name(self, obj): + return extract_text_from_json(obj.category.name) if obj.category else "" + class AdminCourseChapterSerializer(serializers.ModelSerializer): class Meta: @@ -143,12 +150,12 @@ class AdminCourseLessonSerializer(serializers.Serializer): course = serializers.IntegerField(required=False, read_only=True) chapter = serializers.IntegerField() lesson = serializers.IntegerField(required=False, write_only=True, allow_null=True) - title = serializers.CharField(max_length=255, required=False, allow_blank=True, allow_null=True) + title = serializers.JSONField(required=False, allow_null=True) priority = serializers.IntegerField(required=False, allow_null=True) is_active = serializers.BooleanField(default=True) # Lesson (Base Content) fields - content_type = serializers.ChoiceField(choices=Lesson.ContentTypeChoices.choices, required=False, allow_null=True) + content_type = serializers.JSONField(required=False, allow_null=True) content_file = FileFieldSerializer(required=False, allow_null=True) video_link = serializers.CharField(max_length=500, required=False, allow_blank=True, allow_null=True) duration = serializers.IntegerField(min_value=0, required=False, default=0) @@ -166,10 +173,10 @@ class AdminCourseLessonSerializer(serializers.Serializer): 'course': instance.course_id, 'chapter': instance.chapter_id, 'lesson': instance.lesson_id, - 'title': instance.title or (lesson_obj.title if lesson_obj else ""), + 'title': instance.title or (lesson_obj.title if lesson_obj else []), 'priority': instance.priority, 'is_active': instance.is_active, - 'content_type': lesson_obj.content_type if lesson_obj else None, + 'content_type': lesson_obj.content_type if lesson_obj else [], 'content_file': serialize_file(lesson_obj.content_file, self) if lesson_obj and lesson_obj.content_file else None, 'video_link': lesson_obj.video_link if lesson_obj else "", 'duration': lesson_obj.duration if lesson_obj else 0, @@ -260,7 +267,7 @@ class AdminCourseAttachmentSerializer(serializers.Serializer): id = serializers.IntegerField(read_only=True) course = serializers.IntegerField() attachment = serializers.IntegerField(required=False, write_only=True, allow_null=True) - title = serializers.CharField(max_length=255, required=False, allow_blank=True, allow_null=True) + title = serializers.JSONField(required=False, allow_null=True) file = FileFieldSerializer(required=False, allow_null=True) file_size = serializers.IntegerField(read_only=True) @@ -270,7 +277,7 @@ class AdminCourseAttachmentSerializer(serializers.Serializer): 'id': instance.id, 'course': instance.course_id, 'attachment': instance.attachment_id, - 'title': attachment_obj.title if attachment_obj else "", + 'title': attachment_obj.title if attachment_obj else [], 'file': serialize_file(attachment_obj.file, self) if attachment_obj and attachment_obj.file else None, 'file_size': attachment_obj.file_size if attachment_obj else 0 } @@ -328,7 +335,7 @@ class AdminCourseGlossarySerializer(serializers.Serializer): course = serializers.IntegerField() glossary = serializers.IntegerField(required=False, write_only=True, allow_null=True) title = serializers.CharField(max_length=555, required=False, allow_blank=True, allow_null=True) - description = serializers.CharField(required=False, allow_blank=True, allow_null=True) + description = serializers.JSONField(required=False, allow_null=True) def to_representation(self, instance): glossary_obj = instance.glossary @@ -337,7 +344,7 @@ class AdminCourseGlossarySerializer(serializers.Serializer): 'course': instance.course_id, 'glossary': instance.glossary_id, 'title': glossary_obj.title if glossary_obj else "", - 'description': glossary_obj.description if glossary_obj else "" + 'description': glossary_obj.description if glossary_obj else [] } def create(self, validated_data): diff --git a/apps/course/serializers/course.py b/apps/course/serializers/course.py index d6ecaa1..43a95eb 100644 --- a/apps/course/serializers/course.py +++ b/apps/course/serializers/course.py @@ -2,6 +2,7 @@ from rest_framework import serializers # from dj_filer.admin import get_thumbs from utils import get_thumbs from apps.course.models import Course, CourseCategory, Attachment, Glossary, LessonCompletion, Participant, Lesson, CourseAttachment, CourseGlossary, CourseLesson +from apps.course.models.course import get_localized_field from apps.chat.models import RoomMessage from apps.account.serializers import UserProfileSerializer @@ -9,11 +10,23 @@ from apps.account.serializers import UserProfileSerializer class CourseCategorySerializer(serializers.ModelSerializer): course_count = serializers.SerializerMethodField() + name = serializers.SerializerMethodField() + slug = serializers.SerializerMethodField() class Meta: model = CourseCategory fields = ['id', 'name', 'slug', 'course_count'] + def _lang(self): + request = self.context.get('request') + return getattr(request, 'LANGUAGE_CODE', None) or 'en' + + def get_name(self, obj): + return get_localized_field(self._lang(), obj.name) + + def get_slug(self, obj): + return get_localized_field(self._lang(), obj.slug) + def get_course_count(self, obj): return obj.course_count @@ -27,6 +40,10 @@ class CourseListSerializer(serializers.ModelSerializer): discount_percentage = serializers.SerializerMethodField() final_price = serializers.SerializerMethodField() is_free = serializers.SerializerMethodField() + title = serializers.SerializerMethodField() + slug = serializers.SerializerMethodField() + level = serializers.SerializerMethodField() + status = serializers.SerializerMethodField() class Meta: model = Course @@ -50,6 +67,22 @@ class CourseListSerializer(serializers.ModelSerializer): 'final_price', ] + def _lang(self): + request = self.context.get('request') + return getattr(request, 'LANGUAGE_CODE', None) or 'en' + + def get_title(self, obj): + return get_localized_field(self._lang(), obj.title) + + def get_slug(self, obj): + return get_localized_field(self._lang(), obj.slug) + + def get_level(self, obj): + return get_localized_field(self._lang(), obj.level) + + def get_status(self, obj): + return get_localized_field(self._lang(), obj.status) + def get_thumbnail(self, obj): return get_thumbs(obj.thumbnail, self.context.get('request')) @@ -105,6 +138,12 @@ class CourseDetailSerializer(serializers.ModelSerializer): final_price = serializers.SerializerMethodField() is_free = serializers.SerializerMethodField() is_professor = serializers.SerializerMethodField() + title = serializers.SerializerMethodField() + slug = serializers.SerializerMethodField() + level = serializers.SerializerMethodField() + status = serializers.SerializerMethodField() + timing = serializers.SerializerMethodField() + features = serializers.SerializerMethodField() class Meta: model = Course @@ -358,6 +397,43 @@ class CourseDetailSerializer(serializers.ModelSerializer): def get_is_free(self, obj): return obj.is_free or obj.price == 0 + def _lang(self): + request = self.context.get('request') + return getattr(request, 'LANGUAGE_CODE', None) or 'en' + + def get_title(self, obj): + return get_localized_field(self._lang(), obj.title) + + def get_slug(self, obj): + return get_localized_field(self._lang(), obj.slug) + + def get_level(self, obj): + return get_localized_field(self._lang(), obj.level) + + def get_status(self, obj): + return get_localized_field(self._lang(), obj.status) + + def get_timing(self, obj): + import json + val = get_localized_field(self._lang(), obj.timing) + if isinstance(val, str): + try: + return json.loads(val) + except Exception: + pass + return val + + def get_features(self, obj): + import json + val = get_localized_field(self._lang(), obj.features) + if isinstance(val, str): + try: + return json.loads(val) + except Exception: + pass + return val + + class MyCourseListSerializer(serializers.ModelSerializer): @@ -365,6 +441,9 @@ class MyCourseListSerializer(serializers.ModelSerializer): thumbnail = serializers.SerializerMethodField() lessons_count = serializers.SerializerMethodField() lessons_complated_count = serializers.SerializerMethodField() + title = serializers.SerializerMethodField() + slug = serializers.SerializerMethodField() + status = serializers.SerializerMethodField() class Meta: model = Course @@ -440,16 +519,38 @@ class MyCourseListSerializer(serializers.ModelSerializer): student=student, course_lesson__course=course ).count() + + def _lang(self): + request = self.context.get('request') + return getattr(request, 'LANGUAGE_CODE', None) or 'en' + + def get_title(self, obj): + return get_localized_field(self._lang(), obj.title) + + def get_slug(self, obj): + return get_localized_field(self._lang(), obj.slug) + + def get_status(self, obj): + return get_localized_field(self._lang(), obj.status) class AttachmentSerializer(serializers.ModelSerializer): + title = serializers.SerializerMethodField() + class Meta: model = Attachment fields = ['id', 'title', 'file', 'file_size'] + def _lang(self): + request = self.context.get('request') + return getattr(request, 'LANGUAGE_CODE', None) or 'en' + + def get_title(self, obj): + return get_localized_field(self._lang(), obj.title) + class CourseAttachmentSerializer(serializers.ModelSerializer): - title = serializers.CharField(source='attachment.title', read_only=True) + title = serializers.SerializerMethodField() file = serializers.FileField(source='attachment.file', read_only=True) file_size = serializers.IntegerField(source='attachment.file_size', read_only=True) @@ -457,17 +558,40 @@ class CourseAttachmentSerializer(serializers.ModelSerializer): model = CourseAttachment fields = ['id', 'title', 'file', 'file_size'] + def _lang(self): + request = self.context.get('request') + return getattr(request, 'LANGUAGE_CODE', None) or 'en' + + def get_title(self, obj): + return get_localized_field(self._lang(), obj.attachment.title) + class GlossarySerializer(serializers.ModelSerializer): + description = serializers.SerializerMethodField() + class Meta: model = Glossary fields = ['id', 'title', 'description'] + def _lang(self): + request = self.context.get('request') + return getattr(request, 'LANGUAGE_CODE', None) or 'en' + + def get_description(self, obj): + return get_localized_field(self._lang(), obj.description) + class CourseGlossarySerializer(serializers.ModelSerializer): title = serializers.CharField(source='glossary.title', read_only=True) - description = serializers.CharField(source='glossary.description', read_only=True) + description = serializers.SerializerMethodField() class Meta: model = CourseGlossary - fields = ['id', 'title', 'description'] \ No newline at end of file + fields = ['id', 'title', 'description'] + + def _lang(self): + request = self.context.get('request') + return getattr(request, 'LANGUAGE_CODE', None) or 'en' + + def get_description(self, obj): + return get_localized_field(self._lang(), obj.glossary.description) \ No newline at end of file diff --git a/apps/course/serializers/lesson.py b/apps/course/serializers/lesson.py index fbb988d..95b1fd8 100644 --- a/apps/course/serializers/lesson.py +++ b/apps/course/serializers/lesson.py @@ -2,20 +2,35 @@ from rest_framework import serializers from apps.course.models import Lesson, CourseLesson, Participant, LessonCompletion from apps.course.access import user_has_course_access from apps.quiz.serializers import QuizListSerializer +from apps.course.models.course import get_localized_field class LessonSerializer(serializers.ModelSerializer): + title = serializers.SerializerMethodField() + content_type = serializers.SerializerMethodField() + class Meta: model = Lesson fields = ['id', 'title', 'content_type', 'content_file', 'video_link', 'duration'] + def _lang(self): + request = self.context.get('request') + return getattr(request, 'LANGUAGE_CODE', None) or 'en' + + def get_title(self, obj): + return get_localized_field(self._lang(), obj.title) + + def get_content_type(self, obj): + return get_localized_field(self._lang(), obj.content_type) + class CourseLessonSerializer(serializers.ModelSerializer): is_active = serializers.SerializerMethodField() is_complated = serializers.SerializerMethodField() quizs = serializers.SerializerMethodField() permission = serializers.SerializerMethodField() - content_type = serializers.CharField(source='lesson.content_type', read_only=True) + title = serializers.SerializerMethodField() + content_type = serializers.SerializerMethodField() content_file = serializers.FileField(source='lesson.content_file', read_only=True) video_link = serializers.CharField(source='lesson.video_link', read_only=True) duration = serializers.IntegerField(source='lesson.duration', read_only=True) @@ -24,6 +39,16 @@ class CourseLessonSerializer(serializers.ModelSerializer): model = CourseLesson fields = ['id', 'title', 'priority', 'is_active', 'permission', 'duration', 'content_type', 'content_file', 'video_link', 'is_complated', 'quizs'] + def _lang(self): + request = self.context.get('request') + return getattr(request, 'LANGUAGE_CODE', None) or 'en' + + def get_title(self, obj): + return get_localized_field(self._lang(), obj.title) + + def get_content_type(self, obj): + return get_localized_field(self._lang(), obj.lesson.content_type) + def get_is_active(self, obj): return obj.is_active and self._has_access_for_object(obj) @@ -93,11 +118,19 @@ class CourseChapterSerializer(serializers.ModelSerializer): """ is_active = serializers.SerializerMethodField() lessons = serializers.SerializerMethodField() + title = serializers.SerializerMethodField() class Meta: model = CourseChapter fields = ['id', 'title', 'priority', 'is_active', 'lessons'] + def _lang(self): + request = self.context.get('request') + return getattr(request, 'LANGUAGE_CODE', None) or 'en' + + def get_title(self, obj): + return get_localized_field(self._lang(), obj.title) + def get_is_active(self, obj): if not obj.is_active: return False diff --git a/apps/course/signals.py b/apps/course/signals.py index 0e1c93f..d98cee8 100644 --- a/apps/course/signals.py +++ b/apps/course/signals.py @@ -1,6 +1,6 @@ from apps.course.models import Course from apps.chat.models import RoomMessage -from apps.course.models.course import Course +from apps.course.models.course import Course, extract_text_from_json from apps.course.models.lesson import CourseLesson from django.db.models import Q @@ -14,10 +14,11 @@ UserModel = get_user_model() @receiver(post_save, sender=Course) def handle_room_message_for_course(sender, instance, created, **kwargs): + title_str = extract_text_from_json(instance.title) if created: # فقط برای موارد جدید اجرا شود RoomMessage.objects.create( - name=f"{instance.title} - Group", - description=f"Group chat for course: {instance.title}", + name=f"{title_str} - Group", + description=f"Group chat for course: {title_str}", initiator=instance.professor, # استاد به‌عنوان سازنده اتاق course=instance, room_type=RoomMessage.RoomTypeChoices.GROUP @@ -28,8 +29,8 @@ def handle_room_message_for_course(sender, instance, created, **kwargs): course=instance, room_type=RoomMessage.RoomTypeChoices.GROUP ).update( - name=f"{instance.title} - Group", - description=f"Group chat for course: {instance.title}", + name=f"{title_str} - Group", + description=f"Group chat for course: {title_str}", initiator=instance.professor ) diff --git a/apps/course/views/admin.py b/apps/course/views/admin.py index 30a798c..b04b19c 100644 --- a/apps/course/views/admin.py +++ b/apps/course/views/admin.py @@ -78,11 +78,11 @@ class AdminCourseViewSet(viewsets.ModelViewSet): level = self.request.query_params.get('level', None) if level: - queryset = queryset.filter(level=level) + queryset = queryset.filter(level__contains=[{"title": level}]) status_param = self.request.query_params.get('status', None) if status_param: - queryset = queryset.filter(status=status_param) + queryset = queryset.filter(status__contains=[{"title": status_param}]) is_free_param = self.request.query_params.get('is_free', None) if is_free_param is not None: @@ -300,7 +300,7 @@ class AdminLessonViewSet(viewsets.ModelViewSet): content_type = self.request.query_params.get('content_type', None) if content_type: - queryset = queryset.filter(content_type=content_type) + queryset = queryset.filter(content_type__contains=[{"title": content_type}]) return queryset diff --git a/apps/course/views/course.py b/apps/course/views/course.py index d95309d..66a31c7 100644 --- a/apps/course/views/course.py +++ b/apps/course/views/course.py @@ -121,7 +121,7 @@ class CourseListAPIView(ListAPIView): queryset = Course.objects.select_related( 'category', 'professor' - ).exclude(status=Course.StatusChoices.INACTIVE) + ).exclude(status__contains=[{"title": Course.StatusChoices.INACTIVE}]) request = self.request filters = request.query_params @@ -129,12 +129,18 @@ class CourseListAPIView(ListAPIView): # Handle category_slug with multiple values separated by commas if category_slugs := filters.get('category_slug'): category_slugs_list = category_slugs.split(',') - queryset = queryset.filter(category__slug__in=category_slugs_list) + category_q = Q() + for slug_item in category_slugs_list: + category_q |= Q(category__slug__contains=[{"title": slug_item}]) + queryset = queryset.filter(category_q) # Handle status with multiple values separated by commas if statuses := filters.get('status'): statuses_list = statuses.split(',') - queryset = queryset.filter(status__in=statuses_list) + status_q = Q() + for status_item in statuses_list: + status_q |= Q(status__contains=[{"title": status_item}]) + queryset = queryset.filter(status_q) if is_free := filters.get('is_free'): is_free = is_free.lower() == 'true' @@ -186,6 +192,16 @@ class CourseDetailAPIView(RetrieveAPIView): 'room_messages' ) + def get_object(self): + queryset = self.filter_queryset(self.get_queryset()) + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + slug_value = self.kwargs[lookup_url_kwarg] + obj = queryset.filter(slug__contains=[{"title": slug_value}]).first() + if not obj: + raise NotFound("Course not found") + self.check_object_permissions(self.request, obj) + return obj + @swagger_auto_schema( operation_description=doc_course_detail(), tags=['Imam-Javad - Course'], @@ -231,7 +247,7 @@ class MyCourseListAPIView(ListAPIView): 'chapters__lessons__lesson', 'chapters__lessons__completions', 'participants__student' - ).exclude(status=Course.StatusChoices.INACTIVE) + ).exclude(status__contains=[{"title": Course.StatusChoices.INACTIVE}]) request = self.request filters = request.query_params @@ -316,9 +332,8 @@ class AttachmentListAPIView(ListAPIView): Optimized queryset with select_related for attachment relationship """ course_slug = self.kwargs.get('slug') - try: - course = Course.objects.get(slug=course_slug) - except Course.DoesNotExist: + course = Course.objects.filter(slug__contains=[{"title": course_slug}]).first() + if not course: raise NotFound("Course not found") return CourseAttachment.objects.select_related( 'course', @@ -368,9 +383,8 @@ class GlossaryListAPIView(ListAPIView): Optimized queryset with select_related for glossary relationship """ course_slug = self.kwargs.get('slug') - try: - course = Course.objects.get(slug=course_slug) - except Course.DoesNotExist: + course = Course.objects.filter(slug__contains=[{"title": course_slug}]).first() + if not course: raise NotFound("Course not found") return CourseGlossary.objects.select_related( @@ -494,7 +508,9 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): detail_view = CourseDetailAPIView() queryset = detail_view.get_queryset() - course = get_object_or_404(queryset, slug=slug) + course = queryset.filter(slug__contains=[{"title": slug}]).first() + if not course: + raise NotFound("Course not found") user = request.user print(f"[Online Validate GET] Course found - course_id={course.id} slug={slug} is_online={course.is_online}") diff --git a/apps/course/views/lesson.py b/apps/course/views/lesson.py index 51e5dae..7055f2e 100644 --- a/apps/course/views/lesson.py +++ b/apps/course/views/lesson.py @@ -39,7 +39,7 @@ class LessonListView(ListAPIView): Optimized queryset with select_related and prefetch_related for lesson relationships """ course_slug = self.kwargs.get('slug') - course = get_object_or_404(Course, slug=course_slug) + course = get_object_or_404(Course, slug__contains=[{"title": course_slug}]) return CourseLesson.objects.select_related( 'chapter', # 👇 Route through chapter @@ -316,7 +316,7 @@ class LessonListV2APIView(ListAPIView): def get_queryset(self): course_slug = self.kwargs.get('slug') - course = get_object_or_404(Course, slug=course_slug) + course = get_object_or_404(Course, slug__contains=[{"title": course_slug}]) # We query the Chapters, and prefetch the lessons to avoid N+1 query problems return CourseChapter.objects.prefetch_related( diff --git a/apps/course/views/live_session.py b/apps/course/views/live_session.py index 78a3ff8..d62c35b 100644 --- a/apps/course/views/live_session.py +++ b/apps/course/views/live_session.py @@ -46,7 +46,7 @@ class CourseLiveSessionRoomCreateAPIView(GenericAPIView): def post(self, request, slug, *args, **kwargs): # 1. Standard Permissions Logic - course = get_object_or_404(Course, slug=slug) + course = get_object_or_404(Course, slug__contains=[{"title": slug}]) if not request.user.can_manage_course(course): raise AppAPIException({'message': 'Permission denied'}, status_code=403) @@ -301,7 +301,9 @@ class CourseLiveSessionTokenAPIView(GenericAPIView): logger.info(f"[LiveSession Token] Request from user_id={user.id} for course={course_slug}") try: - course = Course.objects.get(slug=course_slug) + course = Course.objects.filter(slug__contains=[{"title": course_slug}]).first() + if not course: + raise Course.DoesNotExist except Course.DoesNotExist: logger.warning(f"[LiveSession Token] Course not found - course={course_slug} user_id={user.id}") raise AppAPIException({'message': 'Course not found.'}, status_code=status.HTTP_404_NOT_FOUND) diff --git a/apps/course/views/participant.py b/apps/course/views/participant.py index 841c48d..c48eece 100644 --- a/apps/course/views/participant.py +++ b/apps/course/views/participant.py @@ -32,9 +32,8 @@ class CourseParticipantsView(generics.ListAPIView): for the admin course participants table. """ course_slug = self.kwargs.get('slug') - try: - course = Course.objects.get(slug=course_slug) - except Course.DoesNotExist: + course = Course.objects.filter(slug__contains=[{"title": course_slug}]).first() + if not course: raise AppAPIException({'message': "Course not found"}) return StudentUser.objects.select_related().filter(