You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
480 lines
15 KiB
480 lines
15 KiB
import os
|
|
import hashlib
|
|
|
|
from django.contrib import admin
|
|
from django.contrib import messages
|
|
from django import forms
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.db import models
|
|
from django.utils.html import format_html
|
|
from django.shortcuts import redirect
|
|
from django.urls import reverse_lazy
|
|
|
|
from unfold.admin import ModelAdmin, StackedInline, TabularInline
|
|
from unfold.decorators import action, display
|
|
from unfold.contrib.forms.widgets import WysiwygWidget
|
|
from unfold.sections import TableSection
|
|
from unfold.contrib.filters.admin import (
|
|
ChoicesDropdownFilter,
|
|
MultipleRelatedDropdownFilter,
|
|
RangeDateFilter,
|
|
RangeNumericFilter,
|
|
TextFilter,
|
|
)
|
|
from unfold.widgets import (
|
|
UnfoldAdminColorInputWidget,
|
|
UnfoldAdminRadioSelectWidget,
|
|
UnfoldAdminSelectWidget,
|
|
UnfoldAdminSplitDateTimeWidget,
|
|
UnfoldAdminTextInputWidget,
|
|
)
|
|
|
|
from unfold.contrib.forms.widgets import ArrayWidget
|
|
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.account.models import StudentUser
|
|
|
|
from utils.schema import get_weekly_timing_schema, get_course_feature_schema
|
|
|
|
|
|
class CourseTableSection(TableSection):
|
|
verbose_name = _("Course Categories")
|
|
related_name = "courses"
|
|
height = 380
|
|
fields = [
|
|
"title",
|
|
"status",
|
|
"edit_link"
|
|
]
|
|
|
|
def edit_link(self, instance):
|
|
from django.utils.html import format_html
|
|
|
|
return format_html(
|
|
'<a href="/admin/course/course/{}/change/" class="leading-none">'
|
|
'<span class="material-symbols-outlined leading-none text-base-500">visibility</span>'
|
|
'</a>',
|
|
instance.id
|
|
)
|
|
|
|
edit_link.short_description = _("Edit")
|
|
|
|
|
|
class CourseCategoryAdmin(ModelAdmin):
|
|
list_display = ('name', 'slug', 'course_count')
|
|
search_fields = ('name',)
|
|
# exclude = ('slug', )
|
|
|
|
list_sections = [CourseTableSection]
|
|
fieldsets = (
|
|
(None, {
|
|
'fields': ('name', 'slug')
|
|
}),
|
|
)
|
|
|
|
@display(description=_("Courses"))
|
|
def course_count(self, obj):
|
|
count = obj.courses.all().count()
|
|
return format_html(
|
|
'<span class="badge badge-primary">{}</span>',
|
|
count
|
|
)
|
|
|
|
|
|
|
|
class CourseForm(forms.ModelForm):
|
|
class Meta:
|
|
model = Course
|
|
fields = '__all__'
|
|
exclude = ('slug',)
|
|
widgets = {
|
|
'timing': JsonEditorWidget(attrs={
|
|
'schema': get_weekly_timing_schema(),
|
|
'title': _('Course Weekly Schedule'),
|
|
}),
|
|
'features': JsonEditorWidget(attrs={
|
|
'schema': get_course_feature_schema(),
|
|
'title': _('Course Features'),
|
|
}),
|
|
}
|
|
help_texts = {
|
|
'status': 'If set to inactive, the course will not be displayed.',
|
|
}
|
|
|
|
|
|
class AttachmentInline(TabularInline):
|
|
model = Attachment
|
|
extra = 0
|
|
fields = ('title', 'file', 'file_size')
|
|
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)
|
|
|
|
|
|
class GlossaryInline(StackedInline):
|
|
model = Glossary
|
|
fields = ('title', 'description')
|
|
extra = 0
|
|
tab = True
|
|
show_change_link = True
|
|
|
|
|
|
class LessonInline(StackedInline):
|
|
model = Lesson
|
|
fields = ('title', 'is_active', 'duration', 'content_type', 'content_file', 'video_link', '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'",
|
|
}
|
|
|
|
|
|
class ParticipantAdmin(ModelAdmin):
|
|
list_display = ('student_name', 'course_title', 'joined_date',)
|
|
list_filter = (
|
|
('course', MultipleRelatedDropdownFilter),
|
|
)
|
|
search_fields = ('student__email', 'student__fullname', 'course__title')
|
|
readonly_fields = ('joined_date',)
|
|
autocomplete_fields = ('student', 'course')
|
|
fieldsets = (
|
|
(None, {
|
|
'fields': ('student', 'course',)
|
|
}),
|
|
(_('Enrollment Details'), {
|
|
'fields': ('joined_date', 'last_activity', 'progress')
|
|
}),
|
|
)
|
|
|
|
@display(description=_("Student"), header=True)
|
|
def student_name(self, instance: StudentUser):
|
|
from django.templatetags.static import static
|
|
|
|
# Get avatar image path - use user's avatar if available, otherwise use default
|
|
avatar_path = instance.student.avatar.url if instance.student.avatar else static("images/reading(1).png")
|
|
|
|
return [
|
|
instance.student.fullname,
|
|
None,
|
|
None,
|
|
{
|
|
"path": avatar_path,
|
|
"height": 30,
|
|
"width": 36,
|
|
"borderless": True,
|
|
# "squared": True,
|
|
},
|
|
]
|
|
|
|
@admin.display(description=_("Course"))
|
|
def course_title(self, obj):
|
|
if obj.course:
|
|
return obj.course.title
|
|
return "-"
|
|
|
|
class ParticipantInline(TabularInline):
|
|
model = Participant
|
|
fields = ('student', 'joined_date', )
|
|
readonly_fields = ('joined_date', 'student')
|
|
extra = 0
|
|
tab = True
|
|
verbose_name = _("Participant")
|
|
verbose_name_plural = _("Participants")
|
|
show_change_link = True
|
|
|
|
autocomplete_fields = ('student',)
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.order_by('-joined_date')
|
|
def has_add_permission(self, request, obj):
|
|
return False
|
|
|
|
def has_change_permission(self, request, obj=None):
|
|
return False
|
|
|
|
def has_delete_permission(self, request, obj=None):
|
|
return False
|
|
|
|
|
|
|
|
|
|
from django.urls import reverse
|
|
|
|
from django import forms
|
|
from django.shortcuts import render, redirect
|
|
from django.contrib import messages
|
|
|
|
from unfold.widgets import UnfoldAdminSelectWidget
|
|
|
|
class AddStudentForm(forms.Form):
|
|
student = forms.ModelChoiceField(
|
|
queryset=StudentUser.objects.filter(is_active=True),
|
|
label=_("Select Student"),
|
|
widget=UnfoldAdminSelectWidget,
|
|
required=True
|
|
)
|
|
|
|
|
|
class CourseAdmin(ModelAdmin):
|
|
form = CourseForm
|
|
inlines = [LessonInline, AttachmentInline, GlossaryInline, ParticipantInline]
|
|
list_display = ('display_header', 'category', 'display_professor', 'status', 'display_price', 'is_online')
|
|
list_filter = [
|
|
('status', ChoicesDropdownFilter),
|
|
('level', ChoicesDropdownFilter),
|
|
'is_online',
|
|
'is_free',
|
|
('category', MultipleRelatedDropdownFilter),
|
|
('price', RangeNumericFilter),
|
|
]
|
|
save_as = True
|
|
warn_unsaved_form = True
|
|
# compressed_fields = True
|
|
search_fields = ('id','title', 'description')
|
|
exclude = ('slug', )
|
|
readonly_fields = ('final_price',)
|
|
autocomplete_fields = ('category', 'professor',)
|
|
list_filter_submit = True
|
|
change_form_show_cancel_button = True
|
|
radio_fields = {
|
|
"video_type": admin.HORIZONTAL,
|
|
"status": admin.HORIZONTAL,
|
|
"level": admin.HORIZONTAL,
|
|
}
|
|
show_facets = admin.ShowFacets.ALLOW
|
|
formfield_overrides = {
|
|
models.TextField: {
|
|
"widget": WysiwygWidget,
|
|
},
|
|
}
|
|
conditional_fields = {
|
|
'price': "is_free == false",
|
|
'discount_percentage': "is_free == false",
|
|
'final_price': "is_free == false",
|
|
'online_link': "is_online",
|
|
'video_file': "video_type == 'video_file'",
|
|
'video_link': "video_type == 'youtube_link'",
|
|
}
|
|
|
|
fieldsets = (
|
|
(None, {
|
|
'fields': ('title', 'category', 'professor', 'thumbnail')
|
|
}),
|
|
(_('Status'), {
|
|
'fields': ('status', 'is_online', 'online_link'),
|
|
}),
|
|
(_('Course Details'), {
|
|
'fields': ('description', 'short_description', 'level', 'duration', 'lessons_count',),
|
|
'classes': ['tab'],
|
|
}),
|
|
(_('Media'), {
|
|
'fields': ('video_type', 'video_file', 'video_link'),
|
|
}),
|
|
(_('Pricing'), {
|
|
'fields': ('is_free', 'price', 'discount_percentage', 'final_price'),
|
|
}),
|
|
(_('Timing & Features'), {
|
|
'fields': ('timing', 'features'),
|
|
'classes': ['tab'],
|
|
}),
|
|
)
|
|
|
|
|
|
@display(description=_("Course"), header=True)
|
|
def display_header(self, instance):
|
|
from django.templatetags.static import static
|
|
|
|
thumbnail_path = instance.thumbnail.url if instance.thumbnail else None
|
|
|
|
return [
|
|
instance.title,
|
|
instance.short_description or _("No description"),
|
|
None,
|
|
{
|
|
"path": thumbnail_path,
|
|
"height": 40,
|
|
"width": 60,
|
|
"squared": True,
|
|
"borderless": True,
|
|
},
|
|
]
|
|
|
|
@display(description=_("Professor"))
|
|
def display_professor(self, instance):
|
|
return instance.professor.fullname
|
|
|
|
@display(description=_("Price"))
|
|
def display_price(self, instance):
|
|
if instance.is_free:
|
|
return format_html('<span class="text-green-600 font-medium">{}</span>', _("Free"))
|
|
|
|
if instance.discount_percentage > 0:
|
|
return format_html(
|
|
'<span class="line-through text-gray-400 mr-2">${}</span>'
|
|
'<span class="text-green-600 font-medium">${}</span>',
|
|
instance.price,
|
|
instance.final_price
|
|
)
|
|
|
|
return format_html('<span>${}</span>', instance.final_price)
|
|
|
|
|
|
actions_row = [
|
|
"view_course_lessons",
|
|
"add_student_to_course"
|
|
]
|
|
actions_detail = ['add_student_to_course',]
|
|
|
|
|
|
@action(
|
|
description=_("View Lessons"),
|
|
icon="menu_book",
|
|
url_path="actions-row-custom-url",
|
|
permissions=[
|
|
"is_course_professor",
|
|
],
|
|
)
|
|
def view_course_lessons(self, request, object_id):
|
|
"""Navigate to the list of lessons for this course."""
|
|
course = self.get_object(request, object_id)
|
|
if not course:
|
|
messages.error(request, _("Course not found"))
|
|
return redirect(request.META.get("HTTP_REFERER") or reverse_lazy("admin:course_course_changelist"))
|
|
|
|
# Redirect to the lesson list filtered by this course
|
|
from django.urls import reverse
|
|
url = f"{reverse('admin:course_lesson_changelist')}?course__id__exact={course.id}"
|
|
return redirect(url)
|
|
|
|
|
|
def has_is_course_professor_permission(self, request, object_id=None):
|
|
|
|
try:
|
|
if request.user.is_staff:
|
|
return True
|
|
course = self.get_object(request, object_id)
|
|
# Check if the current user is the professor of this course
|
|
return course and hasattr(request.user, 'professor') and course.professor_id == request.user.id
|
|
except Exception as e:
|
|
print(e)
|
|
return False
|
|
|
|
|
|
|
|
|
|
@action(
|
|
description=_("Add Student to Course"),
|
|
icon="person_add",
|
|
permissions=[
|
|
"is_course_professor",
|
|
],
|
|
)
|
|
def add_student_to_course(self, request, object_id):
|
|
"""Add a student to this course as a participant."""
|
|
course = self.get_object(request, object_id)
|
|
if not course:
|
|
messages.error(request, _("Course not found"))
|
|
return redirect(reverse("admin:course_course_changelist"))
|
|
|
|
if request.method == 'POST':
|
|
form = AddStudentForm(request.POST)
|
|
if form.is_valid():
|
|
student = form.cleaned_data['student']
|
|
|
|
# Check if the student is already a participant
|
|
if Participant.objects.filter(student=student, course=course).exists():
|
|
messages.warning(request, _(f"Student {student.fullname} is already enrolled in this course"))
|
|
else:
|
|
# Create a new participant
|
|
Participant.objects.create(
|
|
student=student,
|
|
course=course,
|
|
)
|
|
messages.success(
|
|
request,
|
|
_(f"Student {student.fullname} has been successfully added to {course.title}")
|
|
)
|
|
|
|
return redirect(reverse("admin:course_course_changelist"))
|
|
else:
|
|
form = AddStudentForm()
|
|
|
|
return render(
|
|
request,
|
|
"course/add_student_form.html",
|
|
{
|
|
"form": form,
|
|
"object": object,
|
|
"title": _("Change detail action for {}").format(object),
|
|
**self.admin_site.each_context(request),
|
|
},
|
|
)
|
|
|
|
|
|
class GlossaryAdmin(ModelAdmin):
|
|
list_display = ('title', 'course', 'description')
|
|
list_filter = ('course',)
|
|
search_fields = ('title', 'description', 'course__title')
|
|
ordering = ('-id',)
|
|
|
|
|
|
class AttachmentAdminForm(forms.ModelForm):
|
|
class Meta:
|
|
model = Attachment
|
|
fields = '__all__'
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
if 'file' in self.data or 'file' in self.files:
|
|
file = self.files.get('file')
|
|
if file:
|
|
file.name = self._shorten_file_name(file.name)
|
|
|
|
|
|
def _shorten_file_name(self, file_name):
|
|
max_length = 100
|
|
if len(file_name) > max_length:
|
|
base_name, ext = os.path.splitext(file_name) # جدا کردن نام و پسوند
|
|
allowed_length = max_length - len(ext) # طول مجاز نام بدون پسوند
|
|
|
|
# 80٪ از نام اصلی و 20٪ هش
|
|
base_length = int(allowed_length * 0.8) # 80٪ از طول مجاز
|
|
hash_length = allowed_length - base_length # 20٪ از طول مجاز
|
|
|
|
base_part = base_name[:base_length] # 80٪ اول نام اصلی
|
|
hash_part = hashlib.sha256(base_name.encode('utf-8')).hexdigest()[:hash_length] # 20٪ هش
|
|
|
|
return f"{base_part}{hash_part}{ext}" # ترکیب بخش اصلی و هش با پسوند
|
|
|
|
return file_name
|
|
|
|
|
|
class AttachmentAdmin(ModelAdmin):
|
|
form = AttachmentAdminForm
|
|
list_display = ('title', 'course', 'file', 'file_size')
|
|
list_filter = ('course',)
|
|
search_fields = ('title', 'file', 'course__title')
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
if obj.file:
|
|
obj.file_size = obj.file.size
|
|
super().save_model(request, obj, form, change)
|
|
|
|
|
|
# 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(Attachment, AttachmentAdmin)
|
|
project_admin_site.register(Participant, ParticipantAdmin)
|