From 280383f02dc547507bb5e9fc43a7a9236772cd1e Mon Sep 17 00:00:00 2001 From: erjemin Date: Wed, 10 Jun 2026 19:55:09 +0300 Subject: [PATCH] =?UTF-8?q?mod:=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=20(01)=20ImageAdmin=20(01)=20+=D0=B2=D0=B8=D1=80=D1=82?= =?UTF-8?q?=D1=83=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B8=D0=B7=20filer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lpon_site/frontend/admin.py | 200 +++++++++++++++++++++++++++++++++-- lpon_site/frontend/models.py | 19 ++-- 2 files changed, 202 insertions(+), 17 deletions(-) diff --git a/lpon_site/frontend/admin.py b/lpon_site/frontend/admin.py index 8dddced..02fe363 100644 --- a/lpon_site/frontend/admin.py +++ b/lpon_site/frontend/admin.py @@ -1,23 +1,207 @@ # Кастомная конфигурация Django Admin для LPON сайта. # Регистрируем модели с удобным интерфейсом. +from django import forms from django.contrib import admin -from django.contrib.auth.models import User, Group +from django.utils.html import format_html, mark_safe +from easy_thumbnails.files import get_thumbnailer from .models import ( TbImage, TbArticle, TbArtist, TbItem, TbLabel, TbSeller, TbOffer, TbSource, TbOfferHistory, TbMusicStyle, TbFormat ) # ============================================================================ -# ModelAdmin классы для каждой модели +# Кастомная форма для админки TbImage # ============================================================================ -class ImageAdmin(admin.ModelAdmin): - """Админ для изображений""" - list_display = ('id', 'l_img_source', 'l_img_reality', 'i_img_sort', 't_img_created') - list_filter = ('l_img_source', 'l_img_reality', 't_img_created') - ordering = ('-t_img_created', 'i_img_sort') - readonly_fields = ('t_img_created', 't_img_updated') +class TbImageAdminForm(forms.ModelForm): + """ + Кастомная форма для TbImage в админке. + Добавляет виртуальные поля для редактирования метаданных filer_image + (default_alt_text и default_caption), которые не хранятся в TbImage, но есть в filer_image + """ + # Виртуальные поля для заполнения метаданных filer_image + filer_alt_text = forms.CharField( + max_length=255, + required=False, + widget=forms.TextInput(attrs={ + 'placeholder': 'Введите alt-текст для картинки', + 'class': 'vTextField', + 'size': 200, + }), + label='ALT (новый)', + help_text='Текст для alt-атрибута картинки <img alt="" .../>.' + ' Будет сохранён в filer_image.default_alt_text' + ) + filer_caption = forms.CharField( + max_length=255, + required=False, + widget=forms.TextInput(attrs={ + 'placeholder': 'Введите title-описание картинки', + 'class': 'vTextField', + 'cols': 120, + }), + label='TITLE (новый)', + help_text='Текст для title-атрибута картинки <img title="" .../>.' + ' Будет сохранён в filer_image.default_caption' + ) + class Meta: + model = TbImage + fields = ('image', 'l_img_source', 'l_img_reality', 's_img_src_url', 'i_img_sort', 'f_img_confidence_score') + + def __init__(self, *args, **kwargs): + """ + При инициализации формы подгружаем текущие значения alt/caption из filer_image. + """ + super().__init__(*args, **kwargs) + + # Если редактируем существующую запись, получаем текущие значения из filer + if self.instance and self.instance.pk and hasattr(self.instance, 'image') and self.instance.image: + try: + filer_image = self.instance.image + # Получаем текущие значения из filer и заполняем виртуальные поля + self.fields['filer_alt_text'].initial = filer_image.default_alt_text or '' + self.fields['filer_caption'].initial = filer_image.default_caption or '' + except Exception: + # Если ошибка при получении filer_image, просто оставляем пустые значения + pass + + +class ImageAdmin(admin.ModelAdmin): + """ + Админ для изображений TbImage с поддержкой редактирования метаданных filer_image. + + Позволяет пользователю заполнить default_alt_text и default_caption для картинки в filer + прямо в админке TbImage, без необходимости отдельного редактирования фiler. + """ + form = TbImageAdminForm # Используем кастомную форму с виртуальными полями + list_display = ('id', 'image_thumbnail', 'image', '_display_filer_alt_text', 'i_img_sort', 't_img_created') + list_display_links = ('id', 'image_thumbnail', 'image') + list_filter = ('l_img_source', 'l_img_reality', 't_img_created') + ordering = ('image', 'i_img_sort') + readonly_fields = ('t_img_created', 't_img_updated', '_display_filer_alt_text', '_display_filer_caption') + fieldsets = ( + ('Изображение', { + 'fields': ('image', 'l_img_source', 'l_img_reality', 's_img_src_url', 'i_img_sort', + 'f_img_confidence_score'), + 'description': 'Основные данные об изображении и источнике', + }), + ('Метаданные filer (SEO для картинок)', { + 'fields': ('_display_filer_alt_text', '_display_filer_caption', 'filer_alt_text', 'filer_caption'), + 'description': 'Редактируемые поля для заполнения Alt текста и описания в filer. Если не заполнить,' + ' текущие значения останутся без изменений (или не будут заполнены при создании).', + # 'classes': ('collapse',), + }), + ('Служебная информация', { + 'fields': ('t_img_created', 't_img_updated'), + 'classes': ('collapse',), + }), + ) + + def image_thumbnail(self, obj): + """ + Отображает миниатюру картинки (40x40) в списке и в list_display_links. + Использует easy_thumbnails для автоматического создания и кэширования миниатюр. + """ + if obj and obj.image: + try: + # Получаем thumbnailer для картинки через easy_thumbnails + thumbnailer = get_thumbnailer(obj.image.file) + + # Генерируем или получаем уже созданную миниатюру размером 40x40 + thumbnail = thumbnailer.get_thumbnail({ + 'size': (40, 40), # Размер миниатюры: 40x40 пикселей + 'crop': 'smart', # Умное обрезание для сохранения центра картинки + 'quality': 95, # Качество JPEG/WebP (95% для хорошего вида) + }) + + # Возвращаем HTML тег img с миниатюрой + # format_html автоматически экранирует опасные символы + return format_html( + '{}', + thumbnail.url, + obj.image.name or 'картинка' # Alt текст для доступности + ) + except Exception as e: + # Если ошибка при генерации миниатюры (нет файла, ошибка формата и т.д.) + return mark_safe('(ошибка)') + + # Если картинка не привязана + return mark_safe('(нет картинки)') + + # Установляем название столбца в админке + image_thumbnail.short_description = 'Миниатюра (40x40)' + + def _display_filer_alt_text(self, obj): + """ + Display-метод для отображения текущего alt-текста в filer (read-only). + Показывает, какой текст сейчас установлен в filer_image. + """ + if obj and obj.pk and obj.image: + try: + current = obj.image.default_alt_text or '(пусто)' + return f'ALT: {current}' + except Exception: + return '(ошибка при получении)' + return '(новая запись, значение будет установлено после сохранения)' + + _display_filer_alt_text.short_description = 'ALT из filer' + + def _display_filer_caption(self, obj): + """ + Display-метод для отображения текущего caption в filer (read-only). + Показывает, какой текст сейчас установлен в filer_image. + """ + if obj and obj.pk and obj.image: + try: + current = obj.image.default_caption or '(пусто)' + return f'TITLE: {current}' + except Exception: + return '(ошибка при получении)' + return '(новая запись, значение будет установлено после сохранения)' + + _display_filer_caption.short_description = 'TITLE из filer' + + def save_model(self, request, obj, form, change): + """ + Переопределяем save_model для обновления метаданных filer_image. + + Если пользователь заполнил виртуальные поля filer_alt_text или filer_caption, + их значения сохраняются в соответствующие поля filer_image. + Если поля не заполнены, текущие значения в filer остаются без изменений. + """ + # Сначала сохраняем саму запись TbImage + super().save_model(request, obj, form, change) + + # Работаем с meta-данными filer только если картинка привязана + if obj.image: + try: + filer_image = obj.image + + # Обновляем alt_text, если было заполнено в форме + alt_text = form.cleaned_data.get('filer_alt_text', '').strip() + if alt_text: # Если пользователь что-то ввел + filer_image.default_alt_text = alt_text + + # Обновляем caption, если было заполнено в форме + caption = form.cleaned_data.get('filer_caption', '').strip() + if caption: # Если пользователь что-то ввел + filer_image.default_caption = caption + + # Сохраняем filer_image с новыми метаданными + filer_image.save() + + except Exception as e: + # Логируем ошибку, но не прерываем процесс сохранения + import logging + logger = logging.getLogger(__name__) + logger.error(f'Ошибка при сохранении метаданных filer_image: {e}') + + +# ============================================================================ +# Остальные ModelAdmin классы +# ============================================================================ class ArticleAdmin(admin.ModelAdmin): """Админ для статей""" diff --git a/lpon_site/frontend/models.py b/lpon_site/frontend/models.py index 260d9e6..ca5f825 100644 --- a/lpon_site/frontend/models.py +++ b/lpon_site/frontend/models.py @@ -275,9 +275,9 @@ class TbImage(models.Model): image = FilerImageField( # Файл через django_filer - null=True, - blank=True, - on_delete=models.SET_NULL, + null=False, + blank=False, + on_delete=models.DO_NOTHING, verbose_name='Файл изображения', help_text='Файл изображения, загруженный через django_filer.', ) @@ -299,14 +299,14 @@ class TbImage(models.Model): s_img_src_url = models.URLField( blank=True, null=True, - verbose_name='URL источника', - help_text='Если изображение взято из внешнего источника (например, Discogs)', + verbose_name='URL', + help_text='URL источника, если изображение взято (в том числе и парсером) из внешнего источника (например, Discogs)', ) i_img_sort = models.IntegerField( # Порядок (сортировка) вывода default=0, db_index=True, - verbose_name='Cортировка', + verbose_name='Сортировка', help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. Можно использовать' ' для указания обложки (0), задника (1) и т.д.', ) @@ -315,14 +315,15 @@ class TbImage(models.Model): null=True, blank=True, default=None, - verbose_name='Уверенность (для автоматических данных)', - help_text='0.0 - 1.0, насколько уверены, что это правильное изображение', + verbose_name='Достоверность', + help_text='Уверенность (для автоматических данных) 0.0 - 1.0, насколько уверены, что это правильное изображение', ) s_img_copyright = models.CharField( - # Авторские права и лицензия + # Авторские права и лицензия (по идее -- ненужное поле. Можно в filer использовать `obj.image.author`. max_length=255, blank=True, default='', + editable=False, # Поле не редактируется. Кандидат на удаление. verbose_name='Авторские права / Лицензия', help_text='Например: "© 2024 User" или "CC-BY"', )