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. 24
      apps/api/views.py
  3. 92
      apps/course/admin/course.py
  4. 54
      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. 84
      apps/course/models/course.py
  10. 81
      apps/course/models/lesson.py
  11. 21
      apps/course/serializers/course.py
  12. 23
      apps/course/serializers/lesson.py
  13. 14
      apps/course/views/course.py
  14. 69
      apps/course/views/lesson.py
  15. 80
      config/settings/base.py

2
apps/account/templates/account/json_editor_field.html

@ -312,7 +312,7 @@
/* Modern JSON Editor Container - Unfold theme */ /* Modern JSON Editor Container - Unfold theme */
.json-editor-container { .json-editor-container {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
background-color: #fff;
background-color: #0C0B1D;
border-radius: 0.5rem; border-radius: 0.5rem;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;

24
apps/api/views.py

@ -17,23 +17,13 @@ class HomeView(GenericAPIView):
serializer_class = HomeSerializer serializer_class = HomeSerializer
def get(self, request): 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)
return Response({'token': token.key})
# Get build_number from headers
build_number = request.META.get('HTTP_BUILD_NUMBER')
# Print the build_number
print(f"Build Number: {build_number}")
return Response({'token': "ok", 'build_number': build_number})
class CountryView(GenericAPIView): 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.admin import project_admin_site
from utils.json_editor_field import JsonEditorWidget 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 apps.account.models import StudentUser, User
from utils.schema import get_weekly_timing_schema, get_course_feature_schema from utils.schema import get_weekly_timing_schema, get_course_feature_schema
@ -87,10 +87,12 @@ class CourseCategoryAdmin(ModelAdmin):
class CourseForm(forms.ModelForm): class CourseForm(forms.ModelForm):
class Meta: class Meta:
model = Course model = Course
fields = '__all__' fields = '__all__'
exclude = ('slug',) exclude = ('slug',)
widgets = { widgets = {
'timing': JsonEditorWidget(attrs={ 'timing': JsonEditorWidget(attrs={
'schema': get_weekly_timing_schema(), 'schema': get_weekly_timing_schema(),
@ -105,38 +107,37 @@ class CourseForm(forms.ModelForm):
'status': 'If set to inactive, the course will not be displayed.', '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 extra = 0
fields = ('title', 'file', 'file_size')
fields = ('attachment',)
tab = True tab = True
autocomplete_fields = ('attachment',)
def save_model(self, request, obj, form, change):
if obj.file:
obj.file_size = obj.file.size
super().save_model(request, obj, form, change)
class GlossaryInline(StackedInline):
model = Glossary
fields = ('title', 'description')
class CourseGlossaryInline(StackedInline):
model = CourseGlossary
fields = ('glossary',)
extra = 0 extra = 0
tab = True tab = True
show_change_link = 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 extra = 0
tab = True tab = True
show_change_link = True show_change_link = True
ordering_field = "priority" ordering_field = "priority"
conditional_fields = {
'content_file': "content_type == 'video_file'",
'video_link': "content_type == 'youtube_link'",
}
autocomplete_fields = ('lesson',)
class ParticipantAdmin(ModelAdmin): class ParticipantAdmin(ModelAdmin):
@ -227,7 +228,7 @@ class AddStudentForm(forms.Form):
class CourseAdmin(ModelAdmin): class CourseAdmin(ModelAdmin):
form = CourseForm 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_display = ('display_header', 'category', 'display_professor', 'status', 'display_price', 'is_online')
list_filter = [ list_filter = [
('status', ChoicesDropdownFilter), ('status', ChoicesDropdownFilter),
@ -275,7 +276,7 @@ class CourseAdmin(ModelAdmin):
}), }),
(_('Course Details'), { (_('Course Details'), {
'fields': ('description', 'short_description', 'level', 'duration', 'lessons_count',), 'fields': ('description', 'short_description', 'level', 'duration', 'lessons_count',),
'classes': ['tab'],
# 'classes': ['tab'],
}), }),
(_('Media'), { (_('Media'), {
'fields': ('video_type', 'video_file', 'video_link'), 'fields': ('video_type', 'video_file', 'video_link'),
@ -285,7 +286,7 @@ class CourseAdmin(ModelAdmin):
}), }),
(_('Timing & Features'), { (_('Timing & Features'), {
'fields': ('timing', 'features'), 'fields': ('timing', 'features'),
'classes': ['tab'],
# 'classes': ['tab'],
}), }),
) )
@ -422,10 +423,25 @@ class CourseAdmin(ModelAdmin):
class GlossaryAdmin(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',) list_filter = ('course',)
search_fields = ('title', 'description', 'course__title')
search_fields = ('glossary__title', 'glossary__description', 'course__title')
ordering = ('-id',) 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): class AttachmentAdminForm(forms.ModelForm):
@ -462,9 +478,8 @@ class AttachmentAdminForm(forms.ModelForm):
class AttachmentAdmin(ModelAdmin): class AttachmentAdmin(ModelAdmin):
form = AttachmentAdminForm 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): def save_model(self, request, obj, form, change):
if obj.file: if obj.file:
@ -472,9 +487,30 @@ class AttachmentAdmin(ModelAdmin):
super().save_model(request, obj, form, change) 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 # Register with the project admin site
project_admin_site.register(Course, CourseAdmin) project_admin_site.register(Course, CourseAdmin)
project_admin_site.register(CourseCategory, CourseCategoryAdmin) project_admin_site.register(CourseCategory, CourseCategoryAdmin)
project_admin_site.register(Glossary, GlossaryAdmin) project_admin_site.register(Glossary, GlossaryAdmin)
project_admin_site.register(CourseGlossary, CourseGlossaryAdmin)
project_admin_site.register(Attachment, AttachmentAdmin) project_admin_site.register(Attachment, AttachmentAdmin)
project_admin_site.register(CourseAttachment, CourseAttachmentAdmin)
project_admin_site.register(Participant, ParticipantAdmin) project_admin_site.register(Participant, ParticipantAdmin)

54
apps/course/admin/lesson.py

@ -17,7 +17,7 @@ from unfold.widgets import (
) )
from utils.admin import project_admin_site 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 from unfold.admin import ModelAdmin, StackedInline, TabularInline
@ -29,20 +29,21 @@ class LessonForm(forms.ModelForm):
'content_type': UnfoldAdminRadioSelectWidget(), 'content_type': UnfoldAdminRadioSelectWidget(),
} }
from apps.quiz.models import Quiz
class CourseLessonForm(forms.ModelForm):
class Meta:
model = CourseLesson
fields = '__all__'
class LessonAdmin(ModelAdmin): class LessonAdmin(ModelAdmin):
form = LessonForm form = LessonForm
list_display = ('title', 'course', 'display_duration', 'content_type', 'is_active', 'priority')
list_display = ('title', 'display_duration', 'content_type')
list_filter = ( list_filter = (
('course', MultipleRelatedDropdownFilter),
('content_type', ChoicesDropdownFilter), ('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 list_filter_submit = True
radio_fields = { radio_fields = {
"content_type": admin.HORIZONTAL, "content_type": admin.HORIZONTAL,
@ -54,7 +55,7 @@ class LessonAdmin(ModelAdmin):
fieldsets = ( fieldsets = (
(None, { (None, {
'fields': ('course', 'title', 'priority', 'is_active', 'duration')
'fields': ('title', 'duration')
}), }),
(_('Content'), { (_('Content'), {
'fields': ('content_type', 'content_file', 'video_link'), 'fields': ('content_type', 'content_file', 'video_link'),
@ -86,15 +87,41 @@ class LessonAdmin(ModelAdmin):
obj.duration 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): def get_queryset(self, request):
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.order_by('course', 'priority') return qs.order_by('course', 'priority')
class LessonCompletionAdmin(ModelAdmin): 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',) ordering = ('-completed_at',)
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
@ -102,10 +129,11 @@ class LessonCompletionAdmin(ModelAdmin):
Make fields readonly if the object already exists. Make fields readonly if the object already exists.
""" """
if obj: if obj:
return ['student', 'lesson', 'completed_at']
return ['student', 'course_lesson', 'completed_at']
return [] return []
# Register with the project admin site # Register with the project admin site
project_admin_site.register(Lesson, LessonAdmin) project_admin_site.register(Lesson, LessonAdmin)
project_admin_site.register(CourseLesson, CourseLessonAdmin)
project_admin_site.register(LessonCompletion, LessonCompletionAdmin) 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',
),
]

84
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}") return os.path.join(f"courses/{instance.slug}/videos/{filename}")
def attachment_file_upload_to(instance, 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}") return os.path.join(f"courses/{instance.course.slug}/attachments/{filename}")
@ -131,32 +134,61 @@ class Course(models.Model):
verbose_name_plural = "Courses" verbose_name_plural = "Courses"
class Glossary(models.Model): 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') title = models.CharField(max_length=555, verbose_name='Glossary Title')
description = models.TextField(verbose_name='Description') 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): def __str__(self):
return f"{self.course.title} - {self.title}"
return self.title
class Meta: class Meta:
ordering = ("-id",)
verbose_name = "Glossary" 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): 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') title = models.CharField(max_length=255, verbose_name='Attachment Title')
file = models.FileField( file = models.FileField(
upload_to=attachment_file_upload_to, upload_to=attachment_file_upload_to,
verbose_name='Attachment File' verbose_name='Attachment File'
) )
file_size = models.PositiveIntegerField(verbose_name='File Size (in bytes)', null=True, blank=True) 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): def save(self, *args, **kwargs):
# Calculate the file size before saving # Calculate the file size before saving
@ -164,11 +196,39 @@ class Attachment(models.Model):
self.file_size = self.file.size self.file_size = self.file.size
super().save(*args, **kwargs) super().save(*args, **kwargs)
def __str__(self): def __str__(self):
return f"{self.course.title} - {self.title}"
return self.title
class Meta: class Meta:
ordering = ("-id",)
verbose_name = "Attachment" verbose_name = "Attachment"
verbose_name_plural = "Attachments" 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.attachment.title}"
@property
def title(self):
return self.attachment.title
@property
def file(self):
return self.attachment.file
@property
def file_size(self):
return self.attachment.file_size
class Meta:
ordering = ("-id",)
verbose_name = "Course Attachment"
verbose_name_plural = "Course Attachments"

81
apps/course/models/lesson.py

@ -9,21 +9,19 @@ from apps.account.models import StudentUser
def lesson_file_upload_to(instance, filename): 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): class Lesson(models.Model):
"""
Base Lesson model that contains the actual content (video file or link)
"""
class ContentTypeChoices(models.TextChoices): class ContentTypeChoices(models.TextChoices):
YOUTUBE_LINK = 'youtube_link', 'Youtube Link' YOUTUBE_LINK = 'youtube_link', 'Youtube Link'
VIDEO_FILE = 'video_file', 'Video File' 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') 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_type = models.CharField(max_length=50, choices=ContentTypeChoices.choices, verbose_name='Content Type')
content_file = models.FileField( content_file = models.FileField(
null=True, null=True,
@ -31,20 +29,54 @@ class Lesson(models.Model):
upload_to=lesson_file_upload_to, upload_to=lesson_file_upload_to,
) )
video_link = models.CharField(max_length=500, null=True, blank=True, verbose_name='Link') 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")) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated 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): 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): def is_completed_by(self, student):
return self.completions.filter(student=student).exists() 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): 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 self.priority is None:
# If priority is not set, set it to the next available priority # 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'] max_priority = self.course.lessons.aggregate(max_priority=models.Max('priority'))['max_priority']
@ -53,7 +85,6 @@ class Lesson(models.Model):
self._adjust_priorities() self._adjust_priorities()
super().save(*args, **kwargs) super().save(*args, **kwargs)
def _adjust_priorities(self): def _adjust_priorities(self):
# Adjust priorities of other lessons in the course # Adjust priorities of other lessons in the course
lessons = self.course.lessons.exclude(pk=self.pk) lessons = self.course.lessons.exclude(pk=self.pk)
@ -61,36 +92,14 @@ class Lesson(models.Model):
lessons.filter(priority__gte=self.priority).update(priority=models.F('priority') + 1) 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): class LessonCompletion(models.Model):
student = models.ForeignKey( student = models.ForeignKey(
StudentUser, StudentUser,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='lesson_completions' related_name='lesson_completions'
) )
lesson = models.ForeignKey(
Lesson,
course_lesson = models.ForeignKey(
CourseLesson,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='completions' related_name='completions'
) )
@ -98,9 +107,9 @@ class LessonCompletion(models.Model):
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
class Meta: class Meta:
unique_together = ('student', 'lesson')
unique_together = ('student', 'course_lesson')
def __str__(self): 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 dj_filer.admin import get_thumbs
from utils 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.chat.models import RoomMessage
from apps.account.serializers import UserProfileSerializer from apps.account.serializers import UserProfileSerializer
@ -254,7 +254,26 @@ class AttachmentSerializer(serializers.ModelSerializer):
fields = ['id', 'title', 'file', 'file_size'] 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 GlossarySerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Glossary model = Glossary
fields = ['id', 'title', 'description'] 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']

