mod: django-filer настройка (13) поддержка heif/heic (fine)

This commit is contained in:
2026-06-09 20:30:33 +03:00
parent a5425b212d
commit 71cac55221
3 changed files with 66 additions and 189 deletions

View File

@@ -1,9 +1,11 @@
import os import os
import hashlib
import logging import logging
from io import BytesIO from io import BytesIO
from django.apps import AppConfig from django.apps import AppConfig
from django.core.files.base import ContentFile 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 from PIL import Image as PILImage
import pillow_heif import pillow_heif
@@ -16,16 +18,11 @@ from lpon_site.settings import (
FILE_VALIDATORS, FILE_VALIDATORS,
) )
# Регистрируем плагин HEIF в Pillow # Регистрируем плагин HEIF в Pillow для поддержки HEIC/HEIF файлов
pillow_heif.register_heif_opener() pillow_heif.register_heif_opener()
# Получаем логгер для текущего модуля
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Кэш для размеров изображений больше не нужен!
# Filer сам распознает WebP по mime_type и создаст Image запись
# PIL автоматически получит размеры из WebP файла
# ============================================================================== # ==============================================================================
# Конфигурация приложения для фронтенда lpon # Конфигурация приложения для фронтенда lpon
@@ -41,24 +38,22 @@ class FrontendConfig(AppConfig):
admin.site.site_title = 'LPON Administrator' admin.site.site_title = 'LPON Administrator'
admin.site.index_title = 'Добро пожаловать в LPON' admin.site.index_title = 'Добро пожаловать в LPON'
# ============================================================================== # ==============================================================================
# Кастомная конфигурация для django-filer # Кастомная конфигурация для django-filer
# - Патчинг MultiStorageFieldFile.save() для автоматической конвертации в WebP # Патчинг MultiStorageFieldFile.save() для автоматической конвертации в WebP
# ============================================================================== # ==============================================================================
class CustomFilerConfig(AppConfig): class CustomFilerConfig(AppConfig):
name = 'filer' name = 'filer'
verbose_name = 'Медиафайлы' verbose_name = 'Медиафайлы'
# ========================================================================
# Атрибуты конфигурации Django-Filer (импортированы из settings.py) # Атрибуты конфигурации Django-Filer (импортированы из settings.py)
# ========================================================================
FILER_ENABLE_PERMISSIONS = FILER_ENABLE_PERMISSIONS FILER_ENABLE_PERMISSIONS = FILER_ENABLE_PERMISSIONS
FILER_UPLOADER_MAX_FILE_SIZE = FILER_UPLOADER_MAX_FILE_SIZE FILER_UPLOADER_MAX_FILE_SIZE = FILER_UPLOADER_MAX_FILE_SIZE
FILER_WHITELIST_FOR_PATH_ACCESS = FILER_WHITELIST_FOR_PATH_ACCESS FILER_WHITELIST_FOR_PATH_ACCESS = FILER_WHITELIST_FOR_PATH_ACCESS
MIME_TYPE_WHITELIST = MIME_TYPE_WHITELIST MIME_TYPE_WHITELIST = MIME_TYPE_WHITELIST
FILE_VALIDATORS = FILE_VALIDATORS FILE_VALIDATORS = FILE_VALIDATORS
@staticmethod @staticmethod
def _convert_to_webp_if_needed(name: str, content): 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) Возвращает кортеж: (content, new_name, was_converted, image_width, image_height)
КРИТИЧНО: Функция ДОЛЖНА получать размеры из УЖЕ ПРЕОБРАЗОВАННОГО WebP файла, а не из исходника! Размеры ОБЯЗАТЕЛЬНО получаются из исходного изображения перед конвертацией.
Почему: При конвертации в WebP размеры пикселей не меняются, меняется только качество файла.
- PIL может открыть HEIC (благодаря pillow_heif) но размеры исходного могут отличаться
- После конвертации HEIC→WebP размеры могут измениться (масштабирование, кроппинг)
- Filer использует размеры для определения каких миниатюр генерировать
- Размеры ДОЛЖНЫ совпадать с реальным WebP файлом в хранилище!
""" """
_, original_ext = os.path.splitext(name) _, original_ext = os.path.splitext(name)
image_width = None
image_height = None
# Список расширений, которые конвертируем в WebP
convertible_extensions = [".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".heic", ".heif"] convertible_extensions = [".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".heic", ".heif"]
if original_ext.lower() in convertible_extensions: if original_ext.lower() in convertible_extensions:
try: try:
# Шаг 1: Открываем исходный файл с помощью PIL (поддерживает HEIC благодаря pillow_heif) # Открываем исходный файл с помощью PIL
content.seek(0) content.seek(0)
img = PILImage.open(BytesIO(content.read())) 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': if img.mode == 'CMYK':
img = img.convert('RGB') img = img.convert('RGB')
logger.debug("Конвертировано CMYK → RGB")
# Шаг 3: Сохраняем исходное изображение в WebP формат в памяти (BytesIO) # Сохраняем в WebP формат в памяти
webp_buffer = BytesIO() webp_buffer = BytesIO()
img.save(webp_buffer, format="WEBP", quality=THUMBNAIL_WEBP_QUALITY) img.save(webp_buffer, format="WEBP", quality=THUMBNAIL_WEBP_QUALITY)
webp_buffer.seek(0) webp_data = webp_buffer.getvalue()
logger.debug(f"Изображение конвертировано в WebP (качество={THUMBNAIL_WEBP_QUALITY})")
# Шаг 4: КРИТИЧНО - Переоткрываем WebP из памяти и получаем его РЕАЛЬНЫЕ размеры # Изменяем расширение на .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
new_name = os.path.splitext(name)[0] + ".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_data), new_name, True, image_width, image_height
return ContentFile(webp_buffer.read()), new_name, True, image_width, image_height
except Exception: except Exception as e:
logger.error(f"Ошибка при конвертации '{name}' в WebP.", exc_info=True) # Поднимаем ValidationError чтобы она показывалась в админке
# При ошибке возвращаем исходный файл без размеров error_msg = f"Ошибка при конвертации '{name}' в WebP: {str(e)}"
content.seek(0) logger.error(error_msg, exc_info=True)
return content, name, False, None, None raise ValidationError(error_msg)
# Если файл не нуждается в конвертации, возвращаем как есть # Если файл не нуждается в конвертации, возвращаем как есть
content.seek(0) content.seek(0)
@@ -129,113 +104,58 @@ class CustomFilerConfig(AppConfig):
def ready(self): def ready(self):
""" """
Готовимся к работе: патчим MultiStorageFieldFile.save() для WebP конвертации. Инициализация: патчинг MultiStorageFieldFile.save() для WebP конвертации.
""" """
# КРИТИЧЕСКИ ВАЖНО: Переопределяем IMAGE_EXTENSIONS и IMAGE_MIME_TYPES в filer from filer.fields.multistorage_file import MultiStorageFieldFile
# Это гарантирует что filer распознает WebP файлы как изображения, а не как обычные файлы
#
# Почему это нужно делать здесь в apps.py:
# - IMAGE_EXTENSIONS и IMAGE_MIME_TYPES в filer это ГЛОБАЛЬНЫЕ КОНСТАНТЫ (не getattr настройки)
# - Они импортируются как: from filer.settings import IMAGE_EXTENSIONS
# - Нет встроенного механизма для переопределения через settings.py
# - Единственный способ - модифицировать их после импорта, в ready() методе AppConfig
#
# Это стандартная практика для переопределения внутренних настроек третьих пакетов в Django
from filer import settings as filer_settings 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 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): def patched_save(self_instance, name, content, save=True):
""" """
Патчированная версия MultiStorageFieldFile.save() для конвертации HEIC/HEIF в WebP. Патчированная версия MultiStorageFieldFile.save().
Конвертирует изображения в WebP и передает размеры для генерации миниатюр.
Процесс:
1. Конвертирует загруженное изображение в WebP (JPEG, PNG, BMP, TIFF, HEIC, HEIF)
2. Устанавливает правильный mime_type=image/webp
3. Вызывает оригинальный save() с преобразованным контентом
4. Filer автоматически распознает WebP как Image, читает размеры и создает Image запись
""" """
# Шаг 1: Преобразуем загруженный файл в WebP если требуется new_content, new_name, converted, img_width, img_height = (
# Метод вернет размеры ИЗ ПРЕОБРАЗОВАННОГО WebP (не исходного файла) CustomFilerConfig._convert_to_webp_if_needed(name, content)
new_content, new_name, converted, img_width, img_height = CustomFilerConfig._convert_to_webp_if_needed(name, content) )
# Шаг 2: Если файл был конвертирован в WebP, обновляем метаданные
if converted: if converted:
# Важно: Устанавливаем mime_type ПЕРЕД сохранением # Устанавливаем correct mime_type для WebP
# Filer использует mime_type чтобы определить тип файла (Image vs File)
self_instance.instance.mime_type = "image/webp" 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 если есть # Обновляем original_filename если есть
if hasattr(self_instance.instance, 'original_filename') and self_instance.instance.original_filename: if hasattr(self_instance.instance, 'original_filename'):
original_filename, _ = os.path.splitext(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" self_instance.instance.original_filename = f"{original_filename}.webp"
# Шаг 3: Вызываем оригинальный save() метод с new_name и new_content # КРИТИЧНО: Обновляем размер файла и 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()
# Вызываем оригинальный save() метод
result = original_save(self_instance, new_name, new_content, save) result = original_save(self_instance, new_name, new_content, save)
# Шаг 4: После original_save() гарантируем что mime_type=image/webp сохранен в БД # После сохранения гарантируем что mime_type=image/webp в БД
# (на случай если решение о типе файла уже было принято)
if converted and new_name.lower().endswith('.webp'): if converted and new_name.lower().endswith('.webp'):
# Если instance имеет id после save, обновляем mime_type в БД
if self_instance.instance.id: if self_instance.instance.id:
# Обновляем только mime_type self_instance.instance.__class__.objects.filter(
self_instance.instance.__class__.objects.filter(id=self_instance.instance.id).update( id=self_instance.instance.id
mime_type='image/webp' ).update(mime_type='image/webp')
)
logger.info(f"[WebP] Updated mime_type in DB for id={self_instance.instance.id}")
return result return result
MultiStorageFieldFile.save = patched_save 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.")

