ImprovedImageField

В процессе работы над одним проектом я в очередной раз столкнулся с тем, что джанговский ImageField меня ну никак не устраивает — в нём отсутствует возможность ресайза при аплоаде, некак загружать картинки в разные директории1, кроме как по дате, ну и динамически назвать файлик2 тоже нельзя.

В результате поисков готового решения для копипаста не нашлось, хотя два готовых поля с различной функциональностью было. Первое имеет страшный код с жуткими комментариями3, с готовой функциональностью, закрывающей большую часть ТЗ и неработающее в современной Джанге. :) Второе — умеет заливать файлики по динамически изменяющемуся пути, но зато больше ничего не умеет.

Совмещение этих двух подходов много времени не заняло — на самом деле написание этого поста заняло куда больше времени, чем прошло от идеи и начала поисков до рабочей реализации (код поля приведен в конце поста).

Разбор полётов

В функциях fit и rename, занимающихся соответственно ресайзом и переименованием, объяснять в общем-то нечего — всё сказано в докстрингах. :)

Сам класс начинается с изменения init‘а, для получения параметров ресайза. Тут же, кстати, кроется небольшой недостаток — сейчас невозможно не указывать upload_to в параметрах классу, но я пока не придумал, как проверить существование функции для динамической установки пути upload_to.

Кстати, где-то не так давно прочитал, что использование contribute_to_class сейчас не приветствуется, но я для себя альтернатив в документации не нашёл — потому все три рабочие функции подключаю именно с помощью contribute_to_class.

Первая — _upload_to, проверяет на существование у модели метода determine_FIELD_upload_to (который из параметров принимает только self), и при наличии такого — вызывает его для установки upload_to (при отсутствии остаётся рабочим параметр, переданный в конструктор класса).

Дальше тоже нету ничего военного — _resize, после проверки на заполненность поля исправляет его размер (сразу после сохранения4).

Ну и “переименовывалка” — при заполненности поля и присутствии у модели метода determine_FIELD_filename переименовывает в соответствии с ним сущестсвующий файл (а вот это происходит как раз перед сохранением5).

Использовать всё это очень просто, вот пример готовой модели:

class UserProfile(models.Model):
    user = models.ForeignKey(User)
    avatar = ImprovedImageField(max_height=100, max_width=100, blank=True,
                                upload_to='_')

    def determine_avatar_upload_to(self):
        return self.user.username

    def determine_avatar_filename(self):
        return 'avatar'

Теперь при загрузке аватаров для пользователя ‘piranha’ они будут помещаться в каталог {{ settings.MEDIA_ROOT }}/piranha/avatar.jpg6.

Код самого поля:

import os
import shutil

import Image
from django.db.models import ImageField, signals
from django.dispatch import dispatcher
from django.conf import settings


def fit(file_path, max_width, max_height):
    """Resize file (located on file path) to maximum dimensions proportionally.
    At least one of max_width and max_height must be not None."""
    if not (max_width or max_height):
        # Can't resize
        return
    img = Image.open(file_path)
    w, h = img.size
    w = int(max_width or w)
    h = int(max_height or h)
    img.thumbnail((w, h), Image.ANTIALIAS)
    img.save(file_path)


def rename(old_path, new_name):
    """
    old_name is relative to MEDIA_ROOT
    new_name is just base name, without extension
    """
    def fp(path):
        return os.path.join(settings.MEDIA_ROOT, path)
    if not os.path.isfile(fp(old_path)):
        return old_path
    path = os.path.dirname(old_path)
    ext = os.path.splitext(old_path)[1]
    # django wants to have '/' in path
    new_path = '/'.join([path, new_name + ext])
    if new_path != old_path:
        try:
            shutil.move(fp(old_path), fp(new_path))
        except IOError:
            return old_path
    return new_path