23
apps/course/serializers/lesson.py

@ -1,18 +1,25 @@
from rest_framework import serializers 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 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() is_complated = serializers.SerializerMethodField()
quizs = 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: class Meta:
model = Lesson
model = CourseLesson
fields = ['id', 'title', 'priority', 'is_active', 'permission', 'duration', 'content_type', 'content_file', 'video_link', 'is_complated', 'quizs'] fields = ['id', 'title', 'priority', 'is_active', 'permission', 'duration', 'content_type', 'content_file', 'video_link', 'is_complated', 'quizs']
def get_permission(self, obj): def get_permission(self, obj):
@ -46,12 +53,12 @@ class LessonSerializer(serializers.ModelSerializer):
return LessonCompletion.objects.filter( return LessonCompletion.objects.filter(
student=user, student=user,
lesson=obj
course_lesson=obj
).exists() ).exists()
def get_quizs(self, obj): 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 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 ( from apps.course.serializers import (
CourseListSerializer, CourseCategorySerializer, CourseDetailSerializer, 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 * from apps.course.doc import *
@ -175,7 +175,7 @@ class MyCourseListAPIView(ListAPIView):
class AttachmentListAPIView(ListAPIView): class AttachmentListAPIView(ListAPIView):
serializer_class = AttachmentSerializer
serializer_class = CourseAttachmentSerializer
@swagger_auto_schema( @swagger_auto_schema(
manual_parameters=[ manual_parameters=[
@ -197,15 +197,15 @@ class AttachmentListAPIView(ListAPIView):
course = Course.objects.get(slug=course_slug) course = Course.objects.get(slug=course_slug)
except Course.DoesNotExist: except Course.DoesNotExist:
raise NotFound("Course not found") raise NotFound("Course not found")
return Attachment.objects.filter(course=course)
return CourseAttachment.objects.filter(course=course)
class GlossaryListAPIView(ListAPIView): class GlossaryListAPIView(ListAPIView):
serializer_class = GlossarySerializer
serializer_class = CourseGlossarySerializer
filter_backends = [SearchFilter] filter_backends = [SearchFilter]
search_fields = ['title', 'description']
search_fields = ['glossary__title', 'glossary__description']
def get_queryset(self): def get_queryset(self):
course_slug = self.kwargs.get('slug') course_slug = self.kwargs.get('slug')
@ -214,7 +214,7 @@ class GlossaryListAPIView(ListAPIView):
except Course.DoesNotExist: except Course.DoesNotExist:
raise NotFound("Course not found") 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 rest_framework.response import Response
from apps.course.serializers import ( 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 apps.course.doc import *
from utils.exceptions import AppAPIException from utils.exceptions import AppAPIException
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@ -17,8 +17,8 @@ from rest_framework.permissions import IsAuthenticated
class LessonListView(ListAPIView): 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( @swagger_auto_schema(
operation_description=doc_courses_lesson(), operation_description=doc_courses_lesson(),
@ -39,66 +39,29 @@ class LessonListView(ListAPIView):
class LessonDetailView(RetrieveAPIView): class LessonDetailView(RetrieveAPIView):
serializer_class = LessonSerializer
serializer_class = CourseLessonSerializer
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
lesson_id = self.kwargs.get('id') 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() 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 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 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['total_lessons'] = total_lessons
lesson_data['current_lesson_number'] = current_lesson_number lesson_data['current_lesson_number'] = current_lesson_number
lesson_data['next_lesson_id'] = next_lesson_id lesson_data['next_lesson_id'] = next_lesson_id
lesson_data['previous_lesson_id'] = previous_lesson_id lesson_data['previous_lesson_id'] = previous_lesson_id
lesson_data['can_go_next'] = next_lesson is not None 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) return Response(lesson_data)
@ -129,16 +92,16 @@ class LessonCompletionCreateAPIView(GenericAPIView):
if not lesson_id: if not lesson_id:
return Response({'error': 'Lesson ID is required.'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'Lesson ID is required.'}, status=status.HTTP_400_BAD_REQUEST)
try: 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) return Response({'error': 'Lesson not found.'}, status=status.HTTP_404_NOT_FOUND)
# Check if the lesson is already completed by the student # 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) return Response({'message': 'Lesson already completed.'}, status=status.HTTP_200_OK)
# Create a new completion record # Create a new completion record
completion = LessonCompletion(student=student, lesson=lesson)
completion = LessonCompletion(student=student, course_lesson=course_lesson)
completion.save() completion.save()
return Response({'message': 'Lesson completed successfully.'}, status=status.HTTP_201_CREATED) return Response({'message': 'Lesson completed successfully.'}, status=status.HTTP_201_CREATED)

80
config/settings/base.py

@ -436,10 +436,9 @@ UNFOLD = {
"page": "courses", "page": "courses",
"models": [ "models": [
"course.course", "course.course",
"course.coursecategory",
"course.lesson",
"course.glossary",
"course.attachment",
"course.courselesson",
"course.courseglossary",
"course.courseattachment",
"quiz.quiz", "quiz.quiz",
], ],
"items": [ "items": [
@ -450,22 +449,22 @@ UNFOLD = {
"active": lambda request: request.path.startswith(str(reverse_lazy("admin:course_course_changelist"))), "active": lambda request: request.path.startswith(str(reverse_lazy("admin:course_course_changelist"))),
}, },
{ {
"title": _("Lessons"),
"title": _("Course Lessons"),
"icon": "menu_book", "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", "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", "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"), "title": _("Quizzes"),
@ -538,41 +537,47 @@ UNFOLD = {
] ]
}, },
{
"title": _(""),
"items": [
{
"title": _("Certificates"),
"icon": "workspace_premium",
"link": reverse_lazy("admin:certificate_certificate_changelist"),
},
]
},
{ {
"title": _("Courses"), "title": _("Courses"),
"collapsible": True, "collapsible": True,
"separator": True, "separator": True,
"items": [ "items": [
{
"title": _("Categories"),
"icon": "category",
"link": reverse_lazy("admin:course_coursecategory_changelist"),
},
{ {
"title": _("Courses"), "title": _("Courses"),
"icon": "school", "icon": "school",
"link": reverse_lazy("admin:course_course_changelist"), "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": _("Certificates"),
"icon": "workspace_premium",
"link": reverse_lazy("admin:certificate_certificate_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": _("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"),
# },
] ]
}, },
{ {
@ -598,7 +603,12 @@ UNFOLD = {
# You can add more preference sections here # You can add more preference sections here
], ],
}, },
# "STYLES": [
# lambda request: static("css/styles.css"),
# ],
# "SCRIPTS": [
# lambda request: static("js/scripts.js"),
# ],
# { # {
# "title": _("Hadis"), # "title": _("Hadis"),
# "collapsible": True, # "collapsible": True,

Loading…
Cancel
Save