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)