class ImprovedImageField(ImageField):
    """Allows model instance to specify following parameters dynamically:

     — upload_to, specify following method for model:

        def determine_FIELD_upload_to(self):
            return 'avatars/%d' % self.user.username

     — filename (relative to upload_to, without extension):

         def determine_FIELD_filename(self):
             return self.pk

    Additionally field supports automatic resizing, if at least one of
    max_width and max_height supplied.

    Current flaws: upload_to must be specified as parameter (even if
    custom method exist, although parameter can carry useless value).

    Based on:
        http://code.djangoproject.com/wiki/CustomUploadAndFilters
        http://scottbarnham.com/blog/2007/07/31/uploading-images-to-a-dynamic-path-with-django/

    Copyright (c) 2008 Alexander Solovyov under new BSD License.
    """
    def __init__(self, max_width=None, max_height=None, **kwargs):
        self.max_width, self.max_height = max_width, max_height
        super(ImprovedImageField, self).__init__(**kwargs)

    def db_type(self):
        """Required by Django for ORM."""
        return 'varchar(100)'

    def contribute_to_class(self, cls, name):
        """Hook up events so we can access the instance."""
        super(ImprovedImageField, self).contribute_to_class(cls, name)
        dispatcher.connect(self._upload_to, signals.post_init, sender=cls)
        dispatcher.connect(self._resize, signals.post_save, sender=cls)
        dispatcher.connect(self._rename, signals.pre_save, sender=cls)

    def _upload_to(self, instance=None):
        """Get dynamic upload_to value from the model instance."""
        if hasattr(instance, 'determine_%s_upload_to' % self.attname):
            self.upload_to = getattr(instance, 'determine_%s_upload_to' % self.attname)()

    def _resize(self, instance):
        if getattr(instance, self.attname):
            real_path = os.path.join(settings.MEDIA_ROOT, getattr(instance, self.attname))
            if os.path.isfile(real_path):
                fit(real_path, self.max_width, self.max_height)

    def _rename(self, instance):
        if hasattr(instance, 'determine_%s_filename' % self.attname) and getattr(instance, self.attname):
            filename = getattr(instance, 'determine_%s_filename' % self.attname)()
            new_path = rename(getattr(instance, self.attname), filename)
            setattr(instance, self.attname, new_path)

UPD. Наконец-то пофиксил траблу с get_FIELD_filename.

1

А если этого не сделать, то директория быстро загрязнится кучей файлов, что в будущем вполне может привести к заметному увеличению времени поиска файлов системой.

2

Это в первоначальные планы не входило, но позже обрело своё место под солнцем. :)

3

В стиле a=2+3 # Вычисляем 2+3.

4

А точнее, после вызова сигнала post_save у нашей модели.

5

На этот раз — сигнала pre_save.

6

Естественно, расширение зависит от типа файла. :)

Comments: 28 (already: 2) Comment post

А что будет если в _rename я захочу использовать поле id, которые появится после INSERT?

Собственно, сам, когда столкнулся с необходимости переименования и изменения размера(и ещё создания превьюх), сделал универсальное переименование в уникальный хеш(на практике выяснилось, что “красивое” имя файла совсем не важно). А, как оказалось, масштабирование и создания маленьких образов лучше сделать сразу в _save_FIELD_file, чтобы диск не насиловать много:)

Александр , 05:46

А ещё надо добавить опцию для непропорционального тумбнэйла, что бы квадратные тумбы получать, но пр и этом они не были сжатыми..

def fit_thumbnail(self, path):
    im = PIL.Image.open(path)
    x,y = im.size
    min_size = min(x, y)
    region = im.crop((0, 0, min_size, min_size))
    region.thumbnail((80, 80), PIL.Image.ANTIALIAS)
    return region.tostring('jpeg', 'RGB')

вот что то типа такого.

Хрюндель , 06:48

Спасибо, попробую этот код. У меня иначе сделано - я накладывааю полученный thumbnail на чёрный фон (центрируя по вертикали/горизонтали).

ИгорёкК , 09:40

Вот кстати интересный вариант - накладывать на прозрачный фон, центрируя по вертикали и горизонтали. Но с jpg это не катит, да и с png (долбаный IE!) тоже.

Alexander Solovyov , 10:46

У меня фон именно чёрный поэтому с прозрачностью заморачиваться не приходится. Может как вариант в settings прописывать цвет фона (это на правах бреда)?

ИгорёкК , 11:31

Может. Типа, в зависимости от дизайна сайта. С другой стороны, если дизайн меняется, наступает полное и абсолютное зло.

У нас в рабочем проекте цсс-ные учаснеги как-то достигли центрирования неквадратных аватаров в квадратном участочке. Так что может это лучший вариант? :)

Alexander Solovyov , 17:23

Согласен, что при смене дизайна наступает конец :) Но в данном случае нет необходимости.

ИгорёкК , 08:50 (after 1 day)

А я как раз удалил. Имхо кроп - это зло. :)

Alexander Solovyov , 10:41

А вот насчёт красивого имени не совсем согласен. Если есть необходимость выводить аватар пользователя, то чтобы не лезть дополнительным запросом в таблицу с профайлом, можно аватару присваивать ID пользователя в качестве имени файла. У меня так реализовано :)

PS. Хотя можно сделать любимый contribute_to_class и хранить тот же хеш в таблице User.

ИгорёкК , 09:44

А что будет если в _rename я захочу использовать поле id, которые появится после INSERT?

