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.
 
 

599 lines
20 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
from django.urls import reverse_lazy
from unfold.admin import ModelAdmin, StackedInline, TabularInline
from unfold.decorators import action, display
from unfold.contrib.forms.widgets import WysiwygWidget
from unfold.sections import TableSection
from unfold.contrib.filters.admin import (
ChoicesDropdownFilter,
MultipleRelatedDropdownFilter,
RangeDateFilter,
RangeNumericFilter,
TextFilter,
)
from unfold.widgets import (
UnfoldAdminColorInputWidget,
UnfoldAdminRadioSelectWidget,
UnfoldAdminSelectWidget,
UnfoldAdminSplitDateTimeWidget,
UnfoldAdminTextInputWidget,
)
from .professor_base import DirectCourseAdmin, CourseRelatedAdmin, AttachmentGlossaryBaseAdmin
from unfold.contrib.forms.widgets import ArrayWidget
from django.contrib.postgres.fields import ArrayField
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):
from django.utils.html import format_html
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',)
# exclude = ('slug', )
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)
# Make short_description required
self.fields['short_description'].required = True
# Make thumbnail required (show required star in add/change forms)
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))
# Disallow clearing the existing thumbnail (must always have a value)
if thumbnail is False:
self.add_error('thumbnail', _('This field is required and cannot be cleared.'))
return cleaned_data
# On create or when no existing thumbnail, require uploading one
if (thumbnail is None or thumbnail == '') and not has_existing_thumbnail:
self.add_error('thumbnail', _('This field is required.'))
return cleaned_data
class CourseAttachmentInline(StackedInline):
model = CourseAttachment
extra = 0
fields = ('attachment',)
tab = True
autocomplete_fields = ('attachment',)
class CourseGlossaryInline(StackedInline):
model = CourseGlossary
fields = ('glossary',)
extra = 0
tab = True
show_change_link = True
autocomplete_fields = ('glossary',)
class CourseLessonInline(StackedInline):
model = CourseLesson
fields = ('lesson', 'title', 'is_active', 'priority',)
extra = 0
tab = True
show_change_link = True
ordering_field = "priority"
autocomplete_fields = ('lesson',)
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',)
}),
(_('Enrollment Details'), {
'fields': ('joined_date', 'last_activity', 'progress')
}),
)
@display(description=_("Student"), header=True)
def student_name(self, instance: StudentUser):
from django.templatetags.static import static
# Get avatar image path - use user's avatar if available, otherwise use default
avatar_path = instance.student.avatar.url if instance.student.avatar else static("images/reading(1).png")
return [
instance.student.fullname,
None,
None,
{
"path": avatar_path,
"height": 30,
"width": 36,
"borderless": True,
# "squared": True,
},
]
@admin.display(description=_("Course"))
def course_title(self, obj):
if obj.course:
return obj.course.title
return "-"
class ParticipantInline(TabularInline):
model = Participant
fields = ('student', 'joined_date', )
readonly_fields = ('joined_date', 'student')
extra = 0
tab = True
verbose_name = _("Participant")
verbose_name_plural = _("Participants")
show_change_link = True
autocomplete_fields = ('student',)
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.order_by('-joined_date')
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
from django.urls import reverse
from django import forms
from django.shortcuts import render, redirect
from django.contrib import messages
from unfold.widgets import UnfoldAdminSelectWidget
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
# compressed_fields = 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,
}
# formfield_overrides = {
# models.TextField: {
# "widget": WysiwygWidget,
# },
# }
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')
}),
(_('Status'), {
'fields': ('status', 'is_online', 'online_link'),
}),
(_('Chat Settings'), {
'fields': ('is_group_chat_locked', 'is_professor_chat_locked'),
}),
(_('Course Details'), {
'fields': ('description', 'short_description', 'level', 'duration', 'lessons_count',),
# 'classes': ['tab'],
}),
(_('Media'), {
'fields': ('video_type', 'video_file', 'video_link'),
}),
(_('Pricing'), {
'fields': ('is_free', 'price', 'discount_percentage', 'final_price'),
}),
(_('Timing & Features'), {
'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,
# instance.short_description or _("No description"),
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 = [
"view_course_lessons",
"add_student_to_course"
]
actions_detail = ['add_student_to_course',]
@action(
description=_("View Lessons"),
icon="menu_book",
url_path="actions-row-custom-url",
permissions=[
"is_course_professor",
],
)
def view_course_lessons(self, request, object_id):
"""Navigate to the list of lessons for this course."""
course = self.get_object(request, object_id)
if not course:
messages.error(request, _("Course not found"))
return redirect(request.META.get("HTTP_REFERER") or reverse_lazy("admin:course_course_changelist"))
# Redirect to the lesson list filtered by this course
from django.urls import reverse
url = f"{reverse('admin:course_lesson_changelist')}?course__id__exact={course.id}"
return redirect(url)
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)
# Check if the current user can manage this course
return course and request.user.can_manage_course(course)
except Exception as e:
print(e)
return False
@action(
description=_("Add Student to Course"),
icon="person_add",
permissions=[
"is_course_professor",
],
)
def add_student_to_course(self, request, object_id):
"""Add a student to this course as a participant."""
course = self.get_object(request, object_id)
if not course:
messages.error(request, _("Course not found"))
return redirect(reverse("admin:course_course_changelist"))
if request.method == 'POST':
form = AddStudentForm(request.POST)
if form.is_valid():
student = form.cleaned_data['student']
# Check if the student is already a participant
if Participant.objects.filter(student=student, course=course).exists():
messages.warning(request, _(f"Student {student.fullname} is already enrolled in this course"))
else:
# اطمینان از اینکه کاربر نقش student دارد
if not student.has_role('student'):
student.add_role('student')
# Create a new participant
Participant.objects.create(
student=student,
course=course,
)
messages.success(
request,
_(f"Student {student.fullname} has been successfully added to {course.title}")
)
return redirect(reverse("admin:course_course_changelist"))
else:
form = AddStudentForm()
return render(
request,
"course/add_student_form.html",
{
"form": form,
"object": object,
"title": _("Change detail action for {}").format(object),
**self.admin_site.each_context(request),
},
)
class GlossaryAdmin(AttachmentGlossaryBaseAdmin):
list_display = ('title', 'description')
search_fields = ('title', 'description')
ordering = ('-id',)
def is_used_in_professor_courses(self, user, obj):
"""آیا این glossary در دوره‌های استاد استفاده شده؟"""
return obj.courseglossary_set.filter(course__professor=user).exists()
def filter_by_professor_usage(self, user, queryset):
"""فیلتر کردن glossary ها بر اساس استفاده در دوره‌های استاد"""
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')
@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) # طول مجاز نام بدون پسوند
# 80٪ از نام اصلی و 20٪ هش
base_length = int(allowed_length * 0.8) # 80٪ از طول مجاز
hash_length = allowed_length - base_length # 20٪ از طول مجاز
base_part = base_name[:base_length] # 80٪ اول نام اصلی
hash_part = hashlib.sha256(base_name.encode('utf-8')).hexdigest()[:hash_length] # 20٪ هش
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):
"""آیا این attachment در دوره‌های استاد استفاده شده؟"""
return obj.courseattachment_set.filter(course__professor=user).exists()
def filter_by_professor_usage(self, user, queryset):
"""فیلتر کردن attachment ها بر اساس استفاده در دوره‌های استاد"""
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')
@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
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 admin.sites.AlreadyRegistered:
pass
# =========================================================
# 2. REGISTER TO PROJECT ADMIN (The Full Admin Panel)
# =========================================================
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)
# =========================================================
# 3. REGISTER TO DOVOODI ADMIN (The "Ghost" Fix)
# =========================================================
# IMPORTANT: Do NOT inherit from CourseAdmin. Inherit from ModelAdmin directly.
# This prevents dragging in 'inlines' and 'autocomplete_fields' that cause errors.
class HiddenCourseAdmin(ModelAdmin):
# We only need search_fields so the Autocomplete box works
search_fields = ('title', 'id')
# No inlines
# No autocomplete_fields
# No fieldsets
# Hide from the Menu
def has_module_permission(self, request):
return False
# Disable all permissions
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)