About Blog Dev | Alfa Romeo SZ Conkeror wishlist

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

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

Add post to: Delicious Reddit Slashdot Digg Technorati Google
Comment

Comments

Александр 3.03.2008 5:46

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

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

reply
Хрюндель 3.03.2008 6:48

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

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')

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

reply
ИгорёкК 3.03.2008 9:40

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

reply
Александр Соловьёв 3.03.2008 10:46

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

reply
ИгорёкК 3.03.2008 11:31

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

reply
Александр Соловьёв 3.03.2008 17:23

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

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

reply
ИгорёкК 4.03.2008 8:50

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

reply
Александр Соловьёв 3.03.2008 10:41

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

reply
ИгорёкК 3.03.2008 9:44

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

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

reply
Александр Соловьёв 3.03.2008 10:59

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

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

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

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

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

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

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

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

reply
Александр Соловьёв 3.03.2008 17:24

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

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

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

reply
Александр Соловьёв 3.03.2008 18:52

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

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

reply
Хрюндель 3.03.2008 6:49

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

reply
Александр Соловьёв 3.03.2008 10:41

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

reply
ИгорёкК 3.03.2008 9:35

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

reply
Александр Соловьёв 3.03.2008 10:40

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

reply
Dyadya Zed 6.03.2008 1:05

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

reply
Александр Соловьёв 6.03.2008 11:33

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

reply
Виктор 17.03.2008 10:05

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

reply
Александр Соловьёв 17.03.2008 11:38

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

reply
dr 29.03.2008 20:40

два момента:

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

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

p = UserProfile()
p.user = someuser

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

reply
Александр Соловьёв 29.03.2008 22:07

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

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

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

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

reply

Comment form for «ImprovedImageField»

Required. 30 chars of fewer.

Required.