From e8307160a00a2798133251431cdda26357deb174 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Mon, 4 May 2026 08:56:23 +0330 Subject: [PATCH] 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 --- apps/chat/admin.py | 263 +++++++++++++------- apps/course/admin/course.py | 396 +++++++++++++----------------- apps/course/admin/lesson.py | 40 +-- apps/course/admin/live_session.py | 85 ++++++- apps/quiz/admin/participant.py | 57 ++++- apps/quiz/admin/quiz.py | 47 +++- apps/transaction/admin.py | 28 ++- utils/schema.py | 9 +- 8 files changed, 554 insertions(+), 371 deletions(-) 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:
' + '
{}:
' '' - '
Open in new tab
', + '
{}
', + _("Image"), + obj.image_attachment.url, obj.image_attachment.url, - obj.image_attachment.url + _("Open in new tab") ) elif obj.file_attachment: return format_html( - '
File:
' - '📥 Download File
', - 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'))}, } } }