From 71cac55221d5a565a42eef5ff8665d0896653d5f Mon Sep 17 00:00:00 2001 From: erjemin Date: Tue, 9 Jun 2026 20:30:33 +0300 Subject: [PATCH] =?UTF-8?q?mod:=20django-filer=20=D0=BD=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=20(13)=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B0=20heif/heic=20(fine)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lpon_site/frontend/apps.py | 204 ++++++++++---------------------- lpon_site/frontend/models.py | 34 ------ lpon_site/lpon_site/settings.py | 17 +-- 3 files changed, 66 insertions(+), 189 deletions(-) diff --git a/lpon_site/frontend/apps.py b/lpon_site/frontend/apps.py index df7fbe2..2cd8549 100644 --- a/lpon_site/frontend/apps.py +++ b/lpon_site/frontend/apps.py @@ -1,9 +1,11 @@ import os +import hashlib import logging from io import BytesIO + from django.apps import AppConfig from django.core.files.base import ContentFile -from django.db.models.signals import pre_save +from django.core.exceptions import ValidationError from PIL import Image as PILImage import pillow_heif @@ -16,16 +18,11 @@ from lpon_site.settings import ( FILE_VALIDATORS, ) -# Регистрируем плагин HEIF в Pillow +# Регистрируем плагин HEIF в Pillow для поддержки HEIC/HEIF файлов pillow_heif.register_heif_opener() -# Получаем логгер для текущего модуля logger = logging.getLogger(__name__) -# Кэш для размеров изображений больше не нужен! -# Filer сам распознает WebP по mime_type и создаст Image запись -# PIL автоматически получит размеры из WebP файла - # ============================================================================== # Конфигурация приложения для фронтенда lpon @@ -41,24 +38,22 @@ class FrontendConfig(AppConfig): admin.site.site_title = 'LPON Administrator' admin.site.index_title = 'Добро пожаловать в LPON' + # ============================================================================== # Кастомная конфигурация для django-filer -# - Патчинг MultiStorageFieldFile.save() для автоматической конвертации в WebP +# Патчинг MultiStorageFieldFile.save() для автоматической конвертации в WebP # ============================================================================== class CustomFilerConfig(AppConfig): name = 'filer' verbose_name = 'Медиафайлы' - # ======================================================================== # Атрибуты конфигурации Django-Filer (импортированы из settings.py) - # ======================================================================== FILER_ENABLE_PERMISSIONS = FILER_ENABLE_PERMISSIONS FILER_UPLOADER_MAX_FILE_SIZE = FILER_UPLOADER_MAX_FILE_SIZE FILER_WHITELIST_FOR_PATH_ACCESS = FILER_WHITELIST_FOR_PATH_ACCESS MIME_TYPE_WHITELIST = MIME_TYPE_WHITELIST FILE_VALIDATORS = FILE_VALIDATORS - @staticmethod def _convert_to_webp_if_needed(name: str, content): """ @@ -67,61 +62,41 @@ class CustomFilerConfig(AppConfig): Возвращает кортеж: (content, new_name, was_converted, image_width, image_height) - КРИТИЧНО: Функция ДОЛЖНА получать размеры из УЖЕ ПРЕОБРАЗОВАННОГО WebP файла, а не из исходника! - Почему: - - PIL может открыть HEIC (благодаря pillow_heif) но размеры исходного могут отличаться - - После конвертации HEIC→WebP размеры могут измениться (масштабирование, кроппинг) - - Filer использует размеры для определения каких миниатюр генерировать - - Размеры ДОЛЖНЫ совпадать с реальным WebP файлом в хранилище! + Размеры ОБЯЗАТЕЛЬНО получаются из исходного изображения перед конвертацией. + При конвертации в WebP размеры пикселей не меняются, меняется только качество файла. """ _, original_ext = os.path.splitext(name) - image_width = None - image_height = None - # Список расширений, которые конвертируем в WebP convertible_extensions = [".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".heic", ".heif"] if original_ext.lower() in convertible_extensions: try: - # Шаг 1: Открываем исходный файл с помощью PIL (поддерживает HEIC благодаря pillow_heif) + # Открываем исходный файл с помощью PIL content.seek(0) img = PILImage.open(BytesIO(content.read())) - logger.debug(f"Открыт файл '{name}' с расширением '{original_ext}'. Размер: {img.size}") - # Шаг 2: Конвертируем CMYK в RGB если требуется (WebP не поддерживает CMYK) + # Получаем размеры изображения ДО конвертации (они не меняются при сохранении в WebP) + image_width, image_height = img.size + + # Конвертируем CMYK в RGB если требуется if img.mode == 'CMYK': img = img.convert('RGB') - logger.debug("Конвертировано CMYK → RGB") - # Шаг 3: Сохраняем исходное изображение в WebP формат в памяти (BytesIO) + # Сохраняем в WebP формат в памяти webp_buffer = BytesIO() img.save(webp_buffer, format="WEBP", quality=THUMBNAIL_WEBP_QUALITY) - webp_buffer.seek(0) - logger.debug(f"Изображение конвертировано в WebP (качество={THUMBNAIL_WEBP_QUALITY})") + webp_data = webp_buffer.getvalue() - # Шаг 4: КРИТИЧНО - Переоткрываем WebP из памяти и получаем его РЕАЛЬНЫЕ размеры - # Это гарантирует что размеры совпадают с тем что РЕАЛЬНО будет сохранено в хранилище! - webp_buffer.seek(0) - webp_img = PILImage.open(BytesIO(webp_buffer.read())) - image_width, image_height = webp_img.size - logger.info(f"Получены размеры CONVERTED WebP: {image_width}x{image_height}px") - - # Шаг 5: Возвращаем WebP контент в начало буфера для последующего сохранения - webp_buffer.seek(0) - - # Шаг 6: Изменяем расширение на .webp + # Изменяем расширение на .webp new_name = os.path.splitext(name)[0] + ".webp" - logger.info(f"Конвертировано '{name}' → '{new_name}' (WebP). " - f"Размеры: {image_width}x{image_height}px") - # Возвращаем WebP контент и размеры из уже преобразованного файла - return ContentFile(webp_buffer.read()), new_name, True, image_width, image_height + return ContentFile(webp_data), new_name, True, image_width, image_height - except Exception: - logger.error(f"Ошибка при конвертации '{name}' в WebP.", exc_info=True) - # При ошибке возвращаем исходный файл без размеров - content.seek(0) - return content, name, False, None, None + except Exception as e: + # Поднимаем ValidationError чтобы она показывалась в админке + error_msg = f"Ошибка при конвертации '{name}' в WebP: {str(e)}" + logger.error(error_msg, exc_info=True) + raise ValidationError(error_msg) # Если файл не нуждается в конвертации, возвращаем как есть content.seek(0) @@ -129,113 +104,58 @@ class CustomFilerConfig(AppConfig): def ready(self): """ - Готовимся к работе: патчим MultiStorageFieldFile.save() для WebP конвертации. + Инициализация: патчинг MultiStorageFieldFile.save() для WebP конвертации. """ - # КРИТИЧЕСКИ ВАЖНО: Переопределяем IMAGE_EXTENSIONS и IMAGE_MIME_TYPES в filer - # Это гарантирует что filer распознает WebP файлы как изображения, а не как обычные файлы - # - # Почему это нужно делать здесь в apps.py: - # - IMAGE_EXTENSIONS и IMAGE_MIME_TYPES в filer это ГЛОБАЛЬНЫЕ КОНСТАНТЫ (не getattr настройки) - # - Они импортируются как: from filer.settings import IMAGE_EXTENSIONS - # - Нет встроенного механизма для переопределения через settings.py - # - Единственный способ - модифицировать их после импорта, в ready() методе AppConfig - # - # Это стандартная практика для переопределения внутренних настроек третьих пакетов в Django + from filer.fields.multistorage_file import MultiStorageFieldFile from filer import settings as filer_settings - # Добавляем WebP и HEIC/HEIF расширения к списку расширений изображений - if '.webp' not in filer_settings.IMAGE_EXTENSIONS: - filer_settings.IMAGE_EXTENSIONS = list(filer_settings.IMAGE_EXTENSIONS) + ['.webp'] - if '.heic' not in filer_settings.IMAGE_EXTENSIONS: - filer_settings.IMAGE_EXTENSIONS = list(filer_settings.IMAGE_EXTENSIONS) + ['.heic'] - if '.heif' not in filer_settings.IMAGE_EXTENSIONS: - filer_settings.IMAGE_EXTENSIONS = list(filer_settings.IMAGE_EXTENSIONS) + ['.heif'] - - # Добавляем WebP и HEIC/HEIF mime-типы - if 'webp' not in filer_settings.IMAGE_MIME_TYPES: - filer_settings.IMAGE_MIME_TYPES = list(filer_settings.IMAGE_MIME_TYPES) + ['webp'] - if 'heic' not in filer_settings.IMAGE_MIME_TYPES: - filer_settings.IMAGE_MIME_TYPES = list(filer_settings.IMAGE_MIME_TYPES) + ['heic'] - if 'heif' not in filer_settings.IMAGE_MIME_TYPES: - filer_settings.IMAGE_MIME_TYPES = list(filer_settings.IMAGE_MIME_TYPES) + ['heif'] - - logger.info(f"Обновлены filer IMAGE_EXTENSIONS: {filer_settings.IMAGE_EXTENSIONS}") - logger.info(f"Обновлены filer IMAGE_MIME_TYPES: {filer_settings.IMAGE_MIME_TYPES}") - - from filer.fields.multistorage_file import MultiStorageFieldFile - - logger.info("Patching MultiStorageFieldFile.save() for WebP conversion...") original_save = MultiStorageFieldFile.save + # Важно: Добавляем поддержку HEIC/HEIF файлов в filer + # IMAGE_EXTENSIONS это глобальные константы в filer, которые нельзя переопределить через settings.py + # Единственный способ - модифицировать после импорта в ready() методе AppConfig + filer_settings.IMAGE_EXTENSIONS = list(set(filer_settings.IMAGE_EXTENSIONS + ['.heic', '.heif'])) + filer_settings.IMAGE_MIME_TYPES = list(set(filer_settings.IMAGE_MIME_TYPES + ['heic', 'heif'])) + def patched_save(self_instance, name, content, save=True): - """ - Патчированная версия MultiStorageFieldFile.save() для конвертации HEIC/HEIF в WebP. + """ + Патчированная версия MultiStorageFieldFile.save(). + Конвертирует изображения в WebP и передает размеры для генерации миниатюр. + """ + new_content, new_name, converted, img_width, img_height = ( + CustomFilerConfig._convert_to_webp_if_needed(name, content) + ) - Процесс: - 1. Конвертирует загруженное изображение в WebP (JPEG, PNG, BMP, TIFF, HEIC, HEIF) - 2. Устанавливает правильный mime_type=image/webp - 3. Вызывает оригинальный save() с преобразованным контентом - 4. Filer автоматически распознает WebP как Image, читает размеры и создает Image запись - """ - # Шаг 1: Преобразуем загруженный файл в WebP если требуется - # Метод вернет размеры ИЗ ПРЕОБРАЗОВАННОГО WebP (не исходного файла) - new_content, new_name, converted, img_width, img_height = CustomFilerConfig._convert_to_webp_if_needed(name, content) + if converted: + # Устанавливаем correct mime_type для WebP + self_instance.instance.mime_type = "image/webp" - # Шаг 2: Если файл был конвертирован в WebP, обновляем метаданные - if converted: - # Важно: Устанавливаем mime_type ПЕРЕД сохранением - # Filer использует mime_type чтобы определить тип файла (Image vs File) - self_instance.instance.mime_type = "image/webp" - logger.info(f"[WebP] Конвертировано '{name}' → '{new_name}'. " - f"Size: {img_width}x{img_height}px, mime_type=image/webp") + # Обновляем original_filename если есть + if hasattr(self_instance.instance, 'original_filename'): + if self_instance.instance.original_filename: + original_filename, _ = os.path.splitext( + self_instance.instance.original_filename + ) + self_instance.instance.original_filename = f"{original_filename}.webp" - # Обновляем original_filename если есть - if hasattr(self_instance.instance, 'original_filename') and self_instance.instance.original_filename: - original_filename, _ = os.path.splitext(self_instance.instance.original_filename) - self_instance.instance.original_filename = f"{original_filename}.webp" + # КРИТИЧНО: Обновляем размер файла и sha1 на основе WebP данных, а не исходных + new_content.seek(0) + webp_bytes = new_content.read() + new_content.seek(0) + self_instance.instance._file_size = len(webp_bytes) + self_instance.instance.sha1 = hashlib.sha1(webp_bytes).hexdigest() - # Шаг 3: Вызываем оригинальный save() метод с new_name и new_content - result = original_save(self_instance, new_name, new_content, save) + # Вызываем оригинальный save() метод + result = original_save(self_instance, new_name, new_content, save) - # Шаг 4: После original_save() гарантируем что mime_type=image/webp сохранен в БД - # (на случай если решение о типе файла уже было принято) - if converted and new_name.lower().endswith('.webp'): - # Если instance имеет id после save, обновляем mime_type в БД - if self_instance.instance.id: - # Обновляем только mime_type - self_instance.instance.__class__.objects.filter(id=self_instance.instance.id).update( - mime_type='image/webp' - ) - logger.info(f"[WebP] Updated mime_type in DB for id={self_instance.instance.id}") + # После сохранения гарантируем что mime_type=image/webp в БД + if converted and new_name.lower().endswith('.webp'): + if self_instance.instance.id: + self_instance.instance.__class__.objects.filter( + id=self_instance.instance.id + ).update(mime_type='image/webp') - return result + return result MultiStorageFieldFile.save = patched_save - logger.info("MultiStorageFieldFile.save() patched successfully.") - logger.info("Все остальное сделает filer - он распознает image/webp по mime_type и создаст Image запись!") - - # Регистрируем pre_save сигнал для изменения mime_type HEIC/HEIF файлов ДО их сохранения - # Это гарантирует что filer распознает их как Image, а не как File - from filer.models import File as FilerFile - - def fix_heic_mime_type_before_save(sender, instance, **kwargs): - """ - Перехватывает HEIC/HEIF файлы ДО сохранения и восстанавливает mime_type='image/webp'. - - Проблема: Браузер отправляет mime_type='image/heic' для HEIC файлов. - Filer видит расширение/имя и может неправильно определить тип. - Решение: Устанавливаем mime_type='image/webp' ДО сохранения. - Это гарантирует что filer распознает как Image, а не как File. - """ - if instance.file and hasattr(instance.file, 'name'): - file_name = instance.file.name.lower() if instance.file.name else "" - - # Если это WebP файл или конвертированный HEIC/HEIF, устанавливаем правильный mime_type - if file_name.endswith('.webp') or file_name.endswith('.heic') or file_name.endswith('.heif'): - old_mime = instance.mime_type - instance.mime_type = 'image/webp' - logger.info(f"[PRE-SAVE] Fixed mime_type for {file_name}: '{old_mime}' → 'image/webp'") - - pre_save.connect(fix_heic_mime_type_before_save, sender=FilerFile, dispatch_uid="fix_heic_mime_before_save") - logger.info("Pre-save signal handler for HEIC mime_type fixing registered.") diff --git a/lpon_site/frontend/models.py b/lpon_site/frontend/models.py index ac10a5d..260d9e6 100644 --- a/lpon_site/frontend/models.py +++ b/lpon_site/frontend/models.py @@ -1239,37 +1239,3 @@ class TbOfferHistory(models.Model): models.Index(fields=['k_history_to_offer', '-t_history_created'], name='idx_history_by_offer_date'), ] - -# ============================================================================ -# СИГНАЛЫ ДЛЯ ОБРАБОТКИ ЗАГРУЗОК И ОБРАБОТКИ ФАЙЛОВ -# ============================================================================ -import logging -from django.db.models.signals import post_save, pre_delete -from django.dispatch import receiver -from filer.models import File, Image - -# Получаем логгер -logger = logging.getLogger(__name__) - - -@receiver(post_save, sender=Image) -def log_image_save(sender, instance, created, **kwargs): - """ - Логирует сохранение изображения, включая информацию о формате и размере. - Помогает отследить, когда и как файл был загружен. - """ - action = "created" if created else "updated" - logger.info(f"[SIGNAL] Image {action}: {instance.name}, " - f"mime_type={instance.mime_type}, " - f"size={instance.size}, " - f"sha1={instance.sha1}") - - -@receiver(pre_delete, sender=Image) -def log_image_delete(sender, instance, **kwargs): - """ - Логирует удаление изображения для отладки. - """ - logger.info(f"[SIGNAL] Image deleted: {instance.name}, " - f"mime_type={instance.mime_type}, " - f"size={instance.size}") diff --git a/lpon_site/lpon_site/settings.py b/lpon_site/lpon_site/settings.py index bfaa960..0860d98 100644 --- a/lpon_site/lpon_site/settings.py +++ b/lpon_site/lpon_site/settings.py @@ -227,16 +227,16 @@ FILER_MAX_IMAGE_PIXELS = 4096 * 4096 FILER_ENABLE_PERMISSIONS = DEBUG FILER_WHITELIST_FOR_PATH_ACCESS = ( - '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', ".heic", ".heif", + '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.heic', '.heif', '.doc', '.docx', '.pdf', '.xls', '.xlsx', '.txt', '.csv', ) MIME_TYPE_WHITELIST = ( 'image/jpeg', # .jpg / .jpeg - 'image/png', + 'image/png', 'image/x-png', # .png 'image/gif', 'image/svg+xml', 'image/webp', - 'image/heic', 'image/heif', # форматы Apple HEIC/HEIF (без анимации + 'image/heic', 'image/heif', # форматы Apple HEIC/HEIF (без анимации 'image/heic-sequence и 'image/heif-sequence') 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', # .doc / .docx 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # .xls / .xlsx @@ -245,20 +245,11 @@ MIME_TYPE_WHITELIST = ( ) FILE_VALIDATORS = {} -# КРИТИЧЕСКИ ВАЖНЫЕ НАСТРОЙКИ ДЛЯ FILER - расширения и mime-типы для WebP и HEIC/HEIF -# Эти настройки переопределяют defaults в filer/settings.py -# IMAGE_EXTENSIONS используется filer для определения какие файлы считать изображениями -FILER_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.heic', '.heif'] - -# IMAGE_MIME_TYPES используется для проверки mime_type файла -# Это ЧАСТИ mime-типов которые ищутся внутри полного mime_type (например 'webp' в 'image/webp') -FILER_IMAGE_MIME_TYPES = ['gif', 'jpeg', 'png', 'x-png', 'svg+xml', 'webp', 'heic', 'heif'] - # Настройки для "умной" обрезки изображений THUMBNAIL_PROCESSORS = ( 'easy_thumbnails.processors.colorspace', 'easy_thumbnails.processors.autocrop', - #'easy_thumbnails.processors.scale_and_crop', + # 'easy_thumbnails.processors.scale_and_crop', 'filer.thumbnail_processors.scale_and_crop_with_subject_location', 'easy_thumbnails.processors.filters', )