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 apps.quiz.models import Quiz 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 CourseQuizInline(TabularInline): model = Quiz form = MinWidthInlineForm # فیلدهایی که می‌خواهید در لیست تب نمایش داده شوند fields = ('title', 'lesson', 'status') readonly_fields = ('title', 'lesson', 'status') extra = 0 tab = True tab_id = "quizzes_tab" # 🎯 این خط جادویی است! یک لینک برای رفتن به صفحه دیتیل کوییز اضافه می‌کند show_change_link = True verbose_name = _("Quiz") verbose_name_plural = _("Quizzes") # ما فقط می‌خواهیم این تب نمایشی باشد، پس دسترسی اضافه/تغییر/حذف را در این تب می‌بندیم 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 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, CourseQuizInline, 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)