View File

@@ -1239,37 +1239,3 @@ class TbOfferHistory(models.Model):
models.Index(fields=['k_history_to_offer', '-t_history_created'], name='idx_history_by_offer_date'), 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}")

View File

@@ -227,16 +227,16 @@ FILER_MAX_IMAGE_PIXELS = 4096 * 4096
FILER_ENABLE_PERMISSIONS = DEBUG FILER_ENABLE_PERMISSIONS = DEBUG
FILER_WHITELIST_FOR_PATH_ACCESS = ( 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', '.doc', '.docx', '.pdf', '.xls', '.xlsx', '.txt', '.csv',
) )
MIME_TYPE_WHITELIST = ( MIME_TYPE_WHITELIST = (
'image/jpeg', # .jpg / .jpeg 'image/jpeg', # .jpg / .jpeg
'image/png', 'image/png', 'image/x-png', # .png
'image/gif', 'image/gif',
'image/svg+xml', 'image/svg+xml',
'image/webp', '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/pdf',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', # .doc / .docx 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', # .doc / .docx
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # .xls / .xlsx 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # .xls / .xlsx
@@ -245,15 +245,6 @@ MIME_TYPE_WHITELIST = (
) )
FILE_VALIDATORS = {} 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 = ( THUMBNAIL_PROCESSORS = (
'easy_thumbnails.processors.colorspace', 'easy_thumbnails.processors.colorspace',