/
storeksfeed
/
RAG-Bot
Обзор
Документация
Войти
/
storeksfeed
/
RAG-Bot
Код
Запросы
0
Задачи
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
app/core/admin.py
1 207 строк
46 KB
Semyon Glazyrin
Updated AdminMessengerContactInline to 0 extra by default
07 июн 2026, 08:14
Верифицирован
07 июн 2026, 08:14
7b49d7a
Код
Авторство
О чём код?
from django.contrib import admin from django.contrib import messages from django import forms from django.contrib.auth.models import User from django.contrib.auth.admin import UserAdmin from django.shortcuts import redirect, get_object_or_404, render from django.urls import path from django.utils.html import format_html from django.utils.safestring import mark_safe from django.utils import timezone from django.db.models import Count, Q, Avg from django.conf import settings from typing import Protocol, runtime_checkable import logging from .models import RegistrationRequest, BotUser, QuestionAnswer, AdminContactProfile, AdminMessengerContact from .messengers.shared import ( get_available_messengers, get_messenger_alias, get_messenger_icon, send_message, ) from .messengers import messages as msg logger = logging.getLogger(__name__) def display_email(email, email_verified): if not email: return mark_safe( '<span style="font-size: 14px; line-height: 1;" title="Email не указан">–</span>' ) if email_verified: icon = mark_safe( '<i class="fa-solid fa-circle-check" style="font-size: 14px; margin-right: 6px; vertical-align: middle; color: #15803D;" title="Email подтвержден" aria-hidden="true"></i>' ) else: icon = mark_safe( '<i class="fa-regular fa-circle-question" style="font-size: 14px; margin-right: 6px; vertical-align: middle; color: #B45309;" title="Email не подтвержден" aria-hidden="true"></i>' ) return format_html( '<span style="display: inline-flex; align-items: center;">{}{}</span>', icon, email ) def messenger_icon(messenger): return mark_safe(get_messenger_icon(messenger)) def messenger_label(messenger): return get_messenger_alias(messenger) def category_badge(category): colors = { 'global': '#9C27B0', 'naive': '#2196F3', 'local': '#FF9800', } labels = { 'global': 'Global', 'naive': 'Vector', 'local': 'Local', } return format_html( '<span style="display:inline-flex; align-items:center; white-space:nowrap; background-color: {}; color: white; padding: 2px 8px; border-radius: 3px; font-size: 11px;">{}</span>', colors.get(category, '#6C757D'), labels.get(category, category) ) @runtime_checkable class Messengable(Protocol): messenger: str messenger_id: int messenger_username: str | None def send_notification(messengable_obj: Messengable, text: str): """Send notification and show admin warning when delivery fails.""" is_sent = send_message(messengable_obj, text) if not is_sent: logger.warning( f'Уведомление пользователю не отправлено: {getattr(messengable_obj, "messenger_username", None)} (мессенджер: {getattr(messengable_obj, "messenger", None)}, ID: {getattr(messengable_obj, "messenger_id", None)})', ) return is_sent class StaffModelAccessMixin: """Allow all active staff users to access and change model data in admin.""" def has_module_permission(self, request): return request.user.is_active and request.user.is_staff def has_view_permission(self, request, obj=None): return request.user.is_active and request.user.is_staff def has_change_permission(self, request, obj=None): return request.user.is_active and request.user.is_staff @admin.register(RegistrationRequest) class RegistrationRequestAdmin(StaffModelAccessMixin, admin.ModelAdmin): list_display = [ 'id_display', 'username_display', 'email_display', 'user_message_preview', 'status_display', 'created_at', 'action_buttons' ] list_filter = ['messenger', 'status', 'email_verified', 'created_at'] search_fields = ['messenger_username', 'messenger_id', 'email', 'first_name', 'last_name'] readonly_fields = ['messenger', 'messenger_id', 'messenger_username', 'first_name', 'last_name', 'email', 'email_verified', 'user_message', 'status', 'created_at', 'updated_at', 'reviewed_at'] superuser_readonly_fields = ['messenger', 'messenger_id', 'messenger_username', 'created_at', 'updated_at', 'reviewed_at'] actions = ['reject_requests'] fieldsets = ( ('Информация из мессенджера', { 'fields': ('messenger', 'messenger_id', 'messenger_username', 'first_name', 'last_name') }), ('Контактная информация', { 'fields': ('email', 'email_verified') }), ('Сообщение от пользователя', { 'fields': ('user_message',), 'classes': ('collapse',) }), ('Статус заявки', { 'fields': ('status', 'rejection_reason', 'created_at', 'updated_at', 'reviewed_at') }), ) def has_add_permission(self, request): return False def get_readonly_fields(self, request, obj=None): if request.user.is_superuser: return self.superuser_readonly_fields return self.readonly_fields @admin.display(description='Статус') def status(self, obj): styles = { 'pending': ('<i class="fa-regular fa-clock" aria-hidden="true"></i>', '#B45309', 'На рассмотрении'), 'approved': ('<i class="fa-solid fa-circle-check" aria-hidden="true"></i>', '#15803D', 'Одобрена'), 'rejected': ('<i class="fa-solid fa-circle-xmark" aria-hidden="true"></i>', '#B91C1C', 'Отклонена'), } icon, color, label = styles.get( obj.status, ('<i class="fa-solid fa-circle" aria-hidden="true"></i>', '#6C757D', obj.get_status_display())) return format_html( '<span style="display:inline-flex; align-items:center; gap:6px; white-space:nowrap; color: {}; font-weight: 600;">{} <span>{}</span></span>', color, mark_safe(icon), label ) @admin.display(description='ID', ordering='messenger_id') def id_display(self, obj): icon = messenger_icon(obj.messenger) label = messenger_label(obj.messenger) return format_html( '<span title="{} ID" style="display:inline-flex; align-items:center; gap:6px; white-space:nowrap;">{} <span>{}</span></span>', label, icon, obj.messenger_id, ) @admin.display(description='Username', ordering='messenger_username') def username_display(self, obj): icon = messenger_icon(obj.messenger) label = messenger_label(obj.messenger) value = obj.messenger_username or '—' return format_html( '<span title="{} username" style="display:inline-flex; align-items:center; gap:6px; white-space:nowrap;">{} <span>{}</span></span>', label, icon, value, ) @admin.display(description='Email', ordering='email') def email_display(self, obj): return display_email(obj.email, obj.email_verified) @admin.display(description='Статус', ordering='status') def status_display(self, obj): return self.status(obj) @admin.display(description='Сообщение') def user_message_preview(self, obj): if obj.user_message: preview = obj.user_message[:50] + \ ('...' if len(obj.user_message) > 50 else '') return format_html( '<span title="{}">{}</span>', obj.user_message, preview ) return mark_safe( '<span style="color: #CCC;">–</span>' ) @admin.display(description='Действия') def action_buttons(self, obj): if obj.status == 'pending': return format_html( '<div class="inline-flex flex-wrap gap-1">' '<a class="btn btn-xs btn-success" href="/admin/core/registrationrequest/{}/approve/">Одобрить</a>' '<a class="btn btn-xs btn-error" href="/admin/core/registrationrequest/{}/reject/">Отклонить</a>' '</div>', obj.pk, obj.pk ) elif obj.status == 'approved': return mark_safe('<span class="badge badge-ghost badge-sm">Нет доступных</span>') elif obj.status == 'rejected': return format_html( '<div class="inline-flex flex-wrap gap-1">' '<a class="btn btn-xs btn-warning" href="/admin/core/registrationrequest/{}/to-pending/">На рассмотрение</a>' '<a class="btn btn-xs btn-success" href="/admin/core/registrationrequest/{}/approve/">Одобрить</a>' '</div>', obj.pk, obj.pk ) return '-' def get_urls(self): urls = super().get_urls() custom_urls = [ path('<int:pk>/approve/', self.admin_site.admin_view(self.approve_request), name='core_registrationrequest_approve'), path('<int:pk>/reject/', self.admin_site.admin_view(self.reject_request), name='core_registrationrequest_reject'), path('<int:pk>/to-pending/', self.admin_site.admin_view(self.to_pending_request), name='core_registrationrequest_to_pending'), ] return custom_urls + urls def approve_request(self, request, pk): registration = get_object_or_404(RegistrationRequest, pk=pk) approved_user = registration.approve() messages.success( request, f'Заявка от {registration.messenger_username or registration.messenger_id} одобрена.') # Send notification to user send_notification( approved_user, msg.ADMIN_REQUEST_APPROVED, ) return redirect('admin:core_registrationrequest_changelist') def reject_request(self, request, pk): registration = get_object_or_404(RegistrationRequest, pk=pk) if request.method == 'POST': reason = request.POST.get('reason', '') registration.reject(reason) messages.warning( request, f'Заявка от {registration.messenger_username or registration.messenger_id} отклонена.') # Send notification to user reason_text = msg.ADMIN_REQUEST_REJECTED_REASON.format( reason=reason) if reason else '' notification = msg.ADMIN_REQUEST_REJECTED.format( reason=reason_text) send_notification( registration, notification, ) return redirect('admin:core_registrationrequest_changelist') context = { **self.admin_site.each_context(request), 'registration': registration, 'opts': self.model._meta, } return render(request, 'admin/core/reject_request.html', context) def to_pending_request(self, request, pk): registration = get_object_or_404(RegistrationRequest, pk=pk) # If was approved, deactivate the approved user if registration.status == 'approved': try: approved_user = BotUser.objects.get( messenger=registration.messenger, messenger_id=registration.messenger_id) approved_user.status = 'suspended' approved_user.save() except BotUser.DoesNotExist: pass registration.status = 'pending' registration.reviewed_at = None registration.rejection_reason = '' registration.save() # Send notification to user and surface delivery issues in admin UI. send_notification( registration, msg.ADMIN_REQUEST_REVIEW, ) messages.info( request, f'Заявка от {registration.messenger_username or registration.messenger_id} возвращена на рассмотрение.', ) return redirect('admin:core_registrationrequest_changelist') @admin.action(description='Отклонить выбранные заявки') def reject_requests(self, request, queryset): """Mass reject registration requests with optional reason.""" if request.POST.get('confirmed') == 'yes': reason = request.POST.get('reason', '').strip() processed_count = 0 skipped_count = 0 failed_notifications = 0 for registration in queryset: if registration.status == 'rejected': skipped_count += 1 continue registration.reject(reason) reason_text = msg.ADMIN_REQUEST_REJECTED_REASON.format( reason=reason) if reason else '' notification = msg.ADMIN_REQUEST_REJECTED.format( reason=reason_text) if not send_notification(registration, notification): failed_notifications += 1 processed_count += 1 self.message_user( request, f'Отклонено заявок: {processed_count}. Пропущено (уже отклонены): {skipped_count}.', ) if failed_notifications: self.message_user( request, f'Не удалось отправить уведомления: {failed_notifications}. Подробности в логах.', level=messages.WARNING, ) return None selected_ids = ','.join(str(pk) for pk in queryset.values_list('pk', flat=True)) context = { **self.admin_site.each_context(request), 'requests': list(queryset), 'requests_count': queryset.count(), 'selected_ids': selected_ids, 'back_url': request.get_full_path(), 'opts': self.model._meta, } return render(request, 'admin/core/mass_reject_requests.html', context) @admin.register(BotUser) class BotUserAdmin(StaffModelAccessMixin, admin.ModelAdmin): list_display = [ 'id_display', 'username_display', 'email_display', 'status_display', 'user_response_preview', 'questions_count', 'active_at', 'approved_at', 'action_buttons' ] list_filter = ['messenger', 'status', 'email_verified', 'approved_at'] search_fields = ['messenger_username', 'messenger_id', 'email', 'first_name', 'last_name'] readonly_fields = ['messenger', 'messenger_id', 'messenger_username', 'first_name', 'last_name', 'email', 'email_verified', 'approved_at', 'active_at', 'questions_count', 'status', 'status_message', 'status_changed_at', 'user_response', 'change_form_action_buttons'] superuser_readonly_fields = ['messenger', 'messenger_id', 'approved_at', 'active_at', 'questions_count', 'change_form_action_buttons'] fieldsets = ( ('Информация из мессенджера', { 'fields': ('messenger', 'messenger_id', 'messenger_username', 'first_name', 'last_name') }), ('Контактная информация', { 'fields': ('email', 'email_verified') }), ('Статус и сообщения', { 'fields': ('status', 'change_form_action_buttons', 'status_message', 'status_changed_at', 'user_response') }), ('Заметки', { 'fields': ('notes',) }), ('Статистика', { 'fields': ('approved_at', 'active_at', 'questions_count') }), ) def has_add_permission(self, request): return False def get_readonly_fields(self, request, obj=None): if request.user.is_superuser: return self.superuser_readonly_fields return self.readonly_fields @admin.display(description='Статус') def status(self, obj): styles = { 'active': ('<i class="fa-solid fa-circle-check" aria-hidden="true"></i>', '#15803D', 'Активен'), 'suspended': ('<i class="fa-solid fa-circle-pause" aria-hidden="true"></i>', '#B45309', 'Приостановлен'), 'blocked': ('<i class="fa-solid fa-ban" aria-hidden="true"></i>', '#B91C1C', 'Заблокирован'), } icon, color, label = styles.get( obj.status, ('<i class="fa-solid fa-circle" aria-hidden="true"></i>', '#6C757D', obj.get_status_display())) return format_html( '<span style="display:inline-flex; align-items:center; gap:6px; white-space:nowrap; color: {}; font-weight: 600;">{} <span>{}</span></span>', color, mark_safe(icon), label ) @admin.display(description='Мессенджер', ordering='messenger') def platform_display(self, obj): icon = messenger_icon(obj.messenger) label = messenger_label(obj.messenger) return format_html( '<span title="{}" style="display:inline-flex; align-items:center; gap:6px; white-space:nowrap;">{} <span>{}</span></span>', label, icon, label, ) @admin.display(description='ID', ordering='messenger_id') def id_display(self, obj): icon = messenger_icon(obj.messenger) label = messenger_label(obj.messenger) return format_html( '<span title="{} ID" style="display:inline-flex; align-items:center; gap:6px; white-space:nowrap;">{} <span>{}</span></span>', label, icon, obj.messenger_id, ) @admin.display(description='Username', ordering='messenger_username') def username_display(self, obj): icon = messenger_icon(obj.messenger) label = messenger_label(obj.messenger) value = obj.messenger_username or '—' return format_html( '<span title="{} username" style="display:inline-flex; align-items:center; gap:6px; white-space:nowrap;">{} <span>{}</span></span>', label, icon, value, ) @admin.display(description='Ответ пользователя') def user_response_preview(self, obj): """Show if suspended user has responded.""" if obj.status == 'suspended' and obj.user_response: preview = obj.user_response[:50] + \ ('...' if len(obj.user_response) > 50 else '') return format_html( '<span title="{}">{}</span>', obj.user_response, preview ) return mark_safe('<span style="color: #CCC;">–</span>') @admin.display(description='Email', ordering='email') def email_display(self, obj): return display_email(obj.email, obj.email_verified) @admin.display(description='Статус', ordering='status') def status_display(self, obj): return self.status(obj) @admin.display(description='Действия') def change_form_action_buttons(self, obj): """Display action buttons on the change form.""" if not obj or not obj.pk: return '-' buttons = [] # Active user: show Suspend + Block if obj.status == 'active': buttons.append(format_html( '<a href="/admin/core/botuser/{}/suspend/" class="btn btn-warning btn-sm">Приостановить доступ</a>', obj.pk )) buttons.append(format_html( '<a href="/admin/core/botuser/{}/block/" class="btn btn-error btn-sm">Заблокировать</a>', obj.pk )) # Suspended user: show Activate + Block elif obj.status == 'suspended': buttons.append(format_html( '<a href="/admin/core/botuser/{}/activate/" class="btn btn-success btn-sm">Активировать</a>', obj.pk )) buttons.append(format_html( '<a href="/admin/core/botuser/{}/block/" class="btn btn-error btn-sm">Заблокировать</a>', obj.pk )) # Blocked user: show Suspend + Activate elif obj.status == 'blocked': buttons.append(format_html( '<a href="/admin/core/botuser/{}/suspend/" class="btn btn-warning btn-sm">Приостановить доступ</a>', obj.pk )) buttons.append(format_html( '<a href="/admin/core/botuser/{}/activate/" class="btn btn-success btn-sm">Активировать</a>', obj.pk )) return mark_safe('<div class="mt-2 flex flex-wrap gap-2">' + ' '.join(buttons) + '</div>') actions = ['deactivate_users', 'block_users', 'activate_users'] @admin.action(description='Приостановить выбранных пользователей') def deactivate_users(self, request, queryset): """Mass suspend users with optional reason.""" # Check if this is the confirmation step if request.POST.get('confirmed') == 'yes': message = request.POST.get('message', '').strip() # Update all selected users processed_count = 0 skipped_count = 0 failed_notifications = 0 for user in queryset: if user.status == 'suspended': skipped_count += 1 continue user.status = 'suspended' user.status_message = message if message else None user.status_changed_at = timezone.now() user.user_response = None user.save() # Send notification to each user admin_message = msg.ADMIN_USER_SUSPENDED_MESSAGE.format( message=message) if message else '' notification = msg.ADMIN_USER_SUSPENDED.format( admin_message=admin_message) if not send_notification(user, notification): failed_notifications += 1 processed_count += 1 self.message_user( request, f'Приостановлено пользователей: {processed_count}. Пропущено (уже приостановлены): {skipped_count}.', ) if failed_notifications: self.message_user( request, f'Не удалось отправить уведомления: {failed_notifications}. Подробности в предупреждениях выше.', level=messages.WARNING, ) return None # Show confirmation form selected_ids = ','.join(str(pk) for pk in queryset.values_list('pk', flat=True)) context = { **self.admin_site.each_context(request), 'users': list(queryset), 'users_count': queryset.count(), 'selected_ids': selected_ids, 'back_url': request.get_full_path(), 'opts': self.model._meta, } return render(request, 'admin/core/mass_suspend_users.html', context) @admin.action(description='Заблокировать выбранных пользователей') def block_users(self, request, queryset): """Mass block users with optional reason.""" # Check if this is the confirmation step if request.POST.get('confirmed') == 'yes': message = request.POST.get('message', '').strip() # Update all selected users processed_count = 0 skipped_count = 0 failed_notifications = 0 for user in queryset: if user.status == 'blocked': skipped_count += 1 continue user.status = 'blocked' user.status_message = message if message else None user.status_changed_at = timezone.now() user.save() # Send notification to each user admin_message = msg.ADMIN_USER_BLOCKED_MESSAGE.format( message=message) if message else '' notification = msg.ADMIN_USER_BLOCKED.format( admin_message=admin_message) if not send_notification(user, notification): failed_notifications += 1 processed_count += 1 self.message_user( request, f'Заблокировано пользователей: {processed_count}. Пропущено (уже заблокированы): {skipped_count}.', ) if failed_notifications: self.message_user( request, f'Не удалось отправить уведомления: {failed_notifications}. Подробности в предупреждениях выше.', level=messages.WARNING, ) return None # Show confirmation form selected_ids = ','.join(str(pk) for pk in queryset.values_list('pk', flat=True)) context = { **self.admin_site.each_context(request), 'users': list(queryset), 'users_count': queryset.count(), 'selected_ids': selected_ids, 'back_url': request.get_full_path(), 'opts': self.model._meta, } return render(request, 'admin/core/mass_block_users.html', context) @admin.action(description='Активировать выбранных пользователей') def activate_users(self, request, queryset): """Mass activate users and send notifications.""" count = 0 failed_notifications = 0 for user in queryset: user.status = 'active' user.status_changed_at = timezone.now() user.save() # Send notification to user if not send_notification(user, msg.ADMIN_USER_ACTIVATED): failed_notifications += 1 count += 1 self.message_user(request, f'Активировано пользователей: {count}') if failed_notifications: self.message_user( request, f'Не удалось отправить уведомления: {failed_notifications}. Подробности в предупреждениях выше.', level=messages.WARNING, ) @admin.display(description='Действия') def action_buttons(self, obj): """Show action buttons for each user.""" # Active user: show Suspend + Block if obj.status == 'active': return format_html( '<div class="inline-flex flex-wrap gap-1">' '<a class="btn btn-xs btn-warning" href="/admin/core/botuser/{}/suspend/">Приостановить</a>' '<a class="btn btn-xs btn-error" href="/admin/core/botuser/{}/block/">Заблокировать</a>' '</div>', obj.pk, obj.pk ) # Suspended user: show Activate + Block if obj.status == 'suspended': return format_html( '<div class="inline-flex flex-wrap gap-1">' '<a class="btn btn-xs btn-success" href="/admin/core/botuser/{}/activate/">Активировать</a>' '<a class="btn btn-xs btn-error" href="/admin/core/botuser/{}/block/">Заблокировать</a>' '</div>', obj.pk, obj.pk ) # Blocked user: show Suspend + Activate if obj.status == 'blocked': return format_html( '<div class="inline-flex flex-wrap gap-1">' '<a class="btn btn-xs btn-warning" href="/admin/core/botuser/{}/suspend/">Приостановить</a>' '<a class="btn btn-xs btn-success" href="/admin/core/botuser/{}/activate/">Активировать</a>' '</div>', obj.pk, obj.pk ) return '-' def get_urls(self): urls = super().get_urls() custom_urls = [ path('<int:pk>/suspend/', self.admin_site.admin_view(self.suspend_user), name='core_botuser_suspend'), path('<int:pk>/activate/', self.admin_site.admin_view(self.activate_user), name='core_botuser_activate'), path('<int:pk>/block/', self.admin_site.admin_view(self.block_user), name='core_botuser_block'), ] return custom_urls + urls def suspend_user(self, request, pk): user = get_object_or_404(BotUser, pk=pk) if request.method == 'POST': message = request.POST.get('message', '') user.status = 'suspended' user.status_message = message user.status_changed_at = timezone.now() user.user_response = None # Clear previous response user.save() messages.warning( request, f'Доступ приостановлен для пользователя {user.messenger_username or user.messenger_id}.') # Send notification to user admin_message = msg.ADMIN_USER_SUSPENDED_MESSAGE.format( message=message) if message else '' notification = msg.ADMIN_USER_SUSPENDED.format( admin_message=admin_message) send_notification(user, notification) return redirect('admin:core_botuser_changelist') context = { **self.admin_site.each_context(request), 'user': user, 'opts': self.model._meta, } return render(request, 'admin/core/suspend_user.html', context) def block_user(self, request, pk): user = get_object_or_404(BotUser, pk=pk) if request.method == 'POST': message = request.POST.get('message', '') # Mark user as blocked (permanent-ish) user.status = 'blocked' user.status_message = message user.status_changed_at = timezone.now() # user.user_response = None # Clear previous response user.save() messages.warning( request, f'Пользователь {user.messenger_username or user.messenger_id} заблокирован.') # Send notification to user admin_message = msg.ADMIN_USER_BLOCKED_MESSAGE.format( message=message) if message else '' notification = msg.ADMIN_USER_BLOCKED.format( admin_message=admin_message) send_notification(user, notification) return redirect('admin:core_botuser_changelist') context = { **self.admin_site.each_context(request), 'user': user, 'opts': self.model._meta, } return render(request, 'admin/core/block_user.html', context) def activate_user(self, request, pk): user = get_object_or_404(BotUser, pk=pk) user.status = 'active' # user.status_message = None # Clear previous admin message # user.user_response = None # Clear previous response user.status_changed_at = timezone.now() user.save() messages.success( request, f'Доступ восстановлен для пользователя {user.messenger_username or user.messenger_id}.') # Send notification to user send_notification(user, msg.ADMIN_USER_ACTIVATED) return redirect('admin:core_botuser_changelist') @admin.register(QuestionAnswer) class QuestionAnswerAdmin(StaffModelAccessMixin, admin.ModelAdmin): list_display = [ 'id', 'user_link', 'question_preview', 'answer_preview', 'category_badge', 'rating_badge', 'processing_time_display', 'asked_at', 'view_details_link' ] list_filter = ['rating', 'category', 'rating_changed', 'asked_at'] search_fields = ['question', 'answer', 'user__messenger_username', 'user__messenger_id'] readonly_fields = ['user', 'question', 'answer_preview', 'category', 'asked_at', 'rated_at', 'rating_changed', 'processing_time'] superuser_readonly_fields = [ 'user', 'question', 'answer', 'answer_preview', 'category', 'rating', 'asked_at', 'rated_at', 'rating_changed', 'processing_time', ] date_hierarchy = 'asked_at' fieldsets = ( ('Информация о вопросе', { 'fields': ('user', 'asked_at', 'category', 'processing_time') }), ('Вопрос и ответ', { 'fields': ('question', 'answer_display'), 'classes': ('wide',) }), ('Оценка', { 'fields': ('rating', 'rated_at', 'rating_changed') }), ) def has_add_permission(self, request): return False def get_readonly_fields(self, request, obj=None): if request.user.is_superuser: return self.superuser_readonly_fields return self.readonly_fields def get_fieldsets(self, request, obj=None): if not request.user.is_superuser: return self.fieldsets return ( ('Информация о вопросе', { 'fields': ('user', 'asked_at', 'category', 'processing_time') }), ('Вопрос и ответ', { 'fields': ('question', 'answer'), 'classes': ('wide',) }), ('Оценка', { 'fields': ('rating', 'rated_at', 'rating_changed') }), ) def get_queryset(self, request): """Optimize queries with select_related.""" qs = super().get_queryset(request) return qs.select_related('user') @admin.display(description='Ответ') def answer_display(self, obj): """Display answer in the same format as shown to users.""" category_label = obj.get_category_display() return format_html( '<div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; border-left: 4px solid #2196F3;">' '<div style="display:inline-flex; align-items:center; gap:6px; white-space:nowrap; font-weight: bold; margin-bottom: 10px; color: #2196F3;"><i class="fa-regular fa-lightbulb" aria-hidden="true"></i><span>Ответ:</span></div>' '<div style="white-space: pre-wrap; margin-bottom: 15px;">{}</div>' '<div style="font-style: italic; color: #666; font-size: 0.9em;">Метод: {} ({})</div>' '</div>', obj.answer, category_label, obj.category ) @admin.display(description='Пользователь') def user_link(self, obj): """Link to user's page.""" url = f'/admin/core/botuser/{obj.user.pk}/change/' return format_html( '<a href="{}">{}</a>', url, obj.user.messenger_username or obj.user.messenger_id ) @admin.display(description='Вопрос') def question_preview(self, obj): """Show question preview with truncation.""" preview = obj.question[:80] + ('...' if len(obj.question) > 80 else '') return format_html('<span title="{}">{}</span>', obj.question, preview) @admin.display(description='Ответ') def answer_preview(self, obj): """Show answer preview with truncation.""" if obj.answer: preview = obj.answer[:10] + ('...' if len(obj.answer) > 10 else '') return format_html( '<span title="{}">{}</span>', obj.answer, preview ) return mark_safe('<span style="color: #CCC;">–</span>') @admin.display(description='Категория') def category_badge(self, obj): """Show category with color coding.""" return category_badge(obj.category) @admin.display(description='Оценка') def rating_badge(self, obj): """Show numerical rating.""" if obj.rating == 0: return mark_safe( '<span style="display:inline-flex; align-items:center; white-space:nowrap; color: #9CA3AF; font-weight: bold;">-/5</span>' ) colors = { 1: '#DC3545', # Red 2: '#FFC107', # Yellow 3: '#17A2B8', # Light blue 4: '#28A745', # Green 5: '#20C997', # Teal } color = colors.get(obj.rating, '#6C757D') return format_html( '<span style="display:inline-flex; align-items:center; white-space:nowrap; color: {}; font-weight: bold;">{}/5</span>', color, obj.rating ) @admin.display(description='Время обработки') def processing_time_display(self, obj): """Display processing time in seconds.""" if obj.processing_time is None: return mark_safe('<span style="white-space:nowrap; color: #6C757D;">–</span>') return format_html( '<span>{} сек</span>', obj.processing_time ) @admin.display(description='Действия') def view_details_link(self, obj): """Link to view full details.""" url = f'/admin/core/questionanswer/{obj.id}/change/' return format_html( '<a class="btn btn-xs btn-outline" href="{}">Подробнее</a>', url ) def changelist_view(self, request, extra_context=None): """Add analytics to the changelist view.""" total = QuestionAnswer.objects.count() stats = QuestionAnswer.objects.aggregate( total_rated=Count('id', filter=~Q(rating=0)), rating_1=Count('id', filter=Q(rating=1)), rating_2=Count('id', filter=Q(rating=2)), rating_3=Count('id', filter=Q(rating=3)), rating_4=Count('id', filter=Q(rating=4)), rating_5=Count('id', filter=Q(rating=5)), skipped=Count('id', filter=Q(rating=0)), ) # Categories category_stats = QuestionAnswer.objects.values('category').annotate( count=Count('id'), avg_rating=Avg('rating', filter=~Q(rating=0)), rated_count=Count('id', filter=~Q(rating=0)) ).order_by('category') # Calculate average satisfaction (average rating for rated questions) satisfaction_rate = 0 if stats['total_rated'] > 0: total_rating_points = (stats['rating_1'] * 1 + stats['rating_2'] * 2 + stats['rating_3'] * 3 + stats['rating_4'] * 4 + stats['rating_5'] * 5) satisfaction_rate = round( (total_rating_points / stats['total_rated']), 1) extra_context = extra_context or {} extra_context['total_questions'] = total extra_context['stats'] = stats extra_context['satisfaction_rate'] = satisfaction_rate extra_context['category_stats'] = [ { **stat, 'badge_html': category_badge(stat['category']), } for stat in category_stats ] return super().changelist_view(request, extra_context=extra_context) # Site headers admin.site.site_header = f'{settings.APP_NAME} - Панель администратора' admin.site.site_title = f'{settings.APP_NAME} Admin' admin.site.index_title = 'Управление пользователями и заявками' # Custom Django User model display name admin.site.unregister(User) class AdminContactProfileInline(admin.StackedInline): model = AdminContactProfile can_delete = False extra = 0 max_num = 1 verbose_name = 'Отображение в /support' verbose_name_plural = 'Отображение в /support' fields = ('role_text', 'show_in_support') class Media: css = { 'all': ('admin/css/inline_overrides.css',) } def get_readonly_fields(self, request, obj=None): if request.user.is_superuser: return () if obj is not None and obj.pk == request.user.pk: return () return self.fields def has_add_permission(self, request, obj): if request.user.is_superuser: return True if not request.user.is_active or not request.user.is_staff: return False return obj is not None and obj.pk == request.user.pk def has_view_permission(self, request, obj=None): return request.user.is_active and request.user.is_staff def has_change_permission(self, request, obj=None): if request.user.is_superuser: return True if not request.user.is_active or not request.user.is_staff: return False return obj is not None and obj.pk == request.user.pk def has_delete_permission(self, request, obj=None): return request.user.is_superuser def _messenger_choices(): choices = [] available = get_available_messengers() for messenger_id, meta in sorted(available.items(), key=lambda item: item[1]['alias'].lower()): choices.append((messenger_id, meta['alias'])) return choices class AdminMessengerContactInlineForm(forms.ModelForm): messenger = forms.ChoiceField(label='Мессенджер') class Meta: model = AdminMessengerContact fields = ('messenger', 'contact') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) choices = _messenger_choices() current_value = self.initial.get('messenger') or getattr( self.instance, 'messenger', None) if current_value and current_value not in {value for value, _ in choices}: choices.append((current_value, current_value)) self.fields['messenger'].choices = choices class AdminMessengerContactInline(admin.TabularInline): model = AdminMessengerContact form = AdminMessengerContactInlineForm extra = 0 verbose_name = 'Контакт мессенджера' verbose_name_plural = 'Контакты мессенджеров' fields = ('messenger', 'contact') class Media: css = { 'all': ('admin/css/inline_overrides.css',) } def get_readonly_fields(self, request, obj=None): if request.user.is_superuser: return () if obj is not None and obj.pk == request.user.pk: return () return self.fields def has_add_permission(self, request, obj): if request.user.is_superuser: return True if not request.user.is_active or not request.user.is_staff: return False return obj is not None and obj.pk == request.user.pk def has_view_permission(self, request, obj=None): return request.user.is_active and request.user.is_staff def has_change_permission(self, request, obj=None): if request.user.is_superuser: return True if not request.user.is_active or not request.user.is_staff: return False return obj is not None and obj.pk == request.user.pk def has_delete_permission(self, request, obj=None): if request.user.is_superuser: return True if not request.user.is_active or not request.user.is_staff: return False return obj is not None and obj.pk == request.user.pk @admin.register(User) class CustomUserAdmin(UserAdmin): inlines = [AdminContactProfileInline, AdminMessengerContactInline] # Non-superusers can only access and edit their own admin account. # Superusers keep full access to all accounts and permission fields. staff_self_fieldsets = ( (None, {'fields': ('username', 'password')}), ('Персональная информация', { 'fields': ('first_name', 'last_name', 'email')}), ) def get_queryset(self, request): qs = super().get_queryset(request) if request.user.is_superuser: return qs if request.user.is_staff: return qs return qs.none() def get_fieldsets(self, request, obj=None): if request.user.is_superuser: return super().get_fieldsets(request, obj) # Staff can view other admin accounts, but can edit only their own fields. if obj is not None and obj.pk != request.user.pk: return super().get_fieldsets(request, obj) return self.staff_self_fieldsets def has_add_permission(self, request): if request.user.is_superuser: return super().has_add_permission(request) return False def save_model(self, request, obj, form, change): # Users created from admin should be staff by default. if not change: obj.is_staff = True super().save_model(request, obj, form, change) def has_view_permission(self, request, obj=None): if request.user.is_superuser: return super().has_view_permission(request, obj) if not request.user.is_staff: return False return True def has_change_permission(self, request, obj=None): if request.user.is_superuser: return super().has_change_permission(request, obj) if not request.user.is_staff: return False if obj is None: return True return obj.pk == request.user.pk def has_delete_permission(self, request, obj=None): if request.user.is_superuser: return super().has_delete_permission(request, obj) return False def has_module_permission(self, request): if request.user.is_superuser: return super().has_module_permission(request) return request.user.is_active and request.user.is_staff def get_inline_instances(self, request, obj=None): if obj is not None: AdminContactProfile.objects.get_or_create(user=obj) return super().get_inline_instances(request, obj) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.model._meta.verbose_name = 'Администратор' self.model._meta.verbose_name_plural = 'Администраторы'