- Добавлена конфигурация django-filer в settings.py - Настроены URL для filer admin - Обновлен admin.py для работы с FilerFileField - Обновлен индекс поиска для новой структуры
325 lines
12 KiB
Python
325 lines
12 KiB
Python
# 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('<a href="{}">{}</a>', 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(
|
||
'<a href="{}" target="_blank"><img src="{}" style="max-height:50px; max-width:50px;"/></a>',
|
||
obj.file.url,
|
||
thumbnail_url
|
||
)
|
||
|
||
# Для других файлов показываем иконку
|
||
icon_url = obj.file.icons.get('48', '')
|
||
if icon_url:
|
||
return format_html(
|
||
'<a href="{}" target="_blank"><img src="{}" style="height:32px;"/></a>',
|
||
obj.file.url,
|
||
icon_url
|
||
)
|
||
|
||
return format_html('<a href="{}" target="_blank">📎</a>', 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(
|
||
'<div style="margin:10px 0;">'
|
||
'<a href="{}" target="_blank">'
|
||
'<img src="{}" style="max-width:400px; border:1px solid #ddd; border-radius:4px;"/>'
|
||
'</a></div>',
|
||
obj.file.url,
|
||
thumbnail_url
|
||
)
|
||
|
||
# Для PDF можем показать первую страницу (если настроено)
|
||
return format_html(
|
||
'<div style="margin:10px 0;">'
|
||
'<a href="{}" target="_blank" class="button">📎 Открыть файл</a>'
|
||
'</div>',
|
||
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('<span style="color:green;">✅ {}/{}</span>', acked_count, total)
|
||
elif acked_count == 0:
|
||
return format_html('<span style="color:red;">❌ 0/{}</span>', total)
|
||
else:
|
||
return format_html('<span style="color:orange;">⏳ {}/{}</span>', 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('<span style="color:green;">✅ {}</span>', _("Все ознакомились"))
|
||
|
||
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(
|
||
'<a href="{}">{}</a>',
|
||
url,
|
||
u.get_full_name() or u
|
||
))
|
||
|
||
result = '<br>'.join(links)
|
||
if len(pending) > 20:
|
||
result += f'<br><em>... и ещё {len(pending) - 20}</em>'
|
||
|
||
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
|
||
|
||
|