ImageValue в django-dbsettings

в 23:18, , рубрики: django, python, метки: ,

Добрый день.

Часто бывает нужно иметь пользовательские (административные) настройки сайта, которые не могут быть определены в settings.py по двум простым причинам: настройки из settings.py не могут быть изменены без перезапуска сервера; и — самое главное — они могут быть изменены только программистом.

Модуль django-dbsettings (бывш. django-values) призван избавить Вас от этих ограничений: он предоставляет механизм хранения пользовательских настроек в базе данных, а также удобные виды для их редактирования.

И вроде бы все отлично… НО! Что же делать, если в качестве настройки нужна будет картинка: например, логотип сайта? Как выяснилось, django-dbsettings не поддерживает такого типа значений.

О том, как я добавлял поддержку ImageValue в django-dbsettings, я и собираюсь поведать.

Предыстория

Когда передо мной встала задача сделать настройки, я нашел проект django-values, который оказался нерабочим. Помучившись с ним, узнал, что он был переименован в django-dbsettings и перемещен на github.
На githab'е обнаружилось 20 форков. Перепробовав несколько из них, остановился на том, который обновлялся последним. Он оказался рабочим и завелся с первого раза без магии. Единственной проблемой осталось отсутствие типа «Картинка» в настройках.

Было два варианта: либо городить костыли у себя в проекте, чтобы добавить картинки в качестве настроек, либо форкнуть проект и сделать все красиво. Выбор, был очевиден.

Цели

Целей написания этой статьи было несколько:

  • во-первых, я хотел показать, как функционирует django-dbsettings, чтобы те из вас, кому понадобится добавить свой тип _настройки_, не тратили лишний день на чтение кода этого модуля;
  • во-вторых, мне хотелось донести до читателей (скорей даже для «писателей кода») своё пожелание: не останавливайтесь, когда что-то заработает! Продолжайте пересматривать и исправлять свой код, пока не возникнет чувство гордости и эстетического удовлетворения за своё «детище» (но не доводите до фанатизма: «keep it simple» © );
  • статья является примером внесения изменений в open source проект и, конкретно, того, как Ваши наработки должны красиво вписываться по стилю и логике в проект и не нарушать работы других модулей системы. Моё решение не сразу было таким, как оно представлено тут: сначала были затронуты и другие файлы; было несколько лишних if'ов, и т.д., но, следуя предыдущему пункту, удалось минимизировать и локализовать код.

Структура проекта

(Жирным выделены файлы, в которые нужно было внести изменения для внедрения ImageValue)

  • templates/содержит два шаблона: для просмотра и редактирования настроек всего сайта и настроек отдельного приложения
  • tests/ — содержит тесты
  • __init__.py
  • dbsettings.txt — справка по использованию модуля (тут подробно описано, как им пользоваться)
  • forms.py — конструктор формы для редактирования настроек
  • group.py — определяет класс группы настроек, управляет правами доступа
  • loading.py — содержит функции работы с базой (добавить, сохранить, прочитать настройки)
  • models.py — содержит свою модель для хранения настроек
  • urls.py — содержит url'ы для страниц просмотра и редактирования настроек
  • utils.py — содержит функцию выставления default-значений при выполнении syncdb
  • values.pyсодержит описание типов настроек
  • views.pyсодержит описание видов просмотра/редактирования

Чего же нам не хватает?

Покажу внесённые изменения в patch-виде: "-" — удаленная строка, "+" — добавленная строка.

Шаблоны

-<form method="post">
+<form enctype="multipart/form-data" method="post">
...
</form>

Описание формы в шаблонах django-dbsettings не содержало enctype, необходимого для того, чтобы форма принимала файлы, а вид получал их в request.FILES.

Виды

...
if request.method == 'POST':
         # Populate the form with user-submitted data
-        form = editor(request.POST.copy())
+        form = editor(request.POST.copy(), request.FILES)
...

Чтобы загруженные файлы проходили валидацию и попадали в form.cleaned_data с другими введёнными данными, нужно при создании формы передавать ей принятые файлы из запроса.

Типы настроек

На этом остановимся по-подробней.
Файл values.py содержит описание базового класса для настроек. В нем помимо всего прочего есть три метода, которые должны быть переопределены во всех дочерних классах:

...
class Value(object):
...
    def to_python(self, value):
        """Возвращает native-python объект,
        который используется при сравнении
        объектов данного класса"""
        return value

    def get_db_prep_save(self, value):
        """Производит нужные pre-save операции
        и возвращает значение, пригодное для
        хранения в CharField в базе данных"""
        return unicode(value)

    def to_editor(self, value):
        """Производит обратное преобразование
        и возвращает значение, пригодное для
        отображения в форме редактирования"""
        return unicode(value)
...

Также класс Value должен иметь атрибут field, в котором должен храниться класс поля формы (напр. django.forms.FileInput) для его создания.

Пишем свой Value

class ImageValue(Value):
    def __init__(self, *args, **kwargs):
        if 'upload_to' in kwargs:
            self._upload_to = kwargs['upload_to']
            del kwargs['upload_to']
        super(ImageValue, self).__init__(*args, **kwargs)
...

Наследуемся от базового класса Value, обрабатываем свой параметр upload_to, чтобы можно было контролировать подпапку в IMAGE_ROOT, в которую будут заливаться пользовательские изображения.

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

from os.path import join as pjoin
class ImageValue(Value):
...
    def get_db_prep_save(self, value):
        if not value:
            return None

        hashed_name = md5(unicode(time.time())).hexdigest() + value.name[-4:]
        image_path = pjoin(self._upload_to, hashed_name)
        dest_name = pjoin(settings.MEDIA_ROOT, image_path)

        with open(dest_name, 'wb+') as dest_file:
            for chunk in value.chunks():
                dest_file.write(chunk)

        return unicode(image_path)
...

Параметр value содержит объект UploadedFile из django.core.files.uploadedfile. Это стандартный объект, создаваемый при загрузке файлов и попадающий в request.FILES.
Метод производит нехитрые махинации: создает уникальное имя файла, и копирует загруженный файл в нужную директорию, указанную в self._upload_to. Метод возвращает путь до картинки относительно settings.IMAGE_ROOT, в таком виде настройка и попадает в базу данных.

Теперь сделаем обратное преобразование: получим объект картинки из записи в базе данных, за это отвечает следующий метод:

class ImageValue(Value):
...
    def to_editor(self, value):
        if not value:
            return None

        file_name = pjoin(settings.MEDIA_ROOT, value)
        try:
            with open(file_name, 'rb') as f:
                uploaded_file = SimpleUploadedFile(value, f.read(), 'image')

                # небольшой "хак" для получения пути из атрибута name
                uploaded_file.__dict__['_name'] = value
                return uploaded_file
        except IOError:
            return None
...

Тут все делается в обратном порядке: составляем путь до картинки со значением, взятым из базы, создаем объект SimpleUploadedFile и читаем в него файл картинки.
Объясню, зачем нужна строчка:

uploaded_file.__dict__['_name'] = value

Дело в том, что базовый класс для загруженных файлов UploadedFile имеет setter для атрибута name, который от переданного пути отрезает только имя файла и сохраняет его в self._name, а getter возвращает это значение. Записать туда руками путь до картинки — самый быстрый способ передачи его в свой виджет для формы.

И остался только метод, возвращающий объект для сравнения. Этот объект нужен при сравнении значения, полученного из запроса, с текущим значением из базы, чтобы лишний раз не перезаписывать файл. Тут все просто:

class ImageValue(Value):
...
    def to_python(self, value):
        return unicode(value)
...

Остался последний штрих: свой виджет, который рядом со стандартной кнопкой заливки файла будет отображать текущую картинку из базы.

class ImageValue(Value):
...
    class field(forms.ImageField):
        class widget(forms.FileInput):
            "Widget with preview"
            def __init__(self, attrs={}):
                forms.FileInput.__init__(self, attrs)

            def render(self, name, value, attrs=None):
                output = []

                try:
                    if not value:
                        raise IOError('No value')

                    Image.open(value.file)
                    file_name = pjoin(settings.MEDIA_URL, value.name)
                    output.append(u'<p><img src="{}" width="100" /></p>'.format(file_name))
                except IOError:
                    pass

                output.append(forms.FileInput.render(self, name, value, attrs))
                return mark_safe(''.join(output))
...

Создаем свой класс field, а в нем — widget, наследованный от стандартного FileInput. Переопределяем метод render, который отвечает за отображение нашего input'а, возвращая соответствующий html.

Image.open(value.file)

Эта строчка выполняет сразу две необходимых проверки: существует ли указанный файл, и является ли он изображением, в обоих случаях может выбросить исключение IOError.
Функция mark_safe() помечает строку безопасной для вывода html (без этого код нашего виджета просто выведется в виде строчки на странице).

Конечный результат выглядит таким образом:
ImageValue в django dbsettings

Ссылки

django-dbsettings на github.com

Спасибо за внимание.

PS

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

Автор: hdg700

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js