Хмм… похоже, будет неприятно. ;-)

на практике выяснилось, что “красивое” имя файла совсем не важно

Ну… Неважно для работоспособности? Это да. Неважно для пользователей? А вот это смотря каких - мне, например, приятно, что я могу вспомнить схему написания какого-то урла и без проблем найти то, что хочется. А не как в ЖЖ - через уникальные айдишники. Собственно, для этого human-readable урлы и придумали, и красивый путь к картинкам - всего лишь ещё одно его проявление. ;-)

А, как оказалось, масштабирование и создания маленьких образов лучше сделать сразу в _save_FIELD_file, чтобы диск не насиловать много:)

Хмм… Кстати да, я просто привязался к событиям вокруг этого метода, а можно было бы просто его самого заменить… Надо поразмышлять.

Alexander Solovyov , 10:59

Ответь себе на вопрос, как часто ты вбиваешь в адресную строку url картинки?:)

Александр , 14:30

Тут тебя ожидает подлянка небольшая. ;-) Я - нередко по памяти урлы набираю, в том числе и картинки. :)

Alexander Solovyov , 17:24

Ну надо признать, что это не очень распространенный use case….

Александр , 17:59

Ну, я про подлянку писал. Естественно, я никак не являюсь репрезентативной выборкой.

Но, если есть возможность, средства и желание - почему нет? =) Кстати, ещё при чётко понятном сразу названии нету проблем с накапливанием старых аватаров - они просто заменяются новым.

Alexander Solovyov , 18:52

Блин, что то не интуитивный у тебя интерфейс в блогах.. Хотел просто откомментить в блогах, а получилось кому-то ответил

Хрюндель , 06:49

У каждого сообщения есть ссылка - ответить. Логично, что это ответ на сообщение. А ссылка комментирования поста - находится под постом. Может разве что её налево передвинуть…

Alexander Solovyov , 10:41

У меня создание превью попроще происходит. А у тебя это всё более “по уму” :)

ИгорёкК , 09:35

У меня нету создания превью. =) Тамбнейлами у меня занимается sorl-thumbnail, а тут я просто делаю картинку меньшего размера.

Alexander Solovyov , 10:40

Что-то не работает ссылка на sort-thumbnail, говорит 404.

Dyadya Zed , 01:05 (after 3 days)

Что-то меня заглючило. sorl-thumbnail, пофиксил.

Alexander Solovyov , 11:33 (after 3 days)

а мы взяли sorl-thumbnail и довели до ума. тоже довольно удобно получилось.

Виктор , 10:05 (after 14 days)

Это разные вещи. Я тоже использую изменённый sorl-thumbnail. И я об этом уже писал, в комментарии выше.

Alexander Solovyov , 11:38 (after 14 days)

два момента:

get_FIELD_filename пересекается с методом, который добавляет в модель FileField (от которого унаследован ImageField), что не очень хорошо

инициализация upload_to в post_init не будет работать для довольно простого случая, когда вышеприведенный UserProfile создается без инициализации user в конструкторе:

p = UserProfile()
p.user = someuser

по-моему, можно не извращаться с upload_to, если потом все равно делается переименование в pre_save. надо только rename научить между каталогами двигать :)

dr , 20:40 (after 26 days)

get_FIELD_filename пересекается с методом, который добавляет в модель FileField (от которого унаследован ImageField), что не очень хорошо

Хм. Реальный втык. Переименую. :-)

по-моему, можно не извращаться с upload_to, если потом все равно делается переименование в pre_save.

В принципе да, просто они у меня лично в разных местах выросли. :) Прикол в том, что upload_to вполне может быть полезен без переименования. Можно, конечно, в методе переименования это всё делать, но как-то лениво проверять… :)

Alexander Solovyov , 22:07 (after 26 days)

Вопрос: почему не вызывать rename по сигналу post_save? Тогда уже будет известен id объекта, поле которого мы сохраняем. Сохранять картинки с id в имени файла — распространенная задача.

Gregory , 21:01 (after 359 days)

Ну… никаких особых причин не было. :) Вполне можно и по post_save вызывать, конечно.

Alexander Solovyov , 14:50 (after 360 days)

dispatcher.connect(self._upload_to, signals.post_init, sender=cls) AttributeError: ‘module’ object has no attribute ‘connect’

Django 1.0.2

moskrc , 18:03 (after 390 days)

Да, в джанге 1.0 надо использовать signals.post_init.connect(self._upload_to, sender=cls)

Alexander Solovyov , 13:34 (after 391 day)

Comment form for «ImprovedImageField»

Required. 30 chars of fewer.

Required.

Comment post