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.
 
 

544 lines
19 KiB

from utils.admin import admin_url_generator
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)