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.
543 lines
19 KiB
543 lines
19 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, render
|
|
from django.urls import reverse_lazy, reverse
|
|
|
|
from unfold.admin import ModelAdmin, TabularInline
|
|
from unfold.decorators import action, display
|
|
from unfold.sections import TableSection
|
|
from unfold.contrib.filters.admin import (
|
|
ChoicesDropdownFilter,
|
|
MultipleRelatedDropdownFilter,
|
|
RangeDateFilter,
|
|
RangeNumericFilter,
|
|
TextFilter,
|
|
)
|
|
from unfold.widgets import UnfoldAdminSelectWidget
|
|
|
|
from .professor_base import DirectCourseAdmin, CourseRelatedAdmin, AttachmentGlossaryBaseAdmin
|
|
from utils.admin import project_admin_site, dovoodi_admin_site
|
|
from utils.json_editor_field import JsonEditorWidget
|
|
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
|
|
|
|
|
|
class CourseTableSection(TableSection):
|
|
verbose_name = _("Course Categories")
|
|
related_name = "courses"
|
|
height = 380
|
|
fields = [
|
|
"title",
|
|
"status",
|
|
"edit_link"
|
|
]
|
|
|
|
def edit_link(self, instance):
|
|
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',)
|
|
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.'),
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.fields['short_description'].required = True
|
|
if 'thumbnail' in self.fields:
|
|
self.fields['thumbnail'].required = True
|
|
|
|
def clean(self):
|
|
cleaned_data = super().clean()
|
|
thumbnail = cleaned_data.get('thumbnail')
|
|
has_existing_thumbnail = bool(getattr(self.instance, 'thumbnail', None))
|
|
|
|
if thumbnail is False:
|
|
self.add_error('thumbnail', _('This field is required and cannot be cleared.'))
|
|
return cleaned_data
|
|
|
|
if (thumbnail is None or thumbnail == '') and not has_existing_thumbnail:
|
|
self.add_error('thumbnail', _('This field is required.'))
|
|
return cleaned_data
|
|
|
|
# --- WIDTH ENFORCEMENT & PLACEHOLDER TEXT FOR DROPDOWNS ---
|
|
class MinWidthInlineForm(forms.ModelForm):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
target_dropdown_fields = ['lesson', 'attachment', 'glossary', 'student']
|
|
|
|
for field_name, field in self.fields.items():
|
|
if field_name in target_dropdown_fields:
|
|
if hasattr(field.widget, 'attrs'):
|
|
existing_class = field.widget.attrs.get('class', '')
|
|
field.widget.attrs['class'] = f"{existing_class} min-w-[250px] w-full"
|
|
|
|
existing_style = field.widget.attrs.get('style', '')
|
|
field.widget.attrs['style'] = f"{existing_style} min-width: 250px; width: 250px;"
|
|
|
|
# 🔔 Add the custom placeholder text
|
|
if hasattr(field, 'empty_label'):
|
|
field.empty_label = _("Select a value")
|
|
|
|
|
|
class CourseAttachmentInline(TabularInline):
|
|
model = CourseAttachment
|
|
form = MinWidthInlineForm
|
|
extra = 1 # Show 1 empty dropdown by default
|
|
fields = ('attachment',)
|
|
tab = True
|
|
# Removed autocomplete_fields to restore Unfold UI
|
|
verbose_name = _("Course Attachment")
|
|
verbose_name_plural = _("Course Attachments")
|
|
|
|
|
|
class CourseGlossaryInline(TabularInline):
|
|
model = CourseGlossary
|
|
form = MinWidthInlineForm
|
|
fields = ('glossary',)
|
|
extra = 1 # Show 1 empty dropdown by default
|
|
tab = True
|
|
tab_id = "glossaries_tab"
|
|
show_change_link = True
|
|
# Removed autocomplete_fields to restore Unfold UI
|
|
|
|
|
|
class CourseLessonInline(TabularInline):
|
|
model = CourseLesson
|
|
form = MinWidthInlineForm
|
|
fields = ('lesson', 'title', 'is_active', 'priority',)
|
|
extra = 1 # Show 1 empty dropdown by default
|
|
tab = True
|
|
tab_id = "lessons_tab"
|
|
show_change_link = True
|
|
ordering_field = "priority"
|
|
# Removed autocomplete_fields to restore Unfold UI
|
|
|
|
|
|
class ParticipantInline(TabularInline):
|
|
model = Participant
|
|
form = MinWidthInlineForm
|
|
fields = ('student', 'joined_date',)
|
|
readonly_fields = ('joined_date', 'student')
|
|
extra = 0 # Remains 0 because users are added via action buttons
|
|
tab = True
|
|
tab_id = "participants_tab"
|
|
verbose_name = _("Recent Participant")
|
|
verbose_name_plural = _("Recent Participants (Latest 10)")
|
|
show_change_link = True
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
object_id = request.resolver_match.kwargs.get('object_id')
|
|
|
|
if object_id:
|
|
latest_ids = list(qs.filter(course_id=object_id).order_by('-joined_date').values_list('id', flat=True)[:10])
|
|
return qs.filter(id__in=latest_ids).order_by('-joined_date')
|
|
|
|
return qs.none()
|
|
|
|
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
|
|
|
|
|
|
class AddStudentForm(forms.Form):
|
|
student = forms.ModelChoiceField(
|
|
queryset=User.objects.filter(is_active=True , email__isnull=False),
|
|
label=_("Select Student"),
|
|
widget=UnfoldAdminSelectWidget,
|
|
required=True
|
|
)
|
|
|
|
|
|
class CourseAdmin(DirectCourseAdmin):
|
|
form = CourseForm
|
|
inlines = [CourseLessonInline, CourseAttachmentInline, CourseGlossaryInline, 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
|
|
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,
|
|
}
|
|
|
|
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', 'description', 'short_description')
|
|
}),
|
|
(_('Settings & Status'), {
|
|
'fields': (
|
|
('status', 'level'),
|
|
('duration', 'lessons_count'),
|
|
('is_group_chat_locked', 'is_professor_chat_locked'),
|
|
('is_online', 'online_link')
|
|
),
|
|
'classes': ['tab'],
|
|
}),
|
|
(_('Media'), {
|
|
'fields': (
|
|
('video_type', 'video_file', 'video_link'),
|
|
),
|
|
'classes': ['tab'],
|
|
}),
|
|
(_('Pricing'), {
|
|
'fields': (
|
|
('is_free', 'price'),
|
|
('discount_percentage', 'final_price')
|
|
),
|
|
'classes': ['tab'],
|
|
}),
|
|
(_('Advanced Configuration'), {
|
|
'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,
|
|
None, 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 = ["add_student_to_course"]
|
|
actions_detail = ['add_student_to_course', 'manage_all_students']
|
|
|
|
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)
|
|
return course and request.user.can_manage_course(course)
|
|
except Exception as e:
|
|
return False
|
|
|
|
@action(
|
|
description=_("Add Student"),
|
|
icon="person_add",
|
|
permissions=["is_course_professor"],
|
|
)
|
|
def add_student_to_course(self, request, object_id):
|
|
course = self.get_object(request, object_id)
|
|
if not course:
|
|
messages.error(request, _("Course not found"))
|
|
return redirect(admin_url_generator(request, "course_course_changelist"))
|
|
|
|
if request.method == 'POST':
|
|
form = AddStudentForm(request.POST)
|
|
if form.is_valid():
|
|
student = form.cleaned_data['student']
|
|
if Participant.objects.filter(student=student, course=course).exists():
|
|
messages.warning(request, _("Student {} is already enrolled in this course").format(student.fullname))
|
|
else:
|
|
if not student.has_role('student'):
|
|
student.add_role('student')
|
|
Participant.objects.create(student=student, course=course)
|
|
messages.success(request, _("Student {} has been successfully added to {}").format(student.fullname, course.title))
|
|
return redirect(admin_url_generator(request, "course_course_changelist"))
|
|
else:
|
|
form = AddStudentForm()
|
|
|
|
return render(
|
|
request,
|
|
"course/add_student_form.html",
|
|
{
|
|
"form": form,
|
|
"object": object,
|
|
"title": _("Add Student to {}").format(course.title),
|
|
**self.admin_site.each_context(request),
|
|
},
|
|
)
|
|
|
|
@action(
|
|
description=_("Manage All Students"),
|
|
icon="groups",
|
|
permissions=["is_course_professor"],
|
|
)
|
|
def manage_all_students(self, request, object_id):
|
|
course = self.get_object(request, object_id)
|
|
if not course:
|
|
messages.error(request, _("Course not found"))
|
|
return redirect(admin_url_generator(request, "course_course_changelist"))
|
|
|
|
base_url = admin_url_generator(request, "course_participant_changelist")
|
|
url = f"{base_url}?course__id__exact={object_id}"
|
|
return redirect(url)
|
|
|
|
class GlossaryAdmin(AttachmentGlossaryBaseAdmin):
|
|
list_display = ('title', 'description')
|
|
search_fields = ('title', 'description')
|
|
ordering = ('-id',)
|
|
|
|
def is_used_in_professor_courses(self, user, obj):
|
|
return obj.courseglossary_set.filter(course__professor=user).exists()
|
|
|
|
def filter_by_professor_usage(self, user, queryset):
|
|
return queryset.filter(courseglossary__course__professor=user).distinct()
|
|
|
|
|
|
class CourseGlossaryAdmin(CourseRelatedAdmin):
|
|
list_display = ('course', 'glossary_title', 'glossary_description')
|
|
list_filter = ('course',)
|
|
search_fields = ('glossary__title', 'glossary__description', 'course__title')
|
|
ordering = ('-id',)
|
|
autocomplete_fields = ('course', 'glossary')
|
|
|
|
# 🔔 REMOVES FROM "ALL APPLICATIONS" MODAL
|
|
def has_module_permission(self, request):
|
|
return False
|
|
|
|
@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 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)
|
|
base_length = int(allowed_length * 0.8)
|
|
hash_length = allowed_length - base_length
|
|
base_part = base_name[:base_length]
|
|
hash_part = hashlib.sha256(base_name.encode('utf-8')).hexdigest()[:hash_length]
|
|
return f"{base_part}{hash_part}{ext}"
|
|
return file_name
|
|
|
|
|
|
class AttachmentAdmin(AttachmentGlossaryBaseAdmin):
|
|
form = AttachmentAdminForm
|
|
list_display = ('title', 'file', 'file_size')
|
|
search_fields = ('title', 'file')
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
if obj.file:
|
|
obj.file_size = obj.file.size
|
|
super().save_model(request, obj, form, change)
|
|
|
|
def is_used_in_professor_courses(self, user, obj):
|
|
return obj.courseattachment_set.filter(course__professor=user).exists()
|
|
|
|
def filter_by_professor_usage(self, user, queryset):
|
|
return queryset.filter(courseattachment__course__professor=user).distinct()
|
|
|
|
|
|
class CourseAttachmentAdmin(CourseRelatedAdmin):
|
|
list_display = ('course', 'attachment_title', 'attachment_file', 'attachment_file_size')
|
|
list_filter = ('course',)
|
|
search_fields = ('attachment__title', 'course__title')
|
|
autocomplete_fields = ('course', 'attachment')
|
|
|
|
# 🔔 REMOVES FROM "ALL APPLICATIONS" MODAL
|
|
def has_module_permission(self, request):
|
|
return False
|
|
|
|
@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
|
|
|
|
|
|
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', 'is_active')
|
|
}),
|
|
(_('Enrollment Details'), {
|
|
'fields': ('joined_date', 'unread_messages_count')
|
|
}),
|
|
)
|
|
|
|
@display(description=_("Student"), header=True)
|
|
def student_name(self, instance):
|
|
from django.templatetags.static import static
|
|
|
|
avatar_path = instance.student.avatar.url if getattr(instance.student, 'avatar', None) else static("images/reading(1).png")
|
|
|
|
return [
|
|
instance.student.fullname,
|
|
None,
|
|
None,
|
|
{
|
|
"path": avatar_path,
|
|
"height": 30,
|
|
"width": 36,
|
|
"borderless": True,
|
|
},
|
|
]
|
|
|
|
@display(description=_("Course"))
|
|
def course_title(self, obj):
|
|
if obj.course:
|
|
return obj.course.title
|
|
return "-"
|
|
|
|
|
|
# =========================================================
|
|
# REGISTRATIONS
|
|
# =========================================================
|
|
from django.contrib import admin as django_admin
|
|
|
|
try:
|
|
django_admin.site.register(Course, CourseAdmin)
|
|
django_admin.site.register(CourseCategory, CourseCategoryAdmin)
|
|
django_admin.site.register(Glossary, GlossaryAdmin)
|
|
django_admin.site.register(Attachment, AttachmentAdmin)
|
|
except django_admin.sites.AlreadyRegistered:
|
|
pass
|
|
|
|
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)
|
|
|
|
class HiddenCourseAdmin(ModelAdmin):
|
|
search_fields = ('title', 'id')
|
|
|
|
def has_module_permission(self, request):
|
|
return False
|
|
def has_add_permission(self, request):
|
|
return False
|
|
def has_change_permission(self, request, obj=None):
|
|
return False
|
|
def has_delete_permission(self, request, obj=None):
|
|
return False
|
|
|
|
dovoodi_admin_site.register(Course, HiddenCourseAdmin)
|