Задача: принять аватар, проверить содержимое файла и уменьшить изображение так, чтобы оно помещалось в заданный прямоугольник без изменения пропорций. Имя, расширение и присланный 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.