Amazon Byteflow

ImprovedImageField

| django, db, python, programming

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