codeisartАвторский веб-архив · 2008–2011

Python

Python, Django: Автоматический ресайз загружаемых изображений

В этой статье

Задача: принять аватар, проверить содержимое файла и уменьшить изображение так, чтобы оно помещалось в заданный прямоугольник без изменения пропорций. Имя, расширение и присланный Content-Type не подтверждают формат: изображение должен декодировать Pillow.

Размеры, ориентация и формат

Ограничиваем как размер загрузки в байтах, так и число пикселей. JPEG, PNG и WebP разрешены явно; анимацию здесь не принимаем. После проверки файла открываем его заново, учитываем EXIF-ориентацию и уменьшаем через ImageOps.contain с Resampling.LANCZOS. Маленькие изображения не увеличиваем. Политика результата в этом примере — JPEG на белом фоне; это осознанно убирает прозрачность.

Нельзя сравнивать пары размеров оператором <: кортежи сравниваются лексикографически, поэтому узкая высокая картинка может ошибочно остаться слишком высокой. Проверяем ширину и высоту отдельно. Pillow возвращает целые размеры, подходящие для записи.

Подготовка файла и замена аватара

Ниже самостоятельные функции для современного Django. prepare_avatar принимает UploadedFile, возвращает ContentFile и не меняет исходный файл на диске. replace_avatar вызывается после проверки прав на конкретную запись модели; модель должна иметь поле ImageField с именем avatar. Вызов находится на внешней границе транзакции: durable=True намеренно запрещает прятать этот жизненный цикл внутрь другой atomic-транзакции.

from io import BytesIO
from uuid import uuid4
import warnings

from PIL import Image, ImageOps, UnidentifiedImageError
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import transaction


def prepare_avatar(uploaded, bounds=(200, 500)):
    """Return a new JPEG ContentFile; never enlarge the supplied image."""
    if len(bounds) != 2 or any(type(n) is not int or n < 1 for n in bounds):
        raise ValueError('Expected two positive integer dimensions')
    if uploaded.size > 10 * 1024 * 1024:
        raise ValidationError('Размер файла превышает 10 МБ')
    try:
        with warnings.catch_warnings():
            warnings.simplefilter('error', Image.DecompressionBombWarning)
            uploaded.seek(0)
            with Image.open(uploaded) as probe:
                if probe.format not in {'JPEG', 'PNG', 'WEBP'}:
                    raise ValidationError('Разрешены JPEG, PNG и WebP')
                if probe.width * probe.height > 20_000_000:
                    raise ValidationError('Изображение слишком большое')
                if getattr(probe, 'n_frames', 1) != 1:
                    raise ValidationError('Выберите статичное изображение')
                probe.verify()
            uploaded.seek(0)
            with Image.open(uploaded) as original:
                original.load()
                image = ImageOps.exif_transpose(original).convert('RGBA')
                if image.width > bounds[0] or image.height > bounds[1]:
                    image = ImageOps.contain(image, bounds, Image.Resampling.LANCZOS)
                # This site's explicit output policy: JPEG on a white background.
                result = Image.new('RGB', image.size, 'white')
                result.paste(image, mask=image.getchannel('A'))
                output = BytesIO()
                result.save(output, format='JPEG', quality=85)
                return ContentFile(output.getvalue(), name=f'{uuid4().hex}.jpg')
    except (UnidentifiedImageError, OSError, Image.DecompressionBombError,
            Image.DecompressionBombWarning) as error:
        raise ValidationError('Файл не удалось прочитать как изображение') from error


def replace_avatar(avatar_record, uploaded):
    """Application service; caller must authorize this record beforehand.

    AvatarRecord is the application's actual model, with ImageField `avatar`.
    Invoke at the outer transaction boundary, not inside another atomic block.
    """
    model = type(avatar_record)
    prepared = prepare_avatar(uploaded)
    storage = avatar_record.avatar.storage
    new_name = storage.save(f'avatars/{prepared.name}', prepared)
    try:
        with transaction.atomic(durable=True):
            locked = model.objects.select_for_update().get(pk=avatar_record.pk)
            old_name = locked.avatar.name
            locked.avatar.name = new_name
            locked.save(update_fields=['avatar'])

            def delete_old():
                # Do not delete a file still referenced by another record.
                if old_name and old_name != new_name and not model.objects.filter(avatar=old_name).exists():
                    storage.delete(old_name)

            transaction.on_commit(delete_old, robust=True)
        return new_name
    except Exception:
        # Storage and database are not one transaction: clean a failed new file.
        storage.delete(new_name)
        raise

Почему Storage, а не MEDIA_ROOT

Файл может находиться в локальном каталоге или объектном хранилище. Поэтому сохранение и удаление выполняются через Storage, без склейки MEDIA_ROOT с пользовательской строкой и без os.remove по предположительному пути. Новый файл получает случайное имя .jpg. Старый удаляется только после успешного commit и только если на него больше не ссылается запись модели. select_for_update сериализует конкурирующие замены одной записи на СУБД, поддерживающей такие блокировки.

Файлы и база не одна транзакция

Storage не откатывается вместе с SQL. Если сохранение записи не удалось, пытаемся удалить новый файл; если удаление старого после commit не удалось, robust callback позволит сохранить новую рабочую запись, но ошибку нужно обработать по журналу. Остановка процесса между шагами может оставить неиспользуемый файл — для такого случая нужен отдельный поиск и сбор файлов, на которые никто не ссылается. Не удаляйте файл, ещё используемый другими объектами.

Что проверить

  • 1024×768 помещается в 200×500 как 200×150.
  • 100×1000 уменьшается до 50×500; 50×50 остаётся 50×50.
  • EXIF-поворот применяется до расчёта размеров.
  • Неподдерживаемый формат, повреждённый файл, анимация и превышение лимитов отвергаются.
  • После сбоя SQL старый аватар существует, а новый не остаётся привязанным.
  • После commit удаляется только действительно неиспользуемый старый файл.

Поля связи и проверку прав подстройте под свою модель. При удалённом Storage протестируйте его ошибки отдельно; работа с файлами в этом примере не является распределённой транзакцией.

Источники: ImageOps, файлы Django, транзакции и on_commit.