Browse Source

pref admin panel courses

master
alireza 1 year ago
parent
commit
2c2856ed3b
  1. 2
      apps/account/templates/account/json_editor_field.html
  2. 22
      apps/api/views.py
  3. 92
      apps/course/admin/course.py
  4. 56
      apps/course/admin/lesson.py
  5. 0
      apps/course/management/__init__.py
  6. 0
      apps/course/management/commands/__init__.py
  7. 134
      apps/course/management/commands/clear_course_data.py
  8. 132
      apps/course/migrations/0004_alter_attachment_options_alter_glossary_options_and_more.py
  9. 88
      apps/course/models/course.py
  10. 85
      apps/course/models/lesson.py
  11. 21
      apps/course/serializers/course.py
  12. 29
      apps/course/serializers/lesson.py
  13. 14
      apps/course/views/course.py
  14. 69
      apps/course/views/lesson.py
  15. 82
      config/settings/base.py

2
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%;

22
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):

92
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)

56
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(
'<span class="badge badge-info">{} min</span>',
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)

0
apps/course/management/__init__.py

0
apps/course/management/commands/__init__.py

134
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')))

132
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',
),
]

88
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"
verbose_name = "Course Attachment"
verbose_name_plural = "Course Attachments"

85
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"

21
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']

29
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

14
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)

69
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)

82
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,

Loading…
Cancel
Save