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