diff --git a/apps/account/templates/account/json_editor_field.html b/apps/account/templates/account/json_editor_field.html index 55532db..db0c6cc 100644 --- a/apps/account/templates/account/json_editor_field.html +++ b/apps/account/templates/account/json_editor_field.html @@ -312,7 +312,7 @@ /* Modern JSON Editor Container - Unfold theme */ .json-editor-container { margin-bottom: 1.5rem; - background-color: #fff; + background-color: #0C0B1D; border-radius: 0.5rem; overflow: hidden; width: 100%; diff --git a/apps/api/views.py b/apps/api/views.py index 7ddd4a3..6d8a986 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -17,23 +17,13 @@ class HomeView(GenericAPIView): serializer_class = HomeSerializer def get(self, request): - emails = ["zahra@gmail.com", "john.doe@example.com", "alice@example.com"] - phone_numbers = ["09012037621", "09012037615", "09012045432"] - fullnames = ["Alireza", "John Doe", "Alice Smith"] - # انتخاب رندوم از هر لیست - email = random.choice(emails) - phone_number = random.choice(phone_numbers) - fullname = random.choice(fullnames) - # ساخت کاربر جدید - user = User.objects.create( - email=email, - phone_number=phone_number, - fullname=fullname, - ) - # ایجاد توکن برای کاربر - token, created = Token.objects.get_or_create(user=user) + # Get build_number from headers + build_number = request.META.get('HTTP_BUILD_NUMBER') - return Response({'token': token.key}) + # Print the build_number + print(f"Build Number: {build_number}") + + return Response({'token': "ok", 'build_number': build_number}) class CountryView(GenericAPIView): diff --git a/apps/course/admin/course.py b/apps/course/admin/course.py index 71a4656..7718d43 100644 --- a/apps/course/admin/course.py +++ b/apps/course/admin/course.py @@ -34,8 +34,8 @@ from django.contrib.postgres.fields import ArrayField from utils.admin import project_admin_site from utils.json_editor_field import JsonEditorWidget -from apps.course.models import Course, Glossary, Attachment, CourseCategory, Participant -from apps.course.models.lesson import Lesson +from apps.course.models import Course, Glossary, Attachment, CourseCategory, Participant, CourseGlossary, CourseAttachment +from apps.course.models.lesson import Lesson, CourseLesson from apps.account.models import StudentUser, User from utils.schema import get_weekly_timing_schema, get_course_feature_schema @@ -87,10 +87,12 @@ class CourseCategoryAdmin(ModelAdmin): class CourseForm(forms.ModelForm): + class Meta: model = Course fields = '__all__' exclude = ('slug',) + widgets = { 'timing': JsonEditorWidget(attrs={ 'schema': get_weekly_timing_schema(), @@ -104,39 +106,38 @@ class CourseForm(forms.ModelForm): help_texts = { 'status': 'If set to inactive, the course will not be displayed.', } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Make short_description required + self.fields['short_description'].required = True -class AttachmentInline(TabularInline): - model = Attachment +class CourseAttachmentInline(StackedInline): + model = CourseAttachment extra = 0 - fields = ('title', 'file', 'file_size') + fields = ('attachment',) tab = True - - def save_model(self, request, obj, form, change): - if obj.file: - obj.file_size = obj.file.size - super().save_model(request, obj, form, change) + autocomplete_fields = ('attachment',) -class GlossaryInline(StackedInline): - model = Glossary - fields = ('title', 'description') +class CourseGlossaryInline(StackedInline): + model = CourseGlossary + fields = ('glossary',) extra = 0 tab = True show_change_link = True + autocomplete_fields = ('glossary',) -class LessonInline(StackedInline): - model = Lesson - fields = ('title', 'is_active', 'duration', 'content_type', 'content_file', 'video_link', 'priority',) +class CourseLessonInline(StackedInline): + model = CourseLesson + fields = ('lesson', 'title', 'is_active', 'priority',) extra = 0 tab = True show_change_link = True ordering_field = "priority" - conditional_fields = { - 'content_file': "content_type == 'video_file'", - 'video_link': "content_type == 'youtube_link'", - } + autocomplete_fields = ('lesson',) class ParticipantAdmin(ModelAdmin): @@ -227,7 +228,7 @@ class AddStudentForm(forms.Form): class CourseAdmin(ModelAdmin): form = CourseForm - inlines = [LessonInline, AttachmentInline, GlossaryInline, ParticipantInline] + inlines = [CourseLessonInline, CourseAttachmentInline, CourseGlossaryInline, ParticipantInline] list_display = ('display_header', 'category', 'display_professor', 'status', 'display_price', 'is_online') list_filter = [ ('status', ChoicesDropdownFilter), @@ -275,7 +276,7 @@ class CourseAdmin(ModelAdmin): }), (_('Course Details'), { 'fields': ('description', 'short_description', 'level', 'duration', 'lessons_count',), - 'classes': ['tab'], + # 'classes': ['tab'], }), (_('Media'), { 'fields': ('video_type', 'video_file', 'video_link'), @@ -285,7 +286,7 @@ class CourseAdmin(ModelAdmin): }), (_('Timing & Features'), { 'fields': ('timing', 'features'), - 'classes': ['tab'], + # 'classes': ['tab'], }), ) @@ -422,10 +423,25 @@ class CourseAdmin(ModelAdmin): class GlossaryAdmin(ModelAdmin): - list_display = ('title', 'course', 'description') + list_display = ('title', 'description') + search_fields = ('title', 'description') + ordering = ('-id',) + + +class CourseGlossaryAdmin(ModelAdmin): + list_display = ('course', 'glossary_title', 'glossary_description') list_filter = ('course',) - search_fields = ('title', 'description', 'course__title') + search_fields = ('glossary__title', 'glossary__description', 'course__title') ordering = ('-id',) + autocomplete_fields = ('course', 'glossary') + + @admin.display(description=_("Title")) + def glossary_title(self, obj): + return obj.glossary.title + + @admin.display(description=_("Description")) + def glossary_description(self, obj): + return obj.glossary.description class AttachmentAdminForm(forms.ModelForm): @@ -462,9 +478,8 @@ class AttachmentAdminForm(forms.ModelForm): class AttachmentAdmin(ModelAdmin): form = AttachmentAdminForm - list_display = ('title', 'course', 'file', 'file_size') - list_filter = ('course',) - search_fields = ('title', 'file', 'course__title') + list_display = ('title', 'file', 'file_size') + search_fields = ('title', 'file') def save_model(self, request, obj, form, change): if obj.file: @@ -472,9 +487,30 @@ class AttachmentAdmin(ModelAdmin): super().save_model(request, obj, form, change) +class CourseAttachmentAdmin(ModelAdmin): + list_display = ('course', 'attachment_title', 'attachment_file', 'attachment_file_size') + list_filter = ('course',) + search_fields = ('attachment__title', 'course__title') + autocomplete_fields = ('course', 'attachment') + + @admin.display(description=_("Title")) + def attachment_title(self, obj): + return obj.attachment.title + + @admin.display(description=_("File")) + def attachment_file(self, obj): + return obj.attachment.file + + @admin.display(description=_("File Size")) + def attachment_file_size(self, obj): + return obj.attachment.file_size + + # Register with the project admin site project_admin_site.register(Course, CourseAdmin) project_admin_site.register(CourseCategory, CourseCategoryAdmin) project_admin_site.register(Glossary, GlossaryAdmin) +project_admin_site.register(CourseGlossary, CourseGlossaryAdmin) project_admin_site.register(Attachment, AttachmentAdmin) +project_admin_site.register(CourseAttachment, CourseAttachmentAdmin) project_admin_site.register(Participant, ParticipantAdmin) diff --git a/apps/course/admin/lesson.py b/apps/course/admin/lesson.py index ff1d342..f4e334a 100644 --- a/apps/course/admin/lesson.py +++ b/apps/course/admin/lesson.py @@ -17,7 +17,7 @@ from unfold.widgets import ( ) from utils.admin import project_admin_site -from apps.course.models.lesson import Lesson, LessonCompletion +from apps.course.models.lesson import Lesson, CourseLesson, LessonCompletion from unfold.admin import ModelAdmin, StackedInline, TabularInline @@ -29,20 +29,21 @@ class LessonForm(forms.ModelForm): 'content_type': UnfoldAdminRadioSelectWidget(), } -from apps.quiz.models import Quiz - +class CourseLessonForm(forms.ModelForm): + class Meta: + model = CourseLesson + fields = '__all__' + + class LessonAdmin(ModelAdmin): form = LessonForm - list_display = ('title', 'course', 'display_duration', 'content_type', 'is_active', 'priority') + list_display = ('title', 'display_duration', 'content_type') list_filter = ( - ('course', MultipleRelatedDropdownFilter), ('content_type', ChoicesDropdownFilter), - 'is_active', ) - search_fields = ('title', 'course__title') - ordering = ('course', 'priority') - autocomplete_fields = ('course', ) + search_fields = ('title',) + ordering = ('title',) list_filter_submit = True radio_fields = { "content_type": admin.HORIZONTAL, @@ -54,7 +55,7 @@ class LessonAdmin(ModelAdmin): fieldsets = ( (None, { - 'fields': ('course', 'title', 'priority', 'is_active', 'duration') + 'fields': ('title', 'duration') }), (_('Content'), { 'fields': ('content_type', 'content_file', 'video_link'), @@ -86,15 +87,41 @@ class LessonAdmin(ModelAdmin): obj.duration ) + +class CourseLessonAdmin(ModelAdmin): + form = CourseLessonForm + list_display = ('title', 'course', 'display_duration', 'is_active', 'priority') + list_filter = ( + ('course', MultipleRelatedDropdownFilter), + 'is_active', + ) + search_fields = ('title', 'course__title') + ordering = ('course', 'priority') + autocomplete_fields = ('course', 'lesson') + list_filter_submit = True + + fieldsets = ( + (None, { + 'fields': ('course', 'lesson', 'title', 'priority', 'is_active') + }), + ) + + @display(description=_("Duration")) + def display_duration(self, obj): + return format_html( + '{} min', + obj.lesson.duration + ) + def get_queryset(self, request): qs = super().get_queryset(request) return qs.order_by('course', 'priority') class LessonCompletionAdmin(ModelAdmin): - list_display = ('student', 'lesson', 'completed_at') - search_fields = ('student__fullname', 'student__email', 'lesson__title', 'lesson__course__title') - list_filter = ('lesson__course', 'completed_at') + list_display = ('student', 'course_lesson', 'completed_at') + search_fields = ('student__fullname', 'student__email', 'course_lesson__title', 'course_lesson__course__title') + list_filter = ('course_lesson__course', 'completed_at') ordering = ('-completed_at',) def get_readonly_fields(self, request, obj=None): @@ -102,10 +129,11 @@ class LessonCompletionAdmin(ModelAdmin): Make fields readonly if the object already exists. """ if obj: - return ['student', 'lesson', 'completed_at'] + return ['student', 'course_lesson', 'completed_at'] return [] # Register with the project admin site project_admin_site.register(Lesson, LessonAdmin) +project_admin_site.register(CourseLesson, CourseLessonAdmin) project_admin_site.register(LessonCompletion, LessonCompletionAdmin) \ No newline at end of file diff --git a/apps/course/management/__init__.py b/apps/course/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/course/management/commands/__init__.py b/apps/course/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/course/management/commands/clear_course_data.py b/apps/course/management/commands/clear_course_data.py new file mode 100644 index 0000000..118cb9c --- /dev/null +++ b/apps/course/management/commands/clear_course_data.py @@ -0,0 +1,134 @@ +from django.core.management.base import BaseCommand +from django.db import transaction, connection +from django.db.models import ProtectedError +from django.utils.translation import gettext_lazy as _ + +from apps.course.models import ( + Course, CourseCategory, + Lesson, CourseLesson, LessonCompletion, + Attachment, CourseAttachment, + Glossary, CourseGlossary, + Participant +) + + +class Command(BaseCommand): + help = _('Clear all course-related data from the database') + + def add_arguments(self, parser): + parser.add_argument( + '--force', + action='store_true', + dest='force', + help=_('Force deletion without confirmation'), + ) + parser.add_argument( + '--model', + type=str, + dest='model', + help=_('Specify a single model to clear (e.g., "Course", "Lesson", etc.)'), + ) + parser.add_argument( + '--legacy-only', + action='store_true', + dest='legacy_only', + help=_('Clear only legacy models (before migration to new structure)'), + ) + + def table_exists(self, table_name): + """Check if a table exists in the database.""" + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = %s + ); + """, + [table_name] + ) + return cursor.fetchone()[0] + + def handle(self, *args, **options): + force = options['force'] + specific_model = options.get('model') + legacy_only = options.get('legacy_only') + + if not force and not specific_model: + confirm = input(_('This will delete ALL course-related data. Are you sure? (yes/no): ')) + if confirm.lower() != 'yes': + self.stdout.write(self.style.WARNING(_('Operation cancelled.'))) + return + + # Define all models + all_models = { + 'Course': (Course, 'course_course'), + 'CourseCategory': (CourseCategory, 'course_coursecategory'), + 'Lesson': (Lesson, 'course_lesson'), + 'CourseLesson': (CourseLesson, 'course_courselesson'), + 'LessonCompletion': (LessonCompletion, 'course_lessoncompletion'), + 'Attachment': (Attachment, 'course_attachment'), + 'CourseAttachment': (CourseAttachment, 'course_courseattachment'), + 'Glossary': (Glossary, 'course_glossary'), + 'CourseGlossary': (CourseGlossary, 'course_courseglossary'), + 'Participant': (Participant, 'course_participant'), + } + + # Legacy models (before migration) + legacy_models = { + 'Course': (Course, 'course_course'), + 'CourseCategory': (CourseCategory, 'course_coursecategory'), + 'Lesson': (Lesson, 'course_lesson'), + 'LessonCompletion': (LessonCompletion, 'course_lessoncompletion'), + 'Attachment': (Attachment, 'course_attachment'), + 'Glossary': (Glossary, 'course_glossary'), + 'Participant': (Participant, 'course_participant'), + } + + models_to_use = legacy_models if legacy_only else all_models + + if specific_model: + # Clear only the specified model + if specific_model not in models_to_use: + self.stdout.write(self.style.ERROR(_(f'Unknown model: {specific_model}'))) + self.stdout.write(self.style.WARNING(_(f'Available models: {", ".join(models_to_use.keys())}'))) + return + + model_info = models_to_use[specific_model] + models_to_clear = [(specific_model, model_info[0], model_info[1])] + else: + # Clear all models in the correct order to avoid foreign key constraints + models_to_clear = [] + + # Order matters for foreign key constraints + model_order = [ + 'LessonCompletion', 'CourseLesson', 'Lesson', + 'CourseAttachment', 'Attachment', + 'CourseGlossary', 'Glossary', + 'Participant', 'Course', 'CourseCategory' + ] + + for model_name in model_order: + if model_name in models_to_use: + model_info = models_to_use[model_name] + models_to_clear.append((model_name, model_info[0], model_info[1])) + + # Process each model + for model_name, model_class, table_name in models_to_clear: + # Check if the table exists + if not self.table_exists(table_name): + self.stdout.write(self.style.WARNING(_(f'Table {table_name} does not exist, skipping {model_name}'))) + continue + + try: + count = model_class.objects.count() + model_class.objects.all().delete() + self.stdout.write(self.style.SUCCESS(_(f'Deleted {count} {model_name} records'))) + except ProtectedError as e: + self.stdout.write(self.style.ERROR(_(f'Could not delete {model_name} records due to protected foreign keys'))) + self.stdout.write(self.style.ERROR(str(e))) + except Exception as e: + self.stdout.write(self.style.ERROR(_(f'Error deleting {model_name} records: {str(e)}'))) + + self.stdout.write(self.style.SUCCESS(_('Course data clearing completed'))) \ No newline at end of file diff --git a/apps/course/migrations/0004_alter_attachment_options_alter_glossary_options_and_more.py b/apps/course/migrations/0004_alter_attachment_options_alter_glossary_options_and_more.py new file mode 100644 index 0000000..8d7a29d --- /dev/null +++ b/apps/course/migrations/0004_alter_attachment_options_alter_glossary_options_and_more.py @@ -0,0 +1,132 @@ +# Generated by Django 5.1.8 on 2025-04-13 01:35 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0002_alter_user_phone_number'), + ('course', '0003_alter_course_is_online_alter_course_timing_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='attachment', + options={'verbose_name': 'Attachment', 'verbose_name_plural': 'Attachments'}, + ), + migrations.AlterModelOptions( + name='glossary', + options={'verbose_name': 'Glossary', 'verbose_name_plural': 'Glossaries'}, + ), + migrations.RemoveField( + model_name='attachment', + name='course', + ), + migrations.RemoveField( + model_name='glossary', + name='course', + ), + migrations.RemoveField( + model_name='lesson', + name='course', + ), + migrations.RemoveField( + model_name='lesson', + name='is_active', + ), + migrations.RemoveField( + model_name='lesson', + name='priority', + ), + migrations.AddField( + model_name='attachment', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='Created at'), + preserve_default=False, + ), + migrations.AddField( + model_name='attachment', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Updated At'), + ), + migrations.AddField( + model_name='glossary', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='Created at'), + preserve_default=False, + ), + migrations.AddField( + model_name='glossary', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Updated At'), + ), + migrations.AlterField( + model_name='lesson', + name='content_type', + field=models.CharField(choices=[('youtube_link', 'Youtube Link'), ('video_file', 'Video File')], max_length=50, verbose_name='Content Type'), + ), + migrations.CreateModel( + name='CourseAttachment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('attachment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='course_attachments', to='course.attachment', verbose_name='Attachment')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='course.course', verbose_name='Course')), + ], + options={ + 'verbose_name': 'Course Attachment', + 'verbose_name_plural': 'Course Attachments', + 'ordering': ('-id',), + }, + ), + migrations.CreateModel( + name='CourseGlossary', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='glossaries', to='course.course', verbose_name='Course')), + ('glossary', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='course_glossaries', to='course.glossary', verbose_name='Glossary')), + ], + options={ + 'verbose_name': 'Course Glossary', + 'verbose_name_plural': 'Course Glossaries', + 'ordering': ('-id',), + }, + ), + migrations.CreateModel( + name='CourseLesson', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(blank=True, max_length=255, null=True, verbose_name='Course Lesson Title')), + ('priority', models.IntegerField(blank=True, null=True, verbose_name='Priority')), + ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lessons', to='course.course', verbose_name='Course')), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='course_lessons', to='course.lesson', verbose_name='Lesson')), + ], + ), + migrations.AlterUniqueTogether( + name='lessoncompletion', + unique_together=set(), + ), + migrations.AddField( + model_name='lessoncompletion', + name='course_lesson', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='completions', to='course.courselesson'), + preserve_default=False, + ), + migrations.AlterUniqueTogether( + name='lessoncompletion', + unique_together={('student', 'course_lesson')}, + ), + migrations.RemoveField( + model_name='lessoncompletion', + name='lesson', + ), + ] diff --git a/apps/course/models/course.py b/apps/course/models/course.py index cab4c95..79a7a42 100644 --- a/apps/course/models/course.py +++ b/apps/course/models/course.py @@ -15,8 +15,11 @@ 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}") @@ -131,44 +134,101 @@ class Course(models.Model): verbose_name_plural = "Courses" - class Glossary(models.Model): - course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='glossaries', verbose_name='Course') + """ + Base Glossary model that contains the actual content + """ title = models.CharField(max_length=555, verbose_name='Glossary Title') description = models.TextField(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 f"{self.course.title} - {self.title}" - + return self.title class Meta: - ordering = ("-id",) verbose_name = "Glossary" - verbose_name_plural = "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"{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): - course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='attachments', verbose_name='Course') + """ + Base Attachment model that contains the actual file + """ title = models.CharField(max_length=255, 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): # 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 + + 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"{self.course.title} - {self.title}" + return f"{self.course.title} - {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 = "Attachment" - verbose_name_plural = "Attachments" \ No newline at end of file + verbose_name = "Course Attachment" + verbose_name_plural = "Course Attachments" \ No newline at end of file diff --git a/apps/course/models/lesson.py b/apps/course/models/lesson.py index b73b31f..1e636d3 100644 --- a/apps/course/models/lesson.py +++ b/apps/course/models/lesson.py @@ -9,21 +9,19 @@ from apps.account.models import StudentUser def lesson_file_upload_to(instance, filename): - return os.path.join(f"courses/{instance.course.slug}/lessons/{filename}") - + return os.path.join(f"lessons/{filename}") class Lesson(models.Model): + """ + Base Lesson model that contains the actual content (video file or link) + """ class ContentTypeChoices(models.TextChoices): YOUTUBE_LINK = 'youtube_link', 'Youtube Link' VIDEO_FILE = 'video_file', 'Video File' - - course = models.ForeignKey("course.Course", on_delete=models.CASCADE, related_name='lessons', verbose_name='Course') + title = models.CharField(max_length=255, verbose_name='Lesson Title') - priority = models.IntegerField(null=True, blank=True, verbose_name='Priority') - is_active = models.BooleanField(default=True, verbose_name=_('Is Active')) - duration = models.PositiveIntegerField(verbose_name='Duration (in minutes)') content_type = models.CharField(max_length=50, choices=ContentTypeChoices.choices, verbose_name='Content Type') content_file = models.FileField( null=True, @@ -31,57 +29,68 @@ class Lesson(models.Model): upload_to=lesson_file_upload_to, ) video_link = models.CharField(max_length=500, null=True, blank=True, verbose_name='Link') - + duration = models.PositiveIntegerField(verbose_name='Duration (in minutes)') 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 + + +class CourseLesson(models.Model): + """ + Intermediate model that connects Course with Lesson + """ + course = models.ForeignKey("course.Course", on_delete=models.CASCADE, related_name='lessons', verbose_name='Course') + lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='course_lessons', verbose_name='Lesson') + title = models.CharField(max_length=255, verbose_name='Course Lesson Title', null=True, blank=True) + 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, verbose_name=_("Created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) def __str__(self): - return f"{self.course.title} - {self.title}" + title = self.title or self.lesson.title + return f"{self.course.title} - {title}" def is_completed_by(self, student): return self.completions.filter(student=student).exists() + @property + def content_type(self): + return self.lesson.content_type + + @property + def content_file(self): + return self.lesson.content_file + + @property + def video_link(self): + return self.lesson.video_link + + @property + def duration(self): + return self.lesson.duration def save(self, *args, **kwargs): - print(f'---> start') + # If title is not provided, use the lesson's title + if not self.title: + self.title = self.lesson.title + if self.priority is None: # If priority is not set, set it to the next available priority max_priority = self.course.lessons.aggregate(max_priority=models.Max('priority'))['max_priority'] self.priority = (max_priority or 0) + 1 - else: + else: self._adjust_priorities() super().save(*args, **kwargs) - def _adjust_priorities(self): # Adjust priorities of other lessons in the course lessons = self.course.lessons.exclude(pk=self.pk) # Shift priorities for lessons with the same or higher priority lessons.filter(priority__gte=self.priority).update(priority=models.F('priority') + 1) - - - # # If priority is set, adjust the priorities of other lessons - # lessons = self.course.lessons.exclude(pk=self.pk).order_by('priority') - - # updated_priorities = [] - # inserted = False - - # for lesson in lessons: - # if lesson.priority >= self.priority and not inserted: - # updated_priorities.append((self.priority, self)) - # inserted = True - # updated_priorities.append((lesson.priority if not inserted else lesson.priority + 1, lesson)) - # if not inserted: - # updated_priorities.append((self.priority, self)) - - # # Update priorities in bulk - # for priority, lesson in updated_priorities: - # lesson.priority = priority - # lesson.save(update_fields=['priority']) - - class LessonCompletion(models.Model): student = models.ForeignKey( @@ -89,8 +98,8 @@ class LessonCompletion(models.Model): on_delete=models.CASCADE, related_name='lesson_completions' ) - lesson = models.ForeignKey( - Lesson, + course_lesson = models.ForeignKey( + CourseLesson, on_delete=models.CASCADE, related_name='completions' ) @@ -98,9 +107,9 @@ class LessonCompletion(models.Model): created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) class Meta: - unique_together = ('student', 'lesson') + unique_together = ('student', 'course_lesson') def __str__(self): - return f"{self.student.fullname} - {self.lesson.title} - Completed" + return f"{self.student.fullname} - {self.course_lesson.title} - Completed" \ No newline at end of file diff --git a/apps/course/serializers/course.py b/apps/course/serializers/course.py index d381c0a..5f80645 100644 --- a/apps/course/serializers/course.py +++ b/apps/course/serializers/course.py @@ -2,7 +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 +from apps.course.models import Course, CourseCategory, Attachment, Glossary, LessonCompletion, Participant, Lesson, CourseAttachment, CourseGlossary from apps.chat.models import RoomMessage from apps.account.serializers import UserProfileSerializer @@ -252,9 +252,28 @@ class AttachmentSerializer(serializers.ModelSerializer): class Meta: model = Attachment fields = ['id', 'title', 'file', 'file_size'] + + +class CourseAttachmentSerializer(serializers.ModelSerializer): + title = serializers.CharField(source='attachment.title', read_only=True) + file = serializers.FileField(source='attachment.file', read_only=True) + file_size = serializers.IntegerField(source='attachment.file_size', read_only=True) + + class Meta: + model = CourseAttachment + fields = ['id', 'title', 'file', 'file_size'] class GlossarySerializer(serializers.ModelSerializer): class Meta: model = Glossary + fields = ['id', 'title', 'description'] + + +class CourseGlossarySerializer(serializers.ModelSerializer): + title = serializers.CharField(source='glossary.title', read_only=True) + description = serializers.CharField(source='glossary.description', read_only=True) + + class Meta: + model = CourseGlossary fields = ['id', 'title', 'description'] \ No newline at end of file diff --git a/apps/course/serializers/lesson.py b/apps/course/serializers/lesson.py index c90f900..4a26e99 100644 --- a/apps/course/serializers/lesson.py +++ b/apps/course/serializers/lesson.py @@ -1,19 +1,26 @@ from rest_framework import serializers -from apps.course.models import Lesson, Participant, LessonCompletion +from apps.course.models import Lesson, CourseLesson, Participant, LessonCompletion from apps.quiz.serializers import QuizListSerializer +class LessonSerializer(serializers.ModelSerializer): + class Meta: + model = Lesson + fields = ['id', 'title', 'content_type', 'content_file', 'video_link', 'duration'] - -class LessonSerializer(serializers.ModelSerializer): +class CourseLessonSerializer(serializers.ModelSerializer): is_complated = serializers.SerializerMethodField() quizs = serializers.SerializerMethodField() - permission = serializers.SerializerMethodField() + permission = serializers.SerializerMethodField() + content_type = serializers.CharField(source='lesson.content_type', read_only=True) + 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) class Meta: - model = Lesson - fields = ['id', 'title', 'priority', 'is_active', 'permission','duration', 'content_type', 'content_file', 'video_link', 'is_complated', 'quizs'] + model = CourseLesson + fields = ['id', 'title', 'priority', 'is_active', 'permission', 'duration', 'content_type', 'content_file', 'video_link', 'is_complated', 'quizs'] def get_permission(self, obj): if student := self._get_authenticated_user(): @@ -46,12 +53,12 @@ class LessonSerializer(serializers.ModelSerializer): return LessonCompletion.objects.filter( student=user, - lesson=obj + course_lesson=obj ).exists() - def get_quizs(self, obj): - quizzes = obj.quizzes.all() # استفاده از related_name 'quizzes' برای دسترسی به کوییزهای درس - if quizzes.exists(): + # Assuming the related_name for quizzes is now on CourseLesson + quizzes = obj.quizzes.all() if hasattr(obj, 'quizzes') else [] + if quizzes: return QuizListSerializer(quizzes, many=True, context=self.context).data - return None + return None \ No newline at end of file diff --git a/apps/course/views/course.py b/apps/course/views/course.py index 5b46165..e0ada43 100644 --- a/apps/course/views/course.py +++ b/apps/course/views/course.py @@ -9,9 +9,9 @@ from rest_framework.filters import SearchFilter from apps.course.serializers import ( CourseListSerializer, CourseCategorySerializer, CourseDetailSerializer, - AttachmentSerializer, GlossarySerializer, MyCourseListSerializer + CourseAttachmentSerializer, CourseGlossarySerializer, MyCourseListSerializer ) -from apps.course.models import Course, CourseCategory, Attachment, Glossary, Participant +from apps.course.models import Course, CourseCategory, CourseAttachment, CourseGlossary, Participant from apps.course.doc import * @@ -175,7 +175,7 @@ class MyCourseListAPIView(ListAPIView): class AttachmentListAPIView(ListAPIView): - serializer_class = AttachmentSerializer + serializer_class = CourseAttachmentSerializer @swagger_auto_schema( manual_parameters=[ @@ -197,15 +197,15 @@ class AttachmentListAPIView(ListAPIView): course = Course.objects.get(slug=course_slug) except Course.DoesNotExist: raise NotFound("Course not found") - return Attachment.objects.filter(course=course) + return CourseAttachment.objects.filter(course=course) class GlossaryListAPIView(ListAPIView): - serializer_class = GlossarySerializer + serializer_class = CourseGlossarySerializer filter_backends = [SearchFilter] - search_fields = ['title', 'description'] + search_fields = ['glossary__title', 'glossary__description'] def get_queryset(self): course_slug = self.kwargs.get('slug') @@ -214,7 +214,7 @@ class GlossaryListAPIView(ListAPIView): except Course.DoesNotExist: raise NotFound("Course not found") - return Glossary.objects.filter(course=course) + return CourseGlossary.objects.filter(course=course) \ No newline at end of file diff --git a/apps/course/views/lesson.py b/apps/course/views/lesson.py index a4958a3..0ca1a9c 100644 --- a/apps/course/views/lesson.py +++ b/apps/course/views/lesson.py @@ -7,9 +7,9 @@ from rest_framework import status from rest_framework.response import Response from apps.course.serializers import ( - LessonSerializer + CourseLessonSerializer ) -from apps.course.models import Course, Lesson, LessonCompletion +from apps.course.models import Course, CourseLesson, LessonCompletion from apps.course.doc import * from utils.exceptions import AppAPIException from rest_framework.permissions import IsAuthenticated @@ -17,8 +17,8 @@ from rest_framework.permissions import IsAuthenticated class LessonListView(ListAPIView): - serializer_class = LessonSerializer - queryset = Lesson.objects.filter(is_active=True) + serializer_class = CourseLessonSerializer + queryset = CourseLesson.objects.filter(is_active=True) @swagger_auto_schema( operation_description=doc_courses_lesson(), @@ -39,66 +39,29 @@ class LessonListView(ListAPIView): class LessonDetailView(RetrieveAPIView): - serializer_class = LessonSerializer + serializer_class = CourseLessonSerializer def get(self, request, *args, **kwargs): lesson_id = self.kwargs.get('id') - lesson = get_object_or_404(Lesson, id=lesson_id, is_active=True) + course_lesson = get_object_or_404(CourseLesson, id=lesson_id, is_active=True) - course = lesson.course - lessons = Lesson.objects.filter(course=course, is_active=True).order_by('priority') + course = course_lesson.course + lessons = CourseLesson.objects.filter(course=course, is_active=True).order_by('priority') total_lessons = lessons.count() - current_lesson_number = list(lessons.values_list('id', flat=True)).index(lesson.id) + 1 - next_lesson = lessons.filter(priority__gt=lesson.priority).order_by('priority').first() + current_lesson_number = list(lessons.values_list('id', flat=True)).index(course_lesson.id) + 1 + next_lesson = lessons.filter(priority__gt=course_lesson.priority).order_by('priority').first() next_lesson_id = next_lesson.id if next_lesson else None - previous_lesson = lessons.filter(priority__lt=lesson.priority).order_by('-priority').first() + previous_lesson = lessons.filter(priority__lt=course_lesson.priority).order_by('-priority').first() previous_lesson_id = previous_lesson.id if previous_lesson else None - lesson_data = self.get_serializer(lesson).data + lesson_data = self.get_serializer(course_lesson).data lesson_data['total_lessons'] = total_lessons lesson_data['current_lesson_number'] = current_lesson_number lesson_data['next_lesson_id'] = next_lesson_id lesson_data['previous_lesson_id'] = previous_lesson_id lesson_data['can_go_next'] = next_lesson is not None - - - - # # Get the next and previous lessons based on priority and id - # next_lesson = Lesson.objects.filter( - # course=lesson.course, - # is_active=True, - # priority__gte=lesson.priority, - # id__gt=lesson.id - # ).order_by('priority', 'id').first() - - # previous_lesson = Lesson.objects.filter( - # course=lesson.course, - # is_active=True, - # priority__lte=lesson.priority, - # id__lt=lesson.id - # ).order_by('-priority', '-id').first() - - # total_lessons = Lesson.objects.filter(course=lesson.course, is_active=True).count() - # # Calculate the current lesson number in the course - # current_lesson_number = Lesson.objects.filter( - # course=lesson.course, - # is_active=True, - # priority__lte=lesson.priority - # ).count() - - # # Serialize the current lesson - # lesson_data = self.get_serializer(lesson).data - # # Add current lesson number and total lessons - # lesson_data['current_lesson_number'] = current_lesson_number - # lesson_data['total_lessons'] = total_lessons - - # # Add next and previous lesson ids - # lesson_data['next_lesson_id'] = next_lesson.id if next_lesson else None - # lesson_data['previous_lesson_id'] = previous_lesson.id if previous_lesson else None - - return Response(lesson_data) @@ -129,16 +92,16 @@ class LessonCompletionCreateAPIView(GenericAPIView): if not lesson_id: return Response({'error': 'Lesson ID is required.'}, status=status.HTTP_400_BAD_REQUEST) try: - lesson = Lesson.objects.get(id=lesson_id) - except Lesson.DoesNotExist: + course_lesson = CourseLesson.objects.get(id=lesson_id) + except CourseLesson.DoesNotExist: return Response({'error': 'Lesson not found.'}, status=status.HTTP_404_NOT_FOUND) # Check if the lesson is already completed by the student - if LessonCompletion.objects.filter(student=student, lesson=lesson).exists(): + if LessonCompletion.objects.filter(student=student, course_lesson=course_lesson).exists(): return Response({'message': 'Lesson already completed.'}, status=status.HTTP_200_OK) # Create a new completion record - completion = LessonCompletion(student=student, lesson=lesson) + completion = LessonCompletion(student=student, course_lesson=course_lesson) completion.save() return Response({'message': 'Lesson completed successfully.'}, status=status.HTTP_201_CREATED) \ No newline at end of file diff --git a/config/settings/base.py b/config/settings/base.py index 657254f..fee9da3 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -436,10 +436,9 @@ UNFOLD = { "page": "courses", "models": [ "course.course", - "course.coursecategory", - "course.lesson", - "course.glossary", - "course.attachment", + "course.courselesson", + "course.courseglossary", + "course.courseattachment", "quiz.quiz", ], "items": [ @@ -450,22 +449,22 @@ UNFOLD = { "active": lambda request: request.path.startswith(str(reverse_lazy("admin:course_course_changelist"))), }, { - "title": _("Lessons"), + "title": _("Course Lessons"), "icon": "menu_book", - "link": reverse_lazy("admin:course_lesson_changelist"), - "active": lambda request: request.path.startswith(str(reverse_lazy("admin:course_lesson_changelist"))), + "link": reverse_lazy("admin:course_courselesson_changelist"), + "active": lambda request: request.path.startswith(str(reverse_lazy("admin:course_courselesson_changelist"))), }, { - "title": _("Attachments"), + "title": _("Course Attachments"), "icon": "attach_file", - "link": reverse_lazy("admin:course_attachment_changelist"), - "active": lambda request: request.path.startswith(str(reverse_lazy("admin:course_attachment_changelist"))), + "link": reverse_lazy("admin:course_courseattachment_changelist"), + "active": lambda request: request.path.startswith(str(reverse_lazy("admin:course_courseattachment_changelist"))), }, { - "title": _("Glossary"), + "title": _("Course Glossary"), "icon": "book", - "link": reverse_lazy("admin:course_glossary_changelist"), - "active": lambda request: request.path.startswith(str(reverse_lazy("admin:course_glossary_changelist"))), + "link": reverse_lazy("admin:course_courseglossary_changelist"), + "active": lambda request: request.path.startswith(str(reverse_lazy("admin:course_courseglossary_changelist"))), }, { "title": _("Quizzes"), @@ -538,41 +537,47 @@ UNFOLD = { ] }, + { + "title": _(""), + "items": [ + { + "title": _("Certificates"), + "icon": "workspace_premium", + "link": reverse_lazy("admin:certificate_certificate_changelist"), + }, + + ] + }, { "title": _("Courses"), "collapsible": True, "separator": True, "items": [ + { + "title": _("Categories"), + "icon": "category", + "link": reverse_lazy("admin:course_coursecategory_changelist"), + }, { "title": _("Courses"), "icon": "school", "link": reverse_lazy("admin:course_course_changelist"), }, { - "title": _("Categories"), - "icon": "category", - "link": reverse_lazy("admin:course_coursecategory_changelist"), + "title": _("Lessons"), + "icon": "menu_book", + "link": reverse_lazy("admin:course_lesson_changelist"), + }, + { + "title": _("Attachments"), + "icon": "attach_file", + "link": reverse_lazy("admin:course_attachment_changelist"), }, { - "title": _("Certificates"), - "icon": "workspace_premium", - "link": reverse_lazy("admin:certificate_certificate_changelist"), - }, - # { - # "title": _("Lessons"), - # "icon": "menu_book", - # "link": reverse_lazy("admin:course_lesson_changelist"), - # }, - # { - # "title": _("Attachments"), - # "icon": "attach_file", - # "link": reverse_lazy("admin:course_attachment_changelist"), - # }, - # { - # "title": _("Glossary"), - # "icon": "book", - # "link": reverse_lazy("admin:course_glossary_changelist"), - # }, + "title": _("Glossary"), + "icon": "book", + "link": reverse_lazy("admin:course_glossary_changelist"), + }, ] }, { @@ -598,7 +603,12 @@ UNFOLD = { # You can add more preference sections here ], }, - + # "STYLES": [ + # lambda request: static("css/styles.css"), + # ], + # "SCRIPTS": [ + # lambda request: static("js/scripts.js"), + # ], # { # "title": _("Hadis"), # "collapsible": True,