# documents/admin.py """ Django admin для моделей Document и DocumentAcknowledgement. Основные возможности: - Использование django-filer для управления файлами - Поддержка drag & drop загрузки файлов - Превью thumbnails в списке и форме редактирования - Полнотекстовый поиск по содержимому файлов - Версионирование через django-reversion - Workflow управление через django-fsm """ from django.contrib import admin from django.contrib.auth import get_user_model from django.urls import reverse from django.utils.html import format_html, mark_safe from django.utils.translation import gettext_lazy as _ from .models import Document, DocumentAcknowledgement 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", "is_regulation", "acknowledgement_status", ) list_filter = ("sent_to_all", "is_regulation", "uploaded_at", "departments") search_fields = ("title", "description", "file__name", "file__description") filter_horizontal = ( "recipients", "departments", "acknowledgement_recipients", "acknowledgement_departments", ) actions = ["send_document"] # Для django-reversion history_latest_first = True fieldsets = ( ( None, { "fields": ( "title", "file", # FilerFileField с drag & drop "file_preview", # Превью файла "description", "is_regulation", "sent_to_all", "departments", "recipients", ), }, ), ( _("Meta"), { "fields": ( "uploaded_by", "uploaded_at", "file_size", "file_extension", ), "classes": ("collapse",), }, ), ( _("Ознакомление"), { "fields": ( "acknowledgement_required", "acknowledgement_for_all", "acknowledgement_departments", "acknowledgement_recipients", "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_acknowledgement_recipients_qs(obj)) total = len(recipients) if total == 0: return "—" acked_count = DocumentAcknowledgement.objects.filter( document=obj, user__in=recipients, ).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 для рассылки документов получателям.""" 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): """Возвращает список всех получателей документа.""" from documents.audience import document_access_audience return list(document_access_audience(obj)) def get_acknowledgement_recipients_qs(self, obj): """Возвращает сотрудников, обязанных ознакомиться.""" from documents.audience import document_acknowledgement_audience return list(document_acknowledgement_audience(obj)) 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_acknowledgement_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 request.user.is_superuser def has_delete_permission(self, request, obj=None): """Запрещаем удаление ознакомлений (для аудита).""" return request.user.is_superuser