From 62fe0722e571e6b425bbb3f5663d061c2604fc81 Mon Sep 17 00:00:00 2001 From: erjemin Date: Sun, 7 Jun 2026 20:36:54 +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(03)=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B2=20webp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lpon_site/frontend/apps.py | 231 +++++++++++++++++--------------- lpon_site/lpon_site/settings.py | 86 ++++++++---- 2 files changed, 183 insertions(+), 134 deletions(-) diff --git a/lpon_site/frontend/apps.py b/lpon_site/frontend/apps.py index 106bd5a..0261dce 100644 --- a/lpon_site/frontend/apps.py +++ b/lpon_site/frontend/apps.py @@ -1,154 +1,175 @@ import os +import hashlib from io import BytesIO from django.apps import AppConfig from django.core.files.storage import FileSystemStorage from django.core.files.base import ContentFile -from django.db.models.signals import pre_save from PIL import Image as PILImage +from lpon_site.settings import DEBUG, MEDIA_ROOT + + +# 0. Кастомная функция генерирования имен файлов специально для WebP +def generate_filename_webp(instance, filename): + """ + Генерирует имя файла для сохранения. Если исходный файл был конвертирован в WebP, + гарантирует что расширение файла будет .webp. + """ + _, ext = os.path.splitext(filename) + if ext.lower() in [".jpg", ".jpeg", ".png", ".bmp", ".tiff"]: + base_path = filename.rsplit(ext, 1)[0] + return f"{base_path}.webp" + return filename + + +# 0.1. Функция для генерирования путей с префиксом 'flr/' для основных файлов +def generate_upload_path_flr(instance, filename): + """ + Генерирует путь для сохранения файла с префиксом 'flr/'. + """ + from filer.utils.generate_filename import randomized + base_path = randomized(instance, filename) + return f'flr/{base_path}' + + +# 0.2. Функция для генерирования путей с префиксом 'flrm/' для миниатюр +def generate_upload_path_flrm(instance, filename): + """ + Генерирует путь для сохранения миниатюр с префиксом 'flrm/'. + """ + from filer.utils.generate_filename import randomized + base_path = randomized(instance, filename) + return f'flrm/{base_path}' -from lpon_site.settings import DEBUG # 1. class FrontendConfig(AppConfig): name = 'frontend' verbose_name = 'Сайт lpon.ru' - # Переключаем на стандартный AutoField (до 2 млрд записей) default_auto_field = 'django.db.models.AutoField' def ready(self): - """ - Вызывается при инициализации приложения. - Настраиваем админ-сайт (заголовки и т.п.) - """ from django.contrib import admin admin.site.site_header = 'Управление LPON' admin.site.site_title = 'LPON Administrator' admin.site.index_title = 'Добро пожаловать в LPON' - ## Если надо, импортируем сигналы при запуске приложения. -# 2. Создаем кастомное файловое хранилище специально для filer +# 2. class FilerWebPStorage(FileSystemStorage): - def _save(self, name, content): - filename, ext = os.path.splitext(name) - - # Обрабатываем только форматы картинок, исключая webp, svg и гифки - if ext.lower() in [".jpg", ".jpeg", ".png", ".bmp", ".tiff"]: - try: - # Читаем исходные байты файла - content.seek(0) - image_bytes = content.read() - - # Конвертируем в WebP через Pillow - img = PILImage.open(BytesIO(image_bytes)) - buffer = BytesIO() - img.save(buffer, format="WEBP", quality=85) - buffer.seek(0) - - # Подменяем имя и контент на честный WebP - name = f"{filename}.webp" - content = ContentFile(buffer.read(), name=os.path.basename(name)) - except Exception as e: - print(f"[Filer Storage WebP Error]: {e}") - - # Передаем управление стандартному сохранению на диск + """ + Кастомное хранилище для Filer. Основная логика конвертации перенесена + в патч для MultiStorageFieldFile.save, чтобы обновлять метаданные до сохранения. + """ + def _save(self, name: str, content): return super()._save(name, content) - -# Добавляем кастомный конфиг для filer, чтобы переименовать verbose_name и добавить конфиги +# Добавляем кастомный конфиг для filer class CustomFilerConfig(AppConfig): name = 'filer' - verbose_name = 'Медиафайлы' # Переименование вкладки в админке + verbose_name = 'Медиафайлы' - # ======================================================================== - # Конфигурация Django-Filer (для загрузки файлов через админку) - # ======================================================================== - FILER_ENABLE_PERMISSIONS = DEBUG # В production установить True для ограничения доступа - - # Разрешенные расширения файлов для FilerImageField + # Конфигурация Django-Filer + FILER_ENABLE_PERMISSIONS = DEBUG FILER_WHITELIST_FOR_PATH_ACCESS = ( - # ПОДУМАТЬ: поддержка '.heic' требует дополнительных пакетов (и разных для прода и дева) + обработчик сигналов - # для автоматической конвертации .heic в .webp при загрузке. Пока отключаем, и оставим на будущее - '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', # Изображения - '.doc', '.docx', '.pdf', '.txt', '.xls', '.xlsx', '.csv', # Документы + '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', + '.doc', '.docx', '.pdf', '.txt', '.xls', '.xlsx', '.csv', ) - - # Максимальный размер загружаемого файла (в байтах): 100 MB FILER_MAX_UPLOAD_SIZE = 100 * 1024 * 1024 - - # MIME-типы разрешенные для загрузки MIME_TYPE_WHITELIST = ( - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/svg+xml', - 'image/webp', - 'application/pdf', - 'application/msword', # .doc - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', # .docx - 'text/plain', - 'application/vnd.ms-excel', # .xls - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # .xlsx + 'image/jpeg', 'image/png', 'image/gif', 'image/svg+xml', 'image/webp', + 'application/pdf', 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/plain', 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/csv', ) - - # Tiny Image Specifications для автоматических вариантов изображений THUMBNAIL_ALIASES = { '': { - # Для галерей и витрин - # Кратные 16х16 картинки для ускорения GPU-рендера браузеров, retina-дисплеев и "копеечная" экономии на сервере - 'admin_thumbnail': {'size': (64, 64), 'crop': True}, # Для админки - 'small': {'size': (256, 256), 'crop': True}, # Квадрат 256x256 для миниатюр - 'medium': {'size': (512, 512), 'crop': True}, # Квадрат 512x512 для просмотра - 'large': {'size': (1024, 1024), 'crop': 'smart'}, # Большое изображение с умным crop + 'admin_thumbnail': {'size': (64, 64), 'crop': True}, + 'small': {'size': (256, 256), 'crop': True}, + 'medium': {'size': (512, 512), 'crop': True}, + 'large': {'size': (1024, 1024), 'crop': 'smart'}, }, } - - # Форматы сохранения для thumbnail'ов (миниатюр) - THUMBNAIL_PRESERVE_FORMAT = False # Не сохранять оригинальный формат для thumbnails - # THUMBNAIL_FORMAT = 'WEBP' # Конвертировать все thumbnails в WebP + THUMBNAIL_PRESERVE_FORMAT = False THUMBNAIL_FORCE_FORMAT = 'WEBP' - THUMBNAIL_WEBP_QUALITY = 80 # Качество WebP (достаточно 75-85 для thumbnails) - - # Интерпретатор для обработки изображений + THUMBNAIL_WEBP_QUALITY = 80 THUMBNAIL_ENGINE = 'easy_thumbnails.engines.pil_engine.PilEngine' - - # Качество JPEG при сжатии (0-100, по умолчанию 85) - # THUMBNAIL_QUALITY = 85 - - # Источник кеша для миниатюр - THUMBNAIL_DEBUG = DEBUG # Показывать ошибки генерирования миниатюр в debug режиме - - # Валидаторы файлов (пусто = без ограничений) + THUMBNAIL_DEBUG = DEBUG FILE_VALIDATORS = {} def ready(self): - from django.db.models.signals import pre_save - from filer import settings as filer_settings - from filer.models.imagemodels import Image as FilerImage + import sys + from filer.fields.multistorage_file import MultiStorageFieldFile - # ХАК 1: Принудительно заставляем filer использовать наше кастомное хранилище. - # Теперь все оригиналы файлов будут сохраняться через класс FilerWebPStorage. - filer_settings.FILER_STORAGES["public"]["main"] = { - "ENGINE": "frontend.apps.FilerWebPStorage", - "OPTIONS": {}, - } + print("[CustomFilerConfig.ready] Patching MultiStorageFieldFile.save()...", file=sys.stderr) + original_save = MultiStorageFieldFile.save + webp_converter = WebPConverter() - # ХАК 2: Подключаем легкий сигнал pre_save только для того, чтобы - # прописать в базу данных правильный mime_type и расширение для админки. - pre_save.connect(self.fix_meta_before_db_save, sender=FilerImage) + def patched_save(self, name, content, save=True): + """ + Переопределенный save(), который преобразует изображение в WebP, обновляет + метаданные модели (MIME-тип, размер, SHA1) и затем сохраняет. + """ + new_content, new_name, converted = webp_converter.convert_to_webp_if_needed(name, content) - @staticmethod - def fix_meta_before_db_save(sender, instance, **kwargs): - if not instance.file or not instance.file.name: - return + if converted: + # Обновляем метаданные прямо в инстансе модели + self.instance.mime_type = "image/webp" + + if hasattr(self.instance, 'original_filename') and self.instance.original_filename: + original_filename, _ = os.path.splitext(self.instance.original_filename) + self.instance.original_filename = f"{original_filename}.webp" - filename, ext = os.path.splitext(instance.file.name) - if ext.lower() in [".webp", ".svg", ".gif"]: - return + # Вручную пересчитываем размер и SHA1-хэш для нового файла .webp + new_content.seek(0) + file_bytes = new_content.read() + new_content.seek(0) + + self.instance._file_size = len(file_bytes) + self.instance.sha1 = hashlib.sha1(file_bytes).hexdigest() - # Меняем метаданные для базы данных, так как на диске файл СТОПРОЦЕНТНО будет .webp - instance.file.name = f"{filename}.webp" - instance.mime_type = "image/webp" + # Вызываем оригинальный метод save() с (возможно) новым контентом и именем + return original_save(self, new_name, new_content, save) + + MultiStorageFieldFile.save = patched_save + print("[CustomFilerConfig.ready] MultiStorageFieldFile.save() patched successfully", file=sys.stderr) + + +class WebPConverter: + """ + Класс, инкапсулирующий логику преобразования изображений в WebP. + """ + def convert_to_webp_if_needed(self, name: str, content): + """ + Проверяет формат файла и, если это необходимо, преобразует его в WebP. + Возвращает кортеж (новое_содержимое, новое_имя, флаг_конвертации). + """ + _, original_ext = os.path.splitext(name) + + if original_ext.lower() in [".jpg", ".jpeg", ".png", ".bmp", ".tiff"]: + try: + content.seek(0) + img = PILImage.open(BytesIO(content.read())) + + if img.mode == 'CMYK': + img = img.convert('RGB') + + buffer = BytesIO() + img.save(buffer, format="WEBP", quality=80) + buffer.seek(0) + + new_name = name.rsplit(original_ext, 1)[0] + ".webp" + + return ContentFile(buffer.read()), new_name, True + + except Exception as e: + import sys + print(f"[WebPConverter] ERROR converting {name}: {e}", file=sys.stderr) + content.seek(0) + return content, name, False + + content.seek(0) + return content, name, False diff --git a/lpon_site/lpon_site/settings.py b/lpon_site/lpon_site/settings.py index 7ffbc45..51c5564 100644 --- a/lpon_site/lpon_site/settings.py +++ b/lpon_site/lpon_site/settings.py @@ -160,36 +160,64 @@ MEDIA_ROOT = PUBLIC_DIR.joinpath('media') STATICFILES_DIRS = [PUBLIC_DIR.joinpath('static')] STATIC_ROOT = PUBLIC_DIR.joinpath('staticfiles') +# ============================================================================ +# Django-Filer Configuration - WebP Conversion Storage +# ============================================================================ +# Настройка хранилища filer для автоматического преобразования изображений в WebP +# ВАЖНО: эта конфигурация должна быть в settings.py (не в class attribute), +# т.к. Django-filer загружает её ДО инициализации app configs +# ВАЖНО: location должна быть равна MEDIA_ROOT, иначе Django не сможет +# сгенерировать правильные URL для файлов (URL генерируется как MEDIA_URL + relative_path) +# +# Структура папок: +# public/media/ +# ├── flr/ <- основные загруженные файлы (картинки) +# ├── flrm/ <- миниатюры (thumbnails) +# ├── filer_public/ <- старая структура (больше не используется) +# └── filer_public_thumbnails/ <- старая структура (больше не используется) +FILER_STORAGES = { + 'public': { + 'main': { + # Используем кастомное хранилище FilerWebPStorage для преобразования в WebP + 'ENGINE': 'frontend.apps.FilerWebPStorage', + 'OPTIONS': { + # location должна быть MEDIA_ROOT для корректной генерации URL! + 'location': str(MEDIA_ROOT), + }, + # UPLOAD_TO функция добавляет 'flr/' префикс для более компактных путей в шаблонах + 'UPLOAD_TO': 'frontend.apps.generate_upload_path_flr', + 'UPLOAD_TO_PREFIX': '', + }, + 'thumbnails': { + 'ENGINE': 'django.core.files.storage.FileSystemStorage', + 'OPTIONS': { + 'location': str(MEDIA_ROOT), + }, + # Миниатюры идят в папку flrm через UPLOAD_TO функцию + 'UPLOAD_TO': 'frontend.apps.generate_upload_path_flrm', + 'THUMBNAIL_OPTIONS': { + 'base_dir': 'flrm', + }, + }, + }, +} # ============================================================================ -# Easy Thumbnails Configuration (для создания миниатюр в WebP) +# Easy-Thumbnails Configuration - WebP Generation # ============================================================================ +# Настройка генерирования миниатюр в формате WebP вместо JPEG/PNG +THUMBNAIL_PRESERVE_FORMAT = False # Не сохранять оригинальный формат для миниатюр +THUMBNAIL_FORMAT = 'WEBP' # Конвертировать все миниатюры в WebP +THUMBNAIL_QUALITY = 80 # Качество WebP (достаточно 75-85 для миниатюр) -# Не сохранять оригинальный формат — все миниатюры в WebP -# THUMBNAIL_PRESERVE_FORMAT = False -# -# # Все миниатюры конвертировать в WebP -# THUMBNAIL_FORMAT = 'WEBP' -# THUMBNAIL_PRESERVE_FORMAT = False - -# -# # Качество WebP (75-85 достаточно для миниатюр) -# THUMBNAIL_QUALITY = 80 -# -# # Интерпретатор для обработки изображений (Pillow) -# THUMBNAIL_ENGINE = 'easy_thumbnails.engines.pil_engine.PilEngine' -# -# # Показывать ошибки при создании миниатюр в debug режиме -# THUMBNAIL_DEBUG = DEBUG -# -# # Размеры для миниатюр (для фронтенд-галереи) -# THUMBNAIL_ALIASES = { -# '': { -# 'admin_thumbnail': {'size': (64, 64), 'crop': True}, -# 'small': {'size': (256, 256), 'crop': True}, -# 'medium': {'size': (512, 512), 'crop': True}, -# 'large': {'size': (1024, 1024), 'crop': 'smart'}, -# }, -# } - - +# Размеры миниатюр для разных использований +THUMBNAIL_ALIASES = { + '': { + # Для админ-интерфейса + 'admin_thumbnail': {'size': (64, 64), 'crop': True}, + # Для фронтенда + 'small': {'size': (256, 256), 'crop': True}, + 'medium': {'size': (512, 512), 'crop': True}, + 'large': {'size': (1024, 1024), 'crop': 'smart'}, + }, +}