# documents/admin.py """ Django admin для моделей Document и DocumentAcknowledgement. Основные возможности: - Использование django-filer для управления файлами - Поддержка drag & drop загрузки файлов - Превью thumbnails в списке и форме редактирования - Полнотекстовый поиск по содержимому файлов - Версионирование через django-reversion - Workflow управление через django-fsm """ from django.contrib import admin from django.utils.translation import gettext_lazy as _ from django.utils.html import format_html, mark_safe from django.urls import reverse from .models import Document, DocumentAcknowledgement from django.contrib.auth import get_user_model User = get_user_model() class AcknowledgementInline(admin.TabularInline): """Inline для отображения ознакомлений с документом.""" model = DocumentAcknowledgement extra = 0 can_delete = False verbose_name = _("Ознакомление") verbose_name_plural = _("Ознакомления") fields = ('user_link', 'acknowledged_at') readonly_fields = ('user_link', 'acknowledged_at') def user_link(self, obj): """Ссылка на профиль сотрудника.""" if not obj.user: return '—' url = reverse( 'admin:{}_{}_change'.format( User._meta.app_label, User._meta.model_name ), args=[obj.user.pk] ) return format_html('{}', url, obj.user.get_full_name() or obj.user) user_link.short_description = _("Сотрудник") @admin.register(Document) class DocumentAdmin(admin.ModelAdmin): """ Admin для Document с поддержкой django-filer и django-fsm. Возможности: - Drag & drop загрузка файлов через filer - Превью thumbnails для изображений и PDF - Поиск по содержимому файлов - Версионирование через django-reversion - Workflow управление через django-fsm """ inlines = [AcknowledgementInline] list_display = ( 'title', 'file_thumbnail', 'file_info', 'uploaded_at', 'sent_to_all', 'acknowledgement_status', ) list_filter = ('sent_to_all', 'uploaded_at', 'departments') search_fields = ('title', 'description', 'file__name', 'file__description') filter_horizontal = ('recipients', 'departments') actions = ['send_document'] # Для django-reversion history_latest_first = True fieldsets = ( (None, { 'fields': ( 'title', 'file', # FilerFileField с drag & drop 'file_preview', # Превью файла 'description', 'sent_to_all', 'departments', 'recipients' ), }), (_('Meta'), { 'fields': ('uploaded_by', 'uploaded_at', 'file_size', 'file_extension'), 'classes': ('collapse',), }), (_('Ознакомление'), { 'fields': ('recipients_summary', 'pending_list'), }), ) readonly_fields = ( 'uploaded_by', 'uploaded_at', 'file_preview', 'file_size', 'file_extension', 'recipients_summary', 'pending_list', ) def save_model(self, request, obj, form, change): """Автоматически устанавливаем uploaded_by при создании.""" if not obj.pk: obj.uploaded_by = request.user super().save_model(request, obj, form, change) def file_thumbnail(self, obj): """Превью файла в списке документов.""" if not obj.file: return '—' # Для изображений показываем thumbnail if obj.file.file_type == 'Image': thumbnail_url = obj.get_thumbnail('small') if thumbnail_url: return format_html( '', obj.file.url, thumbnail_url ) # Для других файлов показываем иконку icon_url = obj.file.icons.get('48', '') if icon_url: return format_html( '', obj.file.url, icon_url ) return format_html('📎', obj.file.url) file_thumbnail.short_description = _('Превью') def file_preview(self, obj): """Большое превью файла в форме редактирования.""" if not obj.file: return '—' # Для изображений показываем большой thumbnail if obj.file.file_type == 'Image': thumbnail_url = obj.get_thumbnail('medium') if thumbnail_url: return format_html( '
' '' '' '
', obj.file.url, thumbnail_url ) # Для PDF можем показать первую страницу (если настроено) return format_html( '
' '📎 Открыть файл' '
', obj.file.url ) file_preview.short_description = _('Предпросмотр') def file_info(self, obj): """Информация о файле (размер, тип).""" if not obj.file: return '—' # Форматируем размер файла size = obj.file_size if size < 1024: size_str = f'{size} B' elif size < 1024 * 1024: size_str = f'{size / 1024:.1f} KB' else: size_str = f'{size / (1024 * 1024):.1f} MB' ext = obj.file_extension or '—' return f'{ext.upper()} • {size_str}' file_info.short_description = _('Файл') def file_size(self, obj): """Размер файла в читаемом формате.""" size = obj.file_size if not size: return '—' if size < 1024: return f'{size} байт' elif size < 1024 * 1024: return f'{size / 1024:.2f} KB' else: return f'{size / (1024 * 1024):.2f} MB' file_size.short_description = _('Размер файла') def file_extension(self, obj): """Расширение файла.""" return obj.file_extension or '—' file_extension.short_description = _('Расширение') def acknowledgement_status(self, obj): """Статус ознакомления (N из M).""" recipients = set(self.get_recipients_qs(obj)) total = len(recipients) if total == 0: return '—' acked_count = DocumentAcknowledgement.objects.filter(document=obj).count() if acked_count == total: return format_html('✅ {}/{}', acked_count, total) elif acked_count == 0: return format_html('❌ 0/{}', total) else: return format_html('⏳ {}/{}', acked_count, total) acknowledgement_status.short_description = _('Ознакомлены') def send_document(self, request, queryset): """Action для рассылки документов получателям.""" from .tasks import send_document_to_recipients_task for doc in queryset: # TODO: Создать новую задачу send_document_to_recipients_task pass self.message_user(request, _("⚠️ Рассылка будет реализована в обновленном tasks.py")) send_document.short_description = _('Отправить документ') def get_recipients_qs(self, obj): """Возвращает список всех получателей документа.""" if obj.sent_to_all: return list(User.objects.filter(is_active=True)) # Собираем получателей из recipients и departments recipients_set = set(obj.recipients.all()) # Добавляем сотрудников из отделов for department in obj.departments.all(): recipients_set.update(department.active_employees) return list(recipients_set) def recipients_summary(self, obj): """Краткий список получателей.""" recipients = self.get_recipients_qs(obj) count = len(recipients) if count == 0: return _("— никто —") names = [u.get_full_name() or str(u) for u in recipients[:5]] if count > 5: return format_html("{}… (ещё {})", ", ".join(names), count - 5) return ", ".join(names) recipients_summary.short_description = _('Получатели') def pending_list(self, obj): """Список сотрудников, которые ещё не ознакомились.""" recipients = set(self.get_recipients_qs(obj)) acked_ids = set( DocumentAcknowledgement.objects .filter(document=obj) .values_list('user_id', flat=True) ) pending = [u for u in recipients if u.id not in acked_ids] if not pending: return format_html('✅ {}', _("Все ознакомились")) links = [] for u in pending[:20]: # Ограничиваем 20, чтобы не перегружать url = reverse( 'admin:{}_{}_change'.format( User._meta.app_label, User._meta.model_name ), args=[u.pk] ) links.append(format_html( '{}', url, u.get_full_name() or u )) result = '
'.join(links) if len(pending) > 20: result += f'
... и ещё {len(pending) - 20}' return mark_safe(result) pending_list.short_description = _('Кто ещё не ознакомился') @admin.register(DocumentAcknowledgement) class DocumentAcknowledgementAdmin(admin.ModelAdmin): """Admin для записей об ознакомлении с документами.""" list_display = ('document', 'user', 'acknowledged_at') list_filter = ('document', 'acknowledged_at') search_fields = ( 'user__phone_number', 'user__last_name', 'user__first_name', 'document__title' ) readonly_fields = ('user', 'document', 'acknowledged_at') ordering = ('-acknowledged_at',) def has_add_permission(self, request): """Запрещаем ручное добавление ознакомлений.""" return False def has_delete_permission(self, request, obj=None): """Запрещаем удаление ознакомлений (для аудита).""" return False