Browse Source

admin panel enhancement

updated the not supported lazy text fields

add inline tabs for course , livesession and quiz tabs

enhanced chat messages tab to manage messages better

updated the dropdown styles for better ux
master
Mohsen Taba 2 weeks ago
parent
commit
e8307160a0
  1. 255
      apps/chat/admin.py
  2. 388
      apps/course/admin/course.py
  3. 38
      apps/course/admin/lesson.py
  4. 83
      apps/course/admin/live_session.py
  5. 55
      apps/quiz/admin/participant.py
  6. 43
      apps/quiz/admin/quiz.py
  7. 28
      apps/transaction/admin.py
  8. 9
      utils/schema.py

255
apps/chat/admin.py

@ -2,26 +2,104 @@ from django.contrib import admin
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.db.models import Count
from django import forms
from unfold.admin import ModelAdmin, TabularInline
from unfold.contrib.filters.admin import RangeNumericFilter, RangeDateTimeFilter
from django.shortcuts import redirect
from django.urls import reverse
from unfold.decorators import action
from unfold.decorators import action, display
from django.contrib import messages
from apps.chat.models import RoomMessage, ChatMessage, MessageReadStatus
from utils.admin import project_admin_site
from utils.admin import project_admin_site, admin_url_generator
from django.contrib.auth import get_user_model
User = get_user_model()
# --- HELPER FUNCTION: GET ALLOWED USERS FOR A ROOM ---
def get_allowed_users_for_room(room):
"""
Returns a queryset of active users allowed in a specific room.
Private: Initiator + Recipient
Group: Initiator + Recipient + Course Professor + Active Course Participants
"""
allowed_ids = set()
if room.initiator_id:
allowed_ids.add(room.initiator_id)
if room.recipient_id:
allowed_ids.add(room.recipient_id)
if room.room_type == 'group' and room.course_id:
# Add Professor
if room.course.professor_id:
allowed_ids.add(room.course.professor_id)
# Add Active Participants
from apps.course.models import Participant
participant_ids = Participant.objects.filter(
course_id=room.course_id,
is_active=True
).values_list('student_id', flat=True)
allowed_ids.update(participant_ids)
return User.objects.filter(is_active=True, id__in=allowed_ids)
# --- WIDTH ENFORCEMENT FOR SELECT2 DROPDOWNS IN INLINES ---
class MinWidthInlineForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
target_dropdown_fields = ['sender', 'user']
for field_name, field in self.fields.items():
if field_name in target_dropdown_fields and 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;"
class ChatMessageInline(TabularInline):
model = ChatMessage
extra = 0
fields = ('sender', 'content', 'content_type', 'sent_at', 'is_deleted')
form = MinWidthInlineForm
extra = 1 # 🔔 Allows you to add 1 new message from the room tab
tab = True
# 🔔 Using real database fields so you can actually input new messages
fields = ('sender', 'content', 'content_type', 'file_attachment', 'sent_at', 'is_deleted')
readonly_fields = ('sent_at',)
can_delete = False
show_change_link = True
classes = ['collapse']
verbose_name = _("Message")
verbose_name_plural = _("Messages")
verbose_name = _("Recent Message")
verbose_name_plural = _("Recent Messages (Latest 50)")
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(room_id=object_id).order_by('-sent_at').values_list('id', flat=True)[:50])
return qs.filter(id__in=latest_ids).order_by('-sent_at')
return qs.none()
# 🔔 FILTER THE SENDER DROPDOWN IN THE TAB
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "sender":
room_id = request.resolver_match.kwargs.get('object_id')
if room_id:
try:
room = RoomMessage.objects.get(pk=room_id)
kwargs["queryset"] = get_allowed_users_for_room(room)
except RoomMessage.DoesNotExist:
kwargs["queryset"] = User.objects.filter(is_active=True)
else:
kwargs["queryset"] = User.objects.filter(is_active=True)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class MessageReadStatusAdmin(ModelAdmin):
@ -35,19 +113,17 @@ class MessageReadStatusAdmin(ModelAdmin):
search_fields = ('user__username', 'user__email', 'message__content')
readonly_fields = ('read_at',)
@display(description=_("Read Status"))
def is_read_status(self, obj):
if obj.is_read:
return format_html('<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Read</span>')
return format_html('<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Unread</span>')
return format_html('<span class="bg-green-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Read"))
return format_html('<span class="bg-red-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Unread"))
is_read_status.short_description = _("Read Status")
from django.contrib.auth import get_user_model
User = get_user_model()
class RoomMessageAdmin(ModelAdmin):
list_display = (
'name', 'room_type_badge', 'course', 'initiator',
'messages_count', 'view_messages_button','is_locked'
'messages_count', 'is_locked'
)
list_filter = (
'room_type',
@ -60,6 +136,8 @@ class RoomMessageAdmin(ModelAdmin):
readonly_fields = ('created_at', 'updated_at', 'messages_count')
inlines = [ChatMessageInline]
actions_detail = ['manage_all_messages']
fieldsets = (
(_("Room Information"), {
'fields': ('name', 'description', 'room_type', 'messages_count','is_locked'),
@ -76,26 +154,24 @@ class RoomMessageAdmin(ModelAdmin):
)
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "initiator":
kwargs["queryset"] = User.objects.filter(is_active=True, email__isnull=False)
if db_field.name == "recipient":
if db_field.name in ["initiator", "recipient"]:
kwargs["queryset"] = User.objects.filter(is_active=True, email__isnull=False)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@display(description=_("Messages Count"))
def messages_count(self, obj):
count = obj.messages.count()
return format_html('<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">{}</span>', count)
messages_count.short_description = _("Messages Count")
return format_html(
'<span class="bg-primary-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center min-w-[2rem]">'
'{}</span>', count
)
@display(description=_("Room Type"))
def room_type_badge(self, obj):
if obj.room_type == 'group':
return format_html('<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">Group</span>')
return format_html('<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">Private</span>')
room_type_badge.short_description = _("Room Type")
return format_html('<span class="bg-purple-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Group"))
return format_html('<span class="bg-indigo-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Private"))
def get_queryset(self, request):
queryset = super().get_queryset(request)
@ -104,21 +180,25 @@ class RoomMessageAdmin(ModelAdmin):
)
return queryset
def view_messages_button(self, obj):
from django.urls import reverse
url = f"{reverse('admin:chat_chatmessage_changelist')}?room__id__exact={obj.id}"
return format_html(
'<a href="{}" class="inline-flex items-center px-3 py-1.5 rounded text-xs font-medium bg-blue-500 text-white hover:bg-blue-600">'
'<span class="material-icons-outlined mr-1" style="font-size: 14px;">chat</span> {}</a>',
url, _("View Messages")
@action(
description=_("Manage All Messages"),
icon="chat",
)
view_messages_button.short_description = _("Messages")
def manage_all_messages(self, request, object_id):
"""Redirect to the pre-filtered Chat Message changelist for this room."""
room = self.get_object(request, object_id)
if not room:
messages.error(request, _("Room not found"))
return redirect(admin_url_generator(request, "chat_roommessage_changelist"))
base_url = admin_url_generator(request, "chat_chatmessage_changelist")
url = f"{base_url}?room__id__exact={object_id}"
return redirect(url)
class MessageReadStatusInline(TabularInline):
model = MessageReadStatus
form = MinWidthInlineForm
extra = 0
fields = ('user', 'is_read', 'read_at')
readonly_fields = ('read_at',)
@ -128,9 +208,22 @@ class MessageReadStatusInline(TabularInline):
verbose_name = _("Read Status")
verbose_name_plural = _("Read Statuses")
# 🔔 FILTER THE USER DROPDOWN IN THE READ STATUS TAB
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "user":
message_id = request.resolver_match.kwargs.get('object_id')
if message_id:
try:
msg = ChatMessage.objects.get(pk=message_id)
kwargs["queryset"] = get_allowed_users_for_room(msg.room)
except ChatMessage.DoesNotExist:
kwargs["queryset"] = User.objects.filter(is_active=True)
else:
kwargs["queryset"] = User.objects.filter(is_active=True)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class ChatMessageAdmin(ModelAdmin):
# change_list_template = 'admin/chat/chatmessage/change_list.html'
list_display = (
'id', 'room', 'sender', 'content_type_badge', 'content_preview',
'content_size_display', 'has_attachment', 'sent_at', 'is_deleted_status'
@ -172,100 +265,102 @@ class ChatMessageAdmin(ModelAdmin):
)
actions_list = ["back_to_chat_rooms"]
# 🔔 FILTER THE SENDER DROPDOWN IN THE CHAT MESSAGE FORM
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "sender":
message_id = request.resolver_match.kwargs.get('object_id')
if message_id:
try:
msg = ChatMessage.objects.get(pk=message_id)
kwargs["queryset"] = get_allowed_users_for_room(msg.room)
except ChatMessage.DoesNotExist:
kwargs["queryset"] = User.objects.filter(is_active=True)
else:
kwargs["queryset"] = User.objects.filter(is_active=True)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@action(
description=_("Back to Chat Rooms"),
icon="arrow_back", # Unfold natively supports Google Material Icons!
icon="arrow_back",
)
def back_to_chat_rooms(self, request):
"""Redirects the admin back to the RoomMessage list"""
url = reverse('admin:chat_roommessage_changelist')
return redirect(url)
@display(description=_("Content Preview"))
def content_preview(self, obj):
if obj.content_type == 'text':
preview = obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
return preview
return f"{obj.content_type} content"
content_preview.short_description = _("Content Preview")
return _("%(type)s content") % {'type': obj.get_content_type_display()}
@display(description=_("Type"))
def content_type_badge(self, obj):
badges = {
'text': ('blue', 'Text'),
'file': ('yellow', 'File'),
'audio': ('green', 'Audio'),
'image': ('pink', 'Image'),
'text': ('bg-green-500', _('Text')),
'file': ('bg-green-500', _('File')),
'audio': ('bg-green-500', _('Audio')),
'image': ('bg-green-500', _('Image')),
}
color, label = badges.get(obj.content_type, ('gray', obj.content_type))
bg_color, label = badges.get(obj.content_type, ('bg-gray-500', obj.content_type))
return format_html(
'<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{}-100 text-{}-800">{}</span>',
color, color, label
'<span class="{} text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center min-w-[4rem]">{}</span>',
bg_color, label
)
content_type_badge.short_description = _("Type")
@display(description=_("Status"))
def is_deleted_status(self, obj):
if obj.is_deleted:
return format_html('<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Deleted</span>')
return format_html('<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Active</span>')
is_deleted_status.short_description = _("Status")
return format_html('<span class="bg-red-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Deleted"))
return format_html('<span class="bg-green-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Active"))
@display(description=_("Size"))
def content_size_display(self, obj):
if obj.content_size:
# Format size in KB if larger than 1024 bytes
if obj.content_size > 1024:
size_kb = obj.content_size / 1024
return f"{size_kb:.1f} KB"
return f"{obj.content_size} bytes"
return _("{} bytes").format(obj.content_size)
return "-"
content_size_display.short_description = _("Size")
@display(description=_("Attachment"))
def has_attachment(self, obj):
"""Show if message has file/image attachment"""
if obj.image_attachment:
return format_html(
'<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">📷 Image</span>'
)
return format_html('<span class="bg-blue-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("📷 Image"))
elif obj.file_attachment:
return format_html(
'<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">📎 File</span>'
)
return format_html('<span class="bg-green-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("📎 File"))
elif obj.content and obj.content_type != 'text':
return format_html(
'<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">🔗 Legacy</span>'
)
return format_html('<span class="bg-orange-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("🔗 Legacy"))
return "-"
has_attachment.short_description = _("Attachment")
@display(description=_("Attachment Preview"))
def attachment_preview(self, obj):
"""Display attachment preview in detail view"""
if obj.image_attachment:
return format_html(
'<div><strong>Image:</strong><br/>'
'<div><strong>{}:</strong><br/>'
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-top: 10px;" />'
'<br/><a href="{}" target="_blank" style="margin-top: 10px; display: inline-block;">Open in new tab</a></div>',
'<br/><a href="{}" target="_blank" style="margin-top: 10px; display: inline-block;">{}</a></div>',
_("Image"),
obj.image_attachment.url,
obj.image_attachment.url,
obj.image_attachment.url
_("Open in new tab")
)
elif obj.file_attachment:
return format_html(
'<div><strong>File:</strong><br/>'
'<a href="{}" target="_blank" style="margin-top: 10px; display: inline-block; padding: 8px 16px; background: #3b82f6; color: white; border-radius: 4px; text-decoration: none;">📥 Download File</a></div>',
obj.file_attachment.url
'<div><strong>{}:</strong><br/>'
'<a href="{}" target="_blank" style="margin-top: 10px; display: inline-block; padding: 8px 16px; background: #3b82f6; color: white; border-radius: 4px; text-decoration: none;">{}</a></div>',
_("File"),
obj.file_attachment.url,
_("📥 Download File")
)
elif obj.content and obj.content_type != 'text':
return format_html(
'<div><strong>Legacy URL:</strong><br/><code style="background: #f3f4f6; padding: 4px 8px; border-radius: 4px;">{}</code></div>',
'<div><strong>{}:</strong><br/><code style="background: #f3f4f6; padding: 4px 8px; border-radius: 4px;">{}</code></div>',
_("Legacy URL"),
obj.content
)
return "-"
attachment_preview.short_description = _("Attachment Preview")
# Register models with the custom admin site
project_admin_site.register(RoomMessage, RoomMessageAdmin)
project_admin_site.register(ChatMessage, ChatMessageAdmin)
project_admin_site.register(MessageReadStatus, MessageReadStatusAdmin)

388
apps/course/admin/course.py

@ -7,12 +7,11 @@ 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 django.shortcuts import redirect, render
from django.urls import reverse_lazy, reverse
from unfold.admin import ModelAdmin, StackedInline, TabularInline
from unfold.admin import ModelAdmin, 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,
@ -21,24 +20,14 @@ from unfold.contrib.filters.admin import (
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 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
@ -53,23 +42,18 @@ class CourseTableSection(TableSection):
]
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, {
@ -80,20 +64,14 @@ class CourseCategoryAdmin(ModelAdmin):
@display(description=_("Courses"))
def course_count(self, obj):
count = obj.courses.all().count()
return format_html(
'<span class="badge badge-primary">{}</span>',
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(),
@ -105,14 +83,12 @@ class CourseForm(forms.ModelForm):
}),
}
help_texts = {
'status': 'If set to inactive, the course will not be displayed.',
'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
@ -121,122 +97,98 @@ class CourseForm(forms.ModelForm):
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
# --- 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(StackedInline):
class CourseAttachmentInline(TabularInline):
model = CourseAttachment
extra = 0
form = MinWidthInlineForm
extra = 1 # Show 1 empty dropdown by default
fields = ('attachment',)
tab = True
autocomplete_fields = ('attachment',)
# Removed autocomplete_fields to restore Unfold UI
verbose_name = _("Course Attachment")
verbose_name_plural = _("Course Attachments")
class CourseGlossaryInline(StackedInline):
class CourseGlossaryInline(TabularInline):
model = CourseGlossary
form = MinWidthInlineForm
fields = ('glossary',)
extra = 0
extra = 1 # Show 1 empty dropdown by default
tab = True
tab_id = "glossaries_tab"
show_change_link = True
autocomplete_fields = ('glossary',)
# Removed autocomplete_fields to restore Unfold UI
class CourseLessonInline(StackedInline):
class CourseLessonInline(TabularInline):
model = CourseLesson
form = MinWidthInlineForm
fields = ('lesson', 'title', 'is_active', 'priority',)
extra = 0
extra = 1 # Show 1 empty dropdown by default
tab = True
tab_id = "lessons_tab"
show_change_link = True
ordering_field = "priority"
autocomplete_fields = ('lesson',)
# Removed autocomplete_fields to restore Unfold UI
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
form = MinWidthInlineForm
fields = ('student', 'joined_date',)
readonly_fields = ('joined_date', 'student')
extra = 0
extra = 0 # Remains 0 because users are added via action buttons
tab = True
verbose_name = _("Participant")
verbose_name_plural = _("Participants")
tab_id = "participants_tab"
verbose_name = _("Recent Participant")
verbose_name_plural = _("Recent Participants (Latest 10)")
show_change_link = True
autocomplete_fields = ('student',)
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.order_by('-joined_date')
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
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),
@ -249,6 +201,7 @@ class AddStudentForm(forms.Form):
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),
@ -260,23 +213,19 @@ class CourseAdmin(DirectCourseAdmin):
]
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",
@ -288,27 +237,33 @@ class CourseAdmin(DirectCourseAdmin):
fieldsets = (
(None, {
'fields': ('title', 'category', 'professor', 'thumbnail')
}),
(_('Status'), {
'fields': ('status', 'is_online', 'online_link'),
'fields': ('title', 'category', 'professor', 'thumbnail', 'description', 'short_description')
}),
(_('Chat Settings'), {
'fields': ('is_group_chat_locked', 'is_professor_chat_locked'),
}),
(_('Course Details'), {
'fields': ('description', 'short_description', 'level', 'duration', 'lessons_count',),
# 'classes': ['tab'],
(_('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'),
'fields': (
('video_type', 'video_file', 'video_link'),
),
'classes': ['tab'],
}),
(_('Pricing'), {
'fields': ('is_free', 'price', 'discount_percentage', 'final_price'),
'fields': (
('is_free', 'price'),
('discount_percentage', 'final_price')
),
'classes': ['tab'],
}),
(_('Timing & Features'), {
(_('Advanced Configuration'), {
'fields': ('timing', 'features'),
# 'classes': ['tab'],
'classes': ['tab'],
}),
)
@ -316,14 +271,10 @@ class CourseAdmin(DirectCourseAdmin):
@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,
None, None,
{
"path": thumbnail_path,
"height": 40,
@ -349,90 +300,44 @@ class CourseAdmin(DirectCourseAdmin):
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)
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)
# 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"),
description=_("Add Student"),
icon="person_add",
permissions=[
"is_course_professor",
],
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"))
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']
# 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"))
messages.warning(request, _("Student {} is already enrolled in this course").format(student.fullname))
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"))
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()
@ -442,11 +347,25 @@ class CourseAdmin(DirectCourseAdmin):
{
"form": form,
"object": object,
"title": _("Change detail action for {}").format(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')
@ -454,11 +373,9 @@ class GlossaryAdmin(AttachmentGlossaryBaseAdmin):
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()
@ -469,6 +386,10 @@ class CourseGlossaryAdmin(CourseRelatedAdmin):
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
@ -485,28 +406,21 @@ class AttachmentAdminForm(forms.ModelForm):
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}" # ترکیب بخش اصلی و هش با پسوند
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
@ -521,11 +435,9 @@ class AttachmentAdmin(AttachmentGlossaryBaseAdmin):
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()
@ -535,6 +447,10 @@ class CourseAttachmentAdmin(CourseRelatedAdmin):
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
@ -548,18 +464,62 @@ class CourseAttachmentAdmin(CourseRelatedAdmin):
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 admin.sites.AlreadyRegistered:
except django_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)
@ -568,27 +528,11 @@ 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):

38
apps/course/admin/lesson.py

@ -2,33 +2,25 @@ import os
from django.contrib import admin
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 unfold.admin import ModelAdmin
from unfold.decorators import display
from unfold.contrib.forms.widgets import WysiwygWidget
from unfold.contrib.filters.admin import (
ChoicesDropdownFilter,
MultipleRelatedDropdownFilter,
)
from unfold.widgets import (
UnfoldAdminRadioSelectWidget,
)
from unfold.widgets import UnfoldAdminRadioSelectWidget
from utils.admin import project_admin_site
from .professor_base import CourseRelatedAdmin
from apps.course.models.lesson import Lesson, CourseLesson, LessonCompletion
from unfold.admin import ModelAdmin, StackedInline, TabularInline
class LessonForm(forms.ModelForm):
class Meta:
model = Lesson
fields = '__all__'
widgets = {
'content_type': UnfoldAdminRadioSelectWidget(),
}
class CourseLessonForm(forms.ModelForm):
@ -56,18 +48,15 @@ class LessonAdmin(ModelAdmin):
fieldsets = (
(None, {
'fields': ('title', 'duration')
'fields': (('title', 'duration'),)
}),
(_('Content'), {
'fields': ('content_type', 'content_file', 'video_link'),
'classes': [],
}),
)
def get_form(self, request, obj=None, change=False, **kwargs):
form = super().get_form(request, obj, change, **kwargs)
# Enhanced styling for content_type radio buttons
form.base_fields["content_type"].widget = UnfoldAdminRadioSelectWidget(
choices=Lesson.ContentTypeChoices.choices,
radio_style=admin.HORIZONTAL,
@ -78,15 +67,11 @@ class LessonAdmin(ModelAdmin):
"input_class": "form-radio h-5 w-5 text-blue-600 transition duration-150 ease-in-out cursor-pointer",
},
)
return form
@display(description=_("Duration"))
def display_duration(self, obj):
return format_html(
'<span class="badge badge-info">{} min</span>',
obj.duration
)
return format_html('<span class="badge badge-info">{} {}</span>', obj.duration, _("min"))
class CourseLessonAdmin(CourseRelatedAdmin):
@ -101,18 +86,19 @@ class CourseLessonAdmin(CourseRelatedAdmin):
autocomplete_fields = ('course', 'lesson')
list_filter_submit = True
# 🔔 REMOVES FROM "ALL APPLICATIONS" MODAL
def has_module_permission(self, request):
return False
fieldsets = (
(None, {
'fields': ('course', 'lesson', 'title', 'priority', 'is_active')
'fields': ('course', 'lesson', 'title', ('priority', 'is_active'))
}),
)
@display(description=_("Duration"))
def display_duration(self, obj):
return format_html(
'<span class="badge badge-info">{} min</span>',
obj.lesson.duration
)
return format_html('<span class="badge badge-info">{} {}</span>', obj.lesson.duration, _("min"))
def get_queryset(self, request):
qs = super().get_queryset(request)
@ -126,19 +112,15 @@ class LessonCompletionAdmin(ModelAdmin):
ordering = ('-completed_at',)
def get_readonly_fields(self, request, obj=None):
"""
Make fields readonly if the object already exists.
"""
if obj:
return ['student', 'course_lesson', 'completed_at']
return []
# Register with both admin sites for autocomplete support
# Registrations
from django.contrib import admin as django_admin
django_admin.site.register(Lesson, LessonAdmin)
# Register with the project admin site
project_admin_site.register(Lesson, LessonAdmin)
project_admin_site.register(CourseLesson, CourseLessonAdmin)
project_admin_site.register(LessonCompletion, LessonCompletionAdmin)

83
apps/course/admin/live_session.py

@ -1,7 +1,8 @@
from django.contrib import admin
from django import forms
from django.utils.translation import gettext_lazy as _
from unfold.admin import ModelAdmin
from unfold.admin import ModelAdmin, StackedInline
from unfold.contrib.filters.admin import (
ChoicesDropdownFilter,
MultipleRelatedDropdownFilter,
@ -16,6 +17,76 @@ from apps.course.models import (
USER_ROLE_CHOICES,
RECORDING_TYPE_CHOICES,
)
from django.contrib.auth import get_user_model
User = get_user_model()
# --- WIDTH ENFORCEMENT & PLACEHOLDER TEXT FOR DROPDOWNS ---
class MinWidthInlineForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Target the dropdown fields to ensure they don't collapse
target_dropdown_fields = ['user', 'role', 'recording_type']
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 LiveSessionUserInline(StackedInline):
model = LiveSessionUser
form = MinWidthInlineForm
extra = 1
tab = True
fieldsets = (
(None, {
'fields': (('user', 'role', 'is_online'),) # Grouped horizontally
}),
(_("Timing"), {
'fields': (('entered_at', 'exited_at'),) # Grouped horizontally
}),
)
show_change_link = True
verbose_name = _("Session User")
verbose_name_plural = _("Session Users")
# 🔔 FILTER THE USER DROPDOWN TO ONLY SHOW ACTIVE & NON-GUEST USERS
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "user":
kwargs["queryset"] = User.objects.filter(is_active=True, email__isnull=False)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class LiveSessionRecordingInline(StackedInline):
model = LiveSessionRecording
form = MinWidthInlineForm
extra = 1
tab = True
fieldsets = (
(None, {
'fields': (('title', 'recording_type', 'is_active'),) # Grouped horizontally
}),
(_("Media"), {
'fields': ('file',)
}),
)
show_change_link = True
verbose_name = _("Session Recording")
verbose_name_plural = _("Session Recordings")
class CourseLiveSessionAdmin(ModelAdmin):
@ -28,6 +99,10 @@ class CourseLiveSessionAdmin(ModelAdmin):
ordering = ("-started_at",)
autocomplete_fields = ("course",)
readonly_fields = ("created_at", "updated_at")
# Add the custom tabs here
inlines = [LiveSessionUserInline, LiveSessionRecordingInline]
fieldsets = (
(None, {"fields": ("course", "subject", "started_at", "ended_at")}),
(_("Timestamps"), {"fields": ("created_at", "updated_at")}),
@ -59,6 +134,12 @@ class LiveSessionUserAdmin(ModelAdmin):
def get_role_choices(self, request):
return USER_ROLE_CHOICES
# 🔔 FILTER THE STANDALONE ADMIN FORM AS WELL JUST IN CASE
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "user":
kwargs["queryset"] = User.objects.filter(is_active=True, email__isnull=False)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class LiveSessionRecordingAdmin(ModelAdmin):
list_display = ("title", "session", "recording_type", "is_active", "created_at")

55
apps/quiz/admin/participant.py

@ -2,8 +2,9 @@ from django.contrib import admin
from django.db.models import F
from django.contrib.admin import SimpleListFilter
from django.utils.translation import gettext_lazy as _
from django import forms
from unfold.admin import ModelAdmin, StackedInline
from unfold.admin import ModelAdmin, StackedInline, TabularInline
from unfold.decorators import display
from apps.quiz.models import QuizParticipant, ParticipantAnswer
@ -11,13 +12,54 @@ from apps.account.models import User
from utils.admin import project_admin_site
# --- INLINE FOR QUIZ DETAIL PAGE ---
class MinWidthInlineForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
target_dropdown_fields = ['user']
for field_name, field in self.fields.items():
if field_name in target_dropdown_fields and 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;"
class QuizParticipantInline(TabularInline):
model = QuizParticipant
form = MinWidthInlineForm
extra = 0
tab = True
fields = ('user', 'started_at', 'total_timing', 'total_score')
readonly_fields = ('user', 'started_at', 'total_timing', 'total_score')
autocomplete_fields = ('user',)
show_change_link = True
verbose_name = _("Recent Participant")
verbose_name_plural = _("Recent Participants (Latest 10)")
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(quiz_id=object_id).order_by('-started_at').values_list('id', flat=True)[:10])
return qs.filter(id__in=latest_ids).order_by('-started_at')
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 ParticipantAnswerInline(StackedInline):
model = ParticipantAnswer
readonly_fields = (
'correct_answer_display', 'question', 'at_time', 'answer_timing',
)
@display(description="Correct Answer")
@display(description=_("Correct Answer"))
def correct_answer_display(self, obj):
return obj.correct_answer
@ -36,7 +78,8 @@ class UserEmailFilter(SimpleListFilter):
parameter_name = 'user_email'
def lookups(self, request, model_admin):
users = User.objects.all()
# 🔔 FILTER: Only fetch active users who have an email (not guests or deleted)
users = User.objects.filter(is_active=True, email__isnull=False)
return [(user.email, user.email) for user in users]
def queryset(self, request, queryset):
@ -59,4 +102,10 @@ class ParticipantAdmin(ModelAdmin):
date_hierarchy = 'started_at'
ordering = ['-started_at']
# 🔔 FILTER: Restrict the user dropdown in forms to active, non-guest users
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "user":
kwargs["queryset"] = User.objects.filter(is_active=True, email__isnull=False)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
project_admin_site.register(QuizParticipant, ParticipantAdmin)

43
apps/quiz/admin/quiz.py

@ -1,25 +1,29 @@
from django.contrib import admin
from django.contrib import admin, messages
from django.db.models import Count
from django.utils.safestring import mark_safe
from django.urls import reverse
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _
from unfold.admin import ModelAdmin
from unfold.decorators import display
from unfold.decorators import display, action
from apps.course.models import CourseLesson
from apps.quiz.models import Quiz
from apps.quiz.admin.question import QuestionAdminInline
from utils.admin import project_admin_site
from apps.quiz.admin.participant import QuizParticipantInline
from utils.admin import project_admin_site, admin_url_generator
class QuizAdmin(ModelAdmin):
search_fields = ['title', 'lesson__title']
list_display = ['title', 'description', 'lesson', 'each_question_timing', 'status_display', 'questions_display']
list_filter = ['each_question_timing', 'status']
inlines = [QuestionAdminInline]
inlines = [QuestionAdminInline, QuizParticipantInline]
compressed_fields = True
# 🔔 ADD THE TOP ACTION BUTTON
actions_detail = ['manage_all_participants']
def get_queryset(self, request):
queryset = super().get_queryset(request).annotate(
@ -58,15 +62,34 @@ class QuizAdmin(ModelAdmin):
return form
@display(description='Status', ordering='status')
@display(description=_('Status'), ordering='status')
def status_display(self, obj):
if obj.status:
return mark_safe('<span class="unfold-badge unfold-badge--success">Active</span>')
return mark_safe('<span class="unfold-badge unfold-badge--warning">Inactive</span>')
return mark_safe(f'<span class="unfold-badge unfold-badge--success">{_("Active")}</span>')
return mark_safe(f'<span class="unfold-badge unfold-badge--warning">{_("Inactive")}</span>')
@display(description='Questions', ordering='questions_count')
@display(description=_('Questions'), ordering='questions_count')
def questions_display(self, obj):
url = reverse('admin:quiz_question_changelist') + f'?quiz={obj.id}'
return mark_safe(f'<a href="{url}" class="unfold-link">Questions: {obj.questions_count}</a>')
return mark_safe(f'<a href="{url}" class="unfold-link">{_("Questions")}: {obj.questions_count}</a>')
# 🔔 THE REDIRECT LOGIC FOR THE NEW BUTTON
@action(
description=_("Manage All Participants"),
icon="groups",
)
def manage_all_participants(self, request, object_id):
"""Redirect to the pre-filtered Quiz Participant changelist for this quiz."""
quiz = self.get_object(request, object_id)
if not quiz:
messages.error(request, _("Quiz not found"))
return redirect(admin_url_generator(request, "quiz_quiz_changelist"))
# Generate base URL for quiz participant list
base_url = admin_url_generator(request, "quiz_quizparticipant_changelist")
# Append the filter query parameter
url = f"{base_url}?quiz__id__exact={object_id}"
return redirect(url)
project_admin_site.register(Quiz, QuizAdmin)

28
apps/transaction/admin.py

@ -57,20 +57,20 @@ class TransactionParticipantAdmin(ModelAdmin):
@display(description=_("Payment Status"), ordering="status")
def payment_status(self, obj):
if obj.status == 'success':
return format_html('<span class="unfold-badge unfold-badge--success">Paid</span>')
return format_html('<span class="unfold-badge unfold-badge--success">{}</span>', _("Paid"))
elif obj.status == 'failed':
return format_html('<span class="unfold-badge unfold-badge--danger">Failed</span>')
return format_html('<span class="unfold-badge unfold-badge--danger">{}</span>', _("Failed"))
elif obj.status == 'waiting_approval':
return format_html('<span class="unfold-badge unfold-badge--info">Waiting Approval</span>')
return format_html('<span class="unfold-badge unfold-badge--warning">Pending</span>')
return format_html('<span class="unfold-badge unfold-badge--info">{}</span>', _("Waiting Approval"))
return format_html('<span class="unfold-badge unfold-badge--warning">{}</span>', _("Pending"))
@display(description=_("Receipts Count"))
def receipts_count(self, obj):
"""Display count of uploaded receipts"""
count = obj.receipts.count()
if count > 0:
return format_html('<span class="unfold-badge unfold-badge--info">{} receipts</span>', count)
return format_html('<span class="unfold-badge unfold-badge--secondary">No receipts</span>')
return format_html('<span class="unfold-badge unfold-badge--info">{} {}</span>', count, _("receipts"))
return format_html('<span class="unfold-badge unfold-badge--secondary">{}</span>', _("No receipts"))
@display(description=_("Price"), ordering="price")
def price_display(self, obj):
@ -85,11 +85,11 @@ class TransactionParticipantAdmin(ModelAdmin):
course=obj.course
).exists()
if participant_exists:
return format_html('<span class="unfold-badge unfold-badge--success">✓ Enrolled</span>')
return format_html('<span class="unfold-badge unfold-badge--success">✓ {}</span>', _("Enrolled"))
else:
return format_html('<span class="unfold-badge unfold-badge--warning">⚠ Not Enrolled</span>')
return format_html('<span class="unfold-badge unfold-badge--warning">⚠ {}</span>', _("Not Enrolled"))
else:
return format_html('<span class="unfold-badge unfold-badge--secondary">- Not Applicable</span>')
return format_html('<span class="unfold-badge unfold-badge--secondary">- {}</span>', _("Not Applicable"))
def save_model(self, request, obj, form, change):
"""Override save_model to show messages when participant is created"""
@ -113,12 +113,18 @@ class TransactionParticipantAdmin(ModelAdmin):
if participant_exists:
messages.success(
request,
f"Transaction status updated to SUCCESS. User {obj.user.email} is now enrolled in course '{obj.course.title}'."
_("Transaction status updated to SUCCESS. User {user_email} is now enrolled in course '{course_title}'.").format(
user_email=obj.user.email,
course_title=obj.course.title
)
)
else:
messages.warning(
request,
f"Transaction status updated to SUCCESS, but there was an issue enrolling user {obj.user.email} in course '{obj.course.title}'. Please check the logs."
_("Transaction status updated to SUCCESS, but there was an issue enrolling user {user_email} in course '{course_title}'. Please check the logs.").format(
user_email=obj.user.email,
course_title=obj.course.title
)
)
else:
super().save_model(request, obj, form, change)

9
utils/schema.py

@ -22,15 +22,18 @@ def get_weekly_timing_schema():
'title': ' ',
'items': {
'type': 'object',
'title': str('Weekly Timing'),
'title': str(_('Weekly Timing')),
'properties': {
'day': {
'type': 'string',
'enum': ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
'title': 'Day',
'options': {
'enum_titles': [str(_('Monday')), str(_('Tuesday')), str(_('Wednesday')), str(_('Thursday')), str(_('Friday')), str(_('Saturday')), str(_('Sunday'))]
},
'title': _('Day'),
},
'time': {'type': 'string', 'format': 'time','title': str('Time')},
'time': {'type': 'string', 'format': 'time','title': str(_('Time'))},
}
}
}

Loading…
Cancel
Save