mod: django-filer настройка (12) поддержка heif/heic (работает с логами)

This commit is contained in:
2026-06-09 18:54:54 +03:00
parent 6d6bb873e9
commit a5425b212d
3 changed files with 265 additions and 32 deletions

View File

@@ -1,9 +1,9 @@
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 PIL import Image as PILImage from PIL import Image as PILImage
import pillow_heif import pillow_heif
@@ -22,6 +22,10 @@ pillow_heif.register_heif_opener()
# Получаем логгер для текущего модуля # Получаем логгер для текущего модуля
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Кэш для размеров изображений больше не нужен!
# Filer сам распознает WebP по mime_type и создаст Image запись
# PIL автоматически получит размеры из WebP файла
# ============================================================================== # ==============================================================================
# Конфигурация приложения для фронтенда lpon # Конфигурация приложения для фронтенда lpon
@@ -60,53 +64,178 @@ class CustomFilerConfig(AppConfig):
""" """
Преобразует загруженное изображение в WebP формат. Преобразует загруженное изображение в WebP формат.
Поддерживает JPEG, PNG, BMP, TIFF и HEIC/HEIF. Поддерживает JPEG, PNG, BMP, TIFF и HEIC/HEIF.
Возвращает кортеж: (content, new_name, was_converted, image_width, image_height)
КРИТИЧНО: Функция ДОЛЖНА получать размеры из УЖЕ ПРЕОБРАЗОВАННОГО WebP файла, а не из исходника!
Почему:
- PIL может открыть HEIC (благодаря pillow_heif) но размеры исходного могут отличаться
- После конвертации HEIC→WebP размеры могут измениться (масштабирование, кроппинг)
- Filer использует размеры для определения каких миниатюр генерировать
- Размеры ДОЛЖНЫ совпадать с реальным WebP файлом в хранилище!
""" """
_, original_ext = os.path.splitext(name) _, original_ext = os.path.splitext(name)
if original_ext.lower() in [".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".heic", ".heif"]: image_width = None
image_height = None
# Список расширений, которые конвертируем в WebP
convertible_extensions = [".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".heic", ".heif"]
if original_ext.lower() in convertible_extensions:
try: try:
# Шаг 1: Открываем исходный файл с помощью PIL (поддерживает HEIC благодаря pillow_heif)
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)
if img.mode == 'CMYK': if img.mode == 'CMYK':
img = img.convert('RGB') img = img.convert('RGB')
buffer = BytesIO() logger.debug("Конвертировано CMYK → RGB")
img.save(buffer, format="WEBP", quality=THUMBNAIL_WEBP_QUALITY)
buffer.seek(0) # Шаг 3: Сохраняем исходное изображение в WebP формат в памяти (BytesIO)
webp_buffer = BytesIO()
img.save(webp_buffer, format="WEBP", quality=THUMBNAIL_WEBP_QUALITY)
webp_buffer.seek(0)
logger.debug(f"Изображение конвертировано в WebP (качество={THUMBNAIL_WEBP_QUALITY})")
# Шаг 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
new_name = os.path.splitext(name)[0] + ".webp" new_name = os.path.splitext(name)[0] + ".webp"
logger.info(f"Successfully converted '{name}' to '{new_name}' (WebP).") logger.info(f"Конвертировано '{name}' '{new_name}' (WebP). "
return ContentFile(buffer.read()), new_name, True f"Размеры: {image_width}x{image_height}px")
# Возвращаем WebP контент и размеры из уже преобразованного файла
return ContentFile(webp_buffer.read()), new_name, True, image_width, image_height
except Exception: except Exception:
logger.error(f"Error converting '{name}' to WebP.", exc_info=True) logger.error(f"Ошибка при конвертации '{name}' в WebP.", exc_info=True)
# При ошибке возвращаем исходный файл без размеров
content.seek(0) content.seek(0)
return content, name, False return content, name, False, None, None
# Если файл не нуждается в конвертации, возвращаем как есть
content.seek(0) content.seek(0)
return content, name, False return content, name, False, None, None
def ready(self): 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 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 from filer.fields.multistorage_file import MultiStorageFieldFile
logger.info("Patching MultiStorageFieldFile.save() for WebP conversion...") logger.info("Patching MultiStorageFieldFile.save() for WebP conversion...")
original_save = MultiStorageFieldFile.save original_save = MultiStorageFieldFile.save
def patched_save(self_instance, name, content, save=True): def patched_save(self_instance, name, content, save=True):
# Преобразуем загруженный файл в WebP если требуется """
new_content, new_name, converted = CustomFilerConfig._convert_to_webp_if_needed(name, content) Патчированная версия MultiStorageFieldFile.save() для конвертации HEIC/HEIF в WebP.
if converted:
# Обновляем свойства файла для WebP версии
self_instance.instance.mime_type = "image/webp"
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"
# Сохраняем размер и контрольную сумму WebP файла
new_content.seek(0)
file_bytes = new_content.read()
new_content.seek(0)
self_instance.instance._file_size = len(file_bytes)
self_instance.instance.sha1 = hashlib.sha1(file_bytes).hexdigest()
return original_save(self_instance, new_name, new_content, save) Процесс:
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)
# Шаг 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') 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"
# Шаг 3: Вызываем оригинальный save() метод с new_name и new_content
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}")
return result
MultiStorageFieldFile.save = patched_save MultiStorageFieldFile.save = patched_save
logger.info("MultiStorageFieldFile.save() patched successfully.") 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

@@ -410,7 +410,7 @@ class TbMusicStyle(models.Model):
# ============================================================================ # ============================================================================
# СТАТЬИ (любая текстовая информация о релизе, исполнителе, продавце и т.д... # СТАТЬИ (любая текстовая информация о релизе, исполнителе, продавце и т.д...)
# а так же новости, блог, тексты о спец-предложениях и т.д.) # а так же новости, блог, тексты о спец-предложениях и т.д.)
# ============================================================================ # ============================================================================
class TbArticle(models.Model): class TbArticle(models.Model):
@@ -767,7 +767,6 @@ class TbLabel(models.Model):
# ============================================================================ # ============================================================================
# ПРОДАВЦЫ / МАГАЗИНЫ # ПРОДАВЦЫ / МАГАЗИНЫ
# ============================================================================ # ============================================================================
class TbSeller(models.Model): class TbSeller(models.Model):
"""Продавец или магазин, который продаёт товары.""" """Продавец или магазин, который продаёт товары."""
class SellerType(models.TextChoices): class SellerType(models.TextChoices):
@@ -1240,3 +1239,37 @@ 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

@@ -191,11 +191,27 @@ FILER_STORAGES = {
} }
# Настройки easy_thumbnails для генерации миниатюр
# Преимущественно используется для django-filer
THUMBNAIL_PRESERVE_FORMAT = False THUMBNAIL_PRESERVE_FORMAT = False
THUMBNAIL_FORMAT = 'WEBP' THUMBNAIL_FORMAT = 'WEBP'
THUMBNAIL_DEBUG = DEBUG THUMBNAIL_DEBUG = DEBUG
THUMBNAIL_WEBP_QUALITY = 80 THUMBNAIL_QUALITY= 80
THUMBNAIL_WEBP_QUALITY = THUMBNAIL_QUALITY
THUMBNAIL_ENGINE = 'easy_thumbnails.engines.pil_engine.PilEngine' THUMBNAIL_ENGINE = 'easy_thumbnails.engines.pil_engine.PilEngine'
# Список расширений, которые нужно сохранять при генерации миниатюр
# Это важно для WebP файлов - они не должны быть сконвертированы в другой формат
THUMBNAIL_PRESERVE_EXTENSIONS = ['png', 'gif', 'webp']
# Нейминг миниатюр - стандартный нейминг easy_thumbnails
# Формат: {source_name}{dimensions}{options}{quality}{extra}
# Например: image.webp__40x40_q85_crop_subsampling-2.jpg
THUMBNAIL_NAMER = 'easy_thumbnails.namers.default'
# Показывать ошибки при генерации миниатюр (полезно для отладки)
THUMBNAIL_VERBOSE = DEBUG
THUMBNAIL_ALIASES = { THUMBNAIL_ALIASES = {
'': { '': {
# Примечание: filer автоматически генерирует свои миниатюры для админки (40x40, 210x210 и 420x420) # Примечание: filer автоматически генерирует свои миниатюры для админки (40x40, 210x210 и 420x420)
@@ -212,7 +228,7 @@ 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', '.txt', '.xls', '.xlsx', '.csv', '.doc', '.docx', '.pdf', '.xls', '.xlsx', '.txt', '.csv',
) )
MIME_TYPE_WHITELIST = ( MIME_TYPE_WHITELIST = (
'image/jpeg', # .jpg / .jpeg 'image/jpeg', # .jpg / .jpeg
@@ -220,7 +236,7 @@ MIME_TYPE_WHITELIST = (
'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 (без анимации
'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
@@ -229,11 +245,66 @@ 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',
'easy_thumbnails.processors.autocrop', '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', 'filer.thumbnail_processors.scale_and_crop_with_subject_location',
'easy_thumbnails.processors.filters', 'easy_thumbnails.processors.filters',
) )
# Конфигурация логирования для отладки загрузок и обработки файлов
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '[{levelname}] {asctime} {name}:{funcName}() line {lineno} - {message}',
'style': '{',
'datefmt': '%Y-%m-%d %H:%M:%S',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
'file': {
'class': 'logging.FileHandler',
'filename': os.path.join(BASE_DIR.parent, 'logs', 'uploads.log'),
'formatter': 'verbose',
},
},
'loggers': {
'frontend.apps': {
'level': 'DEBUG',
'handlers': ['console', 'file'],
'propagate': False,
},
'frontend.models': {
'level': 'DEBUG',
'handlers': ['console', 'file'],
'propagate': False,
},
'easy_thumbnails': {
'level': 'DEBUG',
'handlers': ['console', 'file'],
'propagate': False,
},
'filer': {
'level': 'DEBUG',
'handlers': ['console', 'file'],
'propagate': False,
},
},
}