ImageValue в django-dbsettings

    Добрый день.

    Часто возникает необходимость иметь пользовательские (административные) настройки сайта, которые не могут быть определены в 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 (без этого код нашего виджета просто выведется в виде строчки на странице).

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



    Ссылки


    django-dbsettings на github.com

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




    PS


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

    Подробнее
    Реклама

    Комментарии 25

    • НЛО прилетело и опубликовало эту надпись здесь
        0
        Спасибо. Я пользуюсь django-constance(сохраняет в Redis) для этих целей и там тоже нет ImageValue.
        Мне пока не требовался такой функционал, но я думаю стоит его добавить и туда на будущее)
          –2
          Странный подход. Мы не используем сторонние пакеты, а всегда пишем приложение с 0, исключая только то, что context_processor почти всегда один и тот же. Проблем то — написать модельку с нужными полями.
          Хотя, возможно, я не понял сути пакета django-dbsettings.
            0
            Для меня суть пакета — уже реализованный интерфейс админки, который выглядит именно как редактирование настроек. Причем с возможностью деления настройки на группы.
              +3
              То есть вы не используете:
              — django-registration
              — sorl.thumbnail
              — pyutils
              … тут дальше можно много продолжать.

              Ведь смысл джанги именно в большом количестве reusable-app. Почему вы отказываетесь от этого?
                +2
                Кстати, из последнего проекта django-registration выпилил с негодованием, т.к. понял, что от него и так не осталось почти ничего.

                Идея (или реализация, не знаю) с «бэкендами регистрации» в 0.8, которые предполагается писать — ужасна (могу это говорить, т.к. штук 5 разных бэкендов для django-registration написал за последние 2 года, самых разных): получается много кода и очень запутанная логика, и главное — непонятно, зачем: зачем загонять себя в рамки интерфейса бэкендов, что это конкретно дает (ничего) и чем обычные вьюхи хуже (всем лучше — проще, читабельней, понятно, откуда что идет, кто что обрабатывает и как все настраивать).

                Способ разработки django-registration меня тоже не устраивает совершенно — репозиторий с *отключенным багтрекером* — это просто замечательно. Там несколько сотен багов в нем было раньше, но потом это все просто отключили (как найти информацию, которая в этих баг-репортах была? никак). С 1.4 django-registration работал не вполне правильно, а чтоб заставить его работать правильно, нужен манки-патчинг. Вот на этом манки-патчинге django-registration меня потерял; вьюхи и формы и так почти все уже были свои, от RegistrationProfile и так уже был наследник, и от менеджера тоже, короче выкинул все это, скопипастил модельку, поправил для 1.4 (без манки-патчинга) и готово.

                Сейчас поддержку 1.4 добавили уже, и собираются вместо бэкендов сделать CBV, но пока — до свидания.
                  0
                  Михаил, я согласен, что django-registration не идеален. Версия 0.8 мне тоже не понравилась — старую немного под себя допилил. Но нельзя же из-за одного хромого приложения вообще не использовать чужие наработки.
                    0
                    Ага — в более длинном проекте сейчас около сотни зависимостей, в недавно начатом — штук 50 пока, я только за повторное использование кода)
                      0
                      50 штук — ого. Все уже с 1.4 работают? А можно список, если не сложно — ну, чтобы видеть что используют и что действительно работает. Хотя, конечно, это тема отдельной статьи: «Текущие reusable-app»
                        +1
                        50 — это не большое число, там много просто вспомогательных питоньих пакетов и прибитых зависимостей от других пакетов. Сильно много интересного вряд ли найдете) Если что-то с 1.4 не работает, обычно несложно форкнуть репозиторий, починить и ставить из него (сделав пулл реквест еще). Вот реальный текущий список из последнего проекта:

                        базовые:
                        Pillow
                        python-memcached >= 1.43
                        Markdown >= 2.0
                        python-dateutil == 1.5
                        simplejson
                        pip # обновляем pip, по возможности
                        sphinx >= 1.0
                        yuicompressor # требуется наличие java
                        lxml
                        pytils
                        psycopg2

                        утилиты разные:
                        pytz
                        easy-thumbnails
                        django-widget-tweaks == 1.1.1
                        django-excel-response
                        xlwt
                        django-colorful == 0.1.3
                        django-autoslug == 1.5
                        django-admin-decorators == 0.1
                        django-admin-honeypot == 0.2.1
                        yandex-maps == 0.6.1
                        funny-codes == 1.0.1
                        django-robokassa == 1.0
                        south == 0.7.4

                        -e hg+https://bitbucket.org/carljm/django-markitup@2a2442409b0f#egg=django-markitup
                        -e hg+https://bitbucket.org/carljm/django-model-utils@4288074567a8#egg=django-model-utils
                        -e git+git://github.com/sidmitra/django-timezones.git@cd39c662#egg=django-timezones
                        -e git+https://github.com/kmike/django-salmonella@01c9e2c601526b7#egg=django-salmonella

                        -e git+git://github.com/toastdriven/django-tastypie.git@edd14767ec1fb4628f3bf#egg=django-tastypie
                        mimeparse >= 0.1.3

                        -e git+git://github.com/miracle2k/webassets.git@bfcd816fb9713de782#egg=webassets
                        -e hg+https://bitbucket.org/kmike/django-annoying@3eddd7fa4f9a#egg=django-annoying
                        -e git+git://github.com/kmike/templated-emails.git#egg=templated-emails
                        -e git+git://github.com/kmike/django-query-exchange.git@93ee5e3730#egg=django-query-exchange

                        # «мини-cms»
                        django-flatblocks == 0.6.0
                        -e hg+https://bitbucket.org/kmike/python-markdown-video#egg=python-markdown-video

                        # админка
                        -e hg+https://bitbucket.org/izi/django-admin-tools@522004b1d118#egg=django-admin-tools

                        # sentry
                        raven
                        sentry == 3.7.1
                        django-celery == 2.5.1
                        celery == 2.5.1
                        gevent == 0.13.6
                        eventlet == 0.9.16

                        Для тестов:
                        coverage == 3.5.1
                        django_coverage == 1.2.2
                        django-webtest == 1.5.2
                        WebTest == 1.3.3
                        mock == 0.8
                        factory-boy == 1.1.3
                        django-factory-boy == 0.1.6
                        python-faker == 0.2.4

                        Локально, на сервере не нужно:
                        # для отладки
                        ipython
                        ipdb
                        docutils >= 0.7
                        -e git+git://github.com/django-debug-toolbar/django-debug-toolbar.git@0.9.4#egg=django-debug-toolbar
                        sqlparse
                        django-eml-email-backend

                        # для деплоя
                        jinja2
                        Fabric == 1.4
                        -e hg+https://bitbucket.org/kmike/django-fab-deploy@0.8.x#egg=django-fab-deploy
                        fabric-taskset == 0.1
                          0
                          Огромное Вам спасибо!
              0
              Приятно, когда параллельно с тобой у других людей возникают одни и те же задачи… а они из успевают решить раньше тебя :) да еще такую подробную статью на хабру написать. Спасибо!
                0
                Приятно, что кому-то уже успело это пригодиться :) Спасибо.
                +2
                Прекрасная статья. И с дзеном дружит, и пользу приносит. Вот прям сейчас и опробую в деле… А то надоело писать модельки с одной записью.
                  0
                  Мы используем django-livesettings. Насколько я понял, это один из форков от django-dbsettings, но немного новее
                  • НЛО прилетело и опубликовало эту надпись здесь
                    0
                    Отлично, как раз нужно в текущем проекте
                    • НЛО прилетело и опубликовало эту надпись здесь
                        0
                        > Site settings
                        > You don't have permission to edit values.

                        Пользователь — admin
                        ЧЯДНТ?
                          0
                          Затупил. Разобрался.
                          +1
                          Репортую, что ничего не работает.
                          dumpz.org/192153/
                          это безотносительно ImageField

                          Если убрать эту строчку про юзера (это просто сообщение), то видно, что файл заливается на диск, но в админке ничего не меняется- картинки нет
                            0
                            Возможно, из-за джанги 1.4.
                            Я только на 1.3 пробовал. До работы доберусь — проверю.
                              0
                              Скорее всего
                                0
                                Да, действительно: docs.djangoproject.com/en/1.2/topics/auth/#messages
                                Deprecated in Django 1.2: This functionality will be removed in Django 1.4.

                                Исправлю в ближайшее время. Спасибо за репорт!
                                  0
                                  Уже исправил. Обновитесь. Еще раз спасибо :)

                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                            Самое читаемое