diff --git a/apps/chat/admin.py b/apps/chat/admin.py
index d68c132..a279a62 100644
--- a/apps/chat/admin.py
+++ b/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('Read')
- return format_html('Unread')
-
- is_read_status.short_description = _("Read Status")
+ return format_html('{}', _("Read"))
+ return format_html('{}', _("Unread"))
+
-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('{}', count)
-
- messages_count.short_description = _("Messages Count")
+ return format_html(
+ ''
+ '{}', count
+ )
+ @display(description=_("Room Type"))
def room_type_badge(self, obj):
if obj.room_type == 'group':
- return format_html('Group')
- return format_html('Private')
-
- room_type_badge.short_description = _("Room Type")
+ return format_html('{}', _("Group"))
+ return format_html('{}', _("Private"))
def get_queryset(self, request):
queryset = super().get_queryset(request)
@@ -103,22 +179,26 @@ class RoomMessageAdmin(ModelAdmin):
total_messages=Count('messages')
)
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(
- ''
- 'chat {}',
- url, _("View Messages")
- )
-
- view_messages_button.short_description = _("Messages")
+
+ @action(
+ description=_("Manage All Messages"),
+ icon="chat",
+ )
+ 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(
- '{}',
- color, color, label
+ '{}',
+ 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('Deleted')
- return format_html('Active')
-
- is_deleted_status.short_description = _("Status")
+ return format_html('{}', _("Deleted"))
+ return format_html('{}', _("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(
- '📷 Image'
- )
+ return format_html('{}', _("📷 Image"))
elif obj.file_attachment:
- return format_html(
- '📎 File'
- )
+ return format_html('{}', _("📎 File"))
elif obj.content and obj.content_type != 'text':
- return format_html(
- '🔗 Legacy'
- )
+ return format_html('{}', _("🔗 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(
- '
',
+ _("Image"),
+ obj.image_attachment.url,
obj.image_attachment.url,
- obj.image_attachment.url
+ _("Open in new tab")
)
elif obj.file_attachment:
return format_html(
- '',
- obj.file_attachment.url
+ '',
+ _("File"),
+ obj.file_attachment.url,
+ _("📥 Download File")
)
elif obj.content and obj.content_type != 'text':
return format_html(
- 'Legacy URL:
{}
',
+ '{}:
{}
',
+ _("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)
+project_admin_site.register(MessageReadStatus, MessageReadStatusAdmin)
\ No newline at end of file
diff --git a/apps/course/admin/course.py b/apps/course/admin/course.py
index c9f1fa6..631bf3f 100644
--- a/apps/course/admin/course.py
+++ b/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 utils.admin import project_admin_site ,dovoodi_admin_site
+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(
''
'visibility'
'',
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(
- '{}',
- 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(),
@@ -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
-
-class CourseAttachmentInline(StackedInline):
+# --- WIDTH ENFORCEMENT & PLACEHOLDER TEXT FOR DROPDOWNS ---
+class MinWidthInlineForm(forms.ModelForm):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ target_dropdown_fields = ['lesson', 'attachment', 'glossary', 'student']
+
+ for field_name, field in self.fields.items():
+ if field_name in target_dropdown_fields:
+ if hasattr(field.widget, 'attrs'):
+ existing_class = field.widget.attrs.get('class', '')
+ field.widget.attrs['class'] = f"{existing_class} min-w-[250px] w-full"
+
+ existing_style = field.widget.attrs.get('style', '')
+ field.widget.attrs['style'] = f"{existing_style} min-width: 250px; width: 250px;"
+
+ # 🔔 Add the custom placeholder text
+ if hasattr(field, 'empty_label'):
+ field.empty_label = _("Select a value")
+
+
+class CourseAttachmentInline(TabularInline):
model = CourseAttachment
- 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
- fields = ('student', 'joined_date', )
+ 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('${}', 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):
@@ -596,4 +540,4 @@ class HiddenCourseAdmin(ModelAdmin):
def has_delete_permission(self, request, obj=None):
return False
-dovoodi_admin_site.register(Course, HiddenCourseAdmin)
+dovoodi_admin_site.register(Course, HiddenCourseAdmin)
\ No newline at end of file
diff --git a/apps/course/admin/lesson.py b/apps/course/admin/lesson.py
index a244412..830d258 100644
--- a/apps/course/admin/lesson.py
+++ b/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(
- '{} min',
- obj.duration
- )
+ return format_html('{} {}', 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(
- '{} min',
- obj.lesson.duration
- )
+ return format_html('{} {}', 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)
+project_admin_site.register(LessonCompletion, LessonCompletionAdmin)
\ No newline at end of file
diff --git a/apps/course/admin/live_session.py b/apps/course/admin/live_session.py
index 5f2b136..01ff51d 100644
--- a/apps/course/admin/live_session.py
+++ b/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")
@@ -84,4 +165,4 @@ class LiveSessionRecordingAdmin(ModelAdmin):
project_admin_site.register(CourseLiveSession, CourseLiveSessionAdmin)
project_admin_site.register(LiveSessionUser, LiveSessionUserAdmin)
-project_admin_site.register(LiveSessionRecording, LiveSessionRecordingAdmin)
+project_admin_site.register(LiveSessionRecording, LiveSessionRecordingAdmin)
\ No newline at end of file
diff --git a/apps/quiz/admin/participant.py b/apps/quiz/admin/participant.py
index b5fa008..0756e30 100644
--- a/apps/quiz/admin/participant.py
+++ b/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']
-project_admin_site.register(QuizParticipant, ParticipantAdmin)
+ # 🔔 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)
\ No newline at end of file
diff --git a/apps/quiz/admin/quiz.py b/apps/quiz/admin/quiz.py
index 95d7cd5..eedfeee 100644
--- a/apps/quiz/admin/quiz.py
+++ b/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('Active')
- return mark_safe('Inactive')
+ return mark_safe(f'{_("Active")}')
+ return mark_safe(f'{_("Inactive")}')
- @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'Questions: {obj.questions_count}')
+ return mark_safe(f'{_("Questions")}: {obj.questions_count}')
+
+ # 🔔 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)
+project_admin_site.register(Quiz, QuizAdmin)
\ No newline at end of file
diff --git a/apps/transaction/admin.py b/apps/transaction/admin.py
index 35f70a9..80107fd 100644
--- a/apps/transaction/admin.py
+++ b/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('Paid')
+ return format_html('{}', _("Paid"))
elif obj.status == 'failed':
- return format_html('Failed')
+ return format_html('{}', _("Failed"))
elif obj.status == 'waiting_approval':
- return format_html('Waiting Approval')
- return format_html('Pending')
+ return format_html('{}', _("Waiting Approval"))
+ return format_html('{}', _("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('{} receipts', count)
- return format_html('No receipts')
+ return format_html('{} {}', count, _("receipts"))
+ return format_html('{}', _("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('✓ Enrolled')
+ return format_html('✓ {}', _("Enrolled"))
else:
- return format_html('⚠ Not Enrolled')
+ return format_html('⚠ {}', _("Not Enrolled"))
else:
- return format_html('- Not Applicable')
+ return format_html('- {}', _("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)
diff --git a/utils/schema.py b/utils/schema.py
index 4183b56..ba99545 100644
--- a/utils/schema.py
+++ b/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'))},
}
}
}