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( '' 'visibility' '', 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('{}', 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('{}', _("Free")) if instance.discount_percentage > 0: return format_html( '${}' '${}', instance.price, instance.final_price ) return format_html('${}', 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)