Поле множественного выбора с автодополнением в Django

в 7:29, , рубрики: django, python, select2, метки: ,

Привет.
В прошлой своей статье я описал технологию создания кастомного поля для ввода тегов в Django. Сейчас я бы хотел поделиться готовым и более-менее универсальным решением, реализующим поле множественного выбора с автодополнением по AJAX. Отличие этого поля от описанного в предыдущей статье в том, что оно позволяет только выбирать элементы из справочника, но не создавать новые. За front-end часть будет отвечать замечательный jQuery-плагин Select2. Решение будет оформлено в виде отдельного приложения Django.

Виджет

from django.forms.widgets import Widget
from django.conf import settings
from django.utils.safestring import mark_safe
from django.template.loader import render_to_string

class InfiniteChoiceWidget(Widget):
'''Infinite choice widget, based on Select2 jQuery-plugin (http://ivaynberg.github.io/select2/)'''

	class Media:
	    js = (
	        settings.STATIC_URL + "select2/select2.js",
	    )
	    css = {
	        'all': (settings.STATIC_URL + 'select2/select2.css',)
	    }

	def __init__(self, data_model, multiple=False, disabled=False, attrs=None):
	    super(InfiniteChoiceWidget, self).__init__(attrs)
	    self.data_model = data_model
	    self.multiple = multiple
	    self.disabled = disabled

	def render(self, name, value, attrs=None):
	    return mark_safe(render_to_string("infinite_choice_widget.html",
	                                      {"disabled": self.disabled,
	                                       "multiple": self.multiple,
	                                       "attrs": attrs,
	                                       "app_name": self.data_model._meta.app_label,
	                                       "model_name": self.data_model.__name__,
	                                       "input_name": name,
	                                       "current_value": value if value else "",
	                                       })
	                     )

Конструктор виджета принимает класс модели, флаги multiple и disabled, отвечающие, соответственно, за множественность выбора и активность поля. В субклассе Media подключаются скрипты и стили Select2. Скрипт, инициализирующий плагин Select2, будет описан в шаблоне infinite_choice_widget.html.

{% load url from future %} {# Режим совместимости с django 1.5 #}

<input type="hidden"
       {% for key,value in attrs.iteritems %}
           {{key}}="{{value}}"
       {% endfor %}
       name="{{input_name}}"
       value="{{current_value}}" />

{# Settings for Select2 #}
<script type="text/javascript">
    $("#{{attrs.id}}").select2({
        multiple: {{multiple|yesno:"true,false"}},
        formatInputTooShort: function (input, min) { return "Пожалуйста, введите " + (min - input.length) + " или более символов"; },
        formatSearching: function () { return "Поиск..."; },
        formatNoMatches: function () { return "Ничего не найдено"; },
        minimumInputLength: 3,
        initSelection : function (element, callback) {
            $.ajax({
                url: "{% url 'infinite_choice_data' app_name model_name %}",
                type: 'GET',
                dataType: 'json',
                data: {ids: element.val()},
                success: function(data, textStatus, xhr) {
                  callback(data);
                },
                error: function(xhr, textStatus, errorThrown) {
                    callback({});
                }
            });
        },
        ajax: {
            url: "{% url 'infinite_choice_data' app_name model_name %}",
            dataType: 'json',
            data: function (term, page) {
            return {term: term, // search term
                    page_limit: 10
                };
            },
            results: function (data, page) {
                return {results: data};
            }
        }
    });

    {% if disabled %}
        $("#{{attrs.id}}").select2("disable");
    {% endif %}
</script>	

Центральным объектом здесь является скрытый input, в котором будут храниться идентификаторы выбранных вариантов. А уже на него по #id натравливается плагин Select2.
Пробежимся по параметрам плагина.

  • multiple — включает/выключает возможность множественного выбора
  • formatInputTooShort — возвращает строку, показывающую, сколько осталось ввести символов до срабатывания автодополнения
  • formatSearching — строка, показывающая, что идет процесс поиска
  • formatNoMatches — строка, показываемая от безысходности...
  • minimumInputLength — минимальное количество введенных символов для срабатывания автодополнения
  • initSelection — если при загрузке страницы в скрытом поле есть какие-то выбранные элементы, то эта функция делает AJAX-запрос серверу на предмет поиска отображаемых названий для этих элементов.
  • ajax — отсылает введенный текст и получает список объектов {id: ..., title: ...}, у которых title начинается с введенного текста. term — введенная строка для поиска вхождений, page_limit — количество выводимых найденных вариантов.

Обратите внимание, что в URL, по которому запрашиваются данные, указываются имя приложения и имя модели из этого приложения. Это сделано для универсализации обрабатывающего представления: вьюхе не обязательно четко знать, из какой модели берутся данные, мы ей это будем сообщать каждый раз.
В конце мы говорим плагину быть неактивным, если виджет передал disabled=True.

Представление для работы автодополнения

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

from django.http import Http404, HttpResponse
import json
from django.db.models import get_model


def infinite_choice_data(request, app_name, model_name):
    '''Returns data for infinite choice field'''

    data = []

    if not request.is_ajax():
        raise Http404

    model = get_model(app_name, model_name) 

    if 'term' in request.GET:
        term = request.GET['term']
        page_limit = request.GET['page_limit']

        data = model.objects.filter(title__startswith=term)[:int(page_limit)]

        json_data = json.dumps([{"id": item.id, "text": unicode(item.title)} for item in data])

    if 'ids' in request.GET:
        id_list = request.GET['ids'].split(',')

        items = model.objects.filter(pk__in=id_list)

        json_data = json.dumps([{"id": item.id, "text": item.title} for item in items])

    response = HttpResponse(json_data, content_type="application/json")
    response['Cache-Control'] = 'max-age=86400'
    return response

Функция django.db.models.get_model фозвращает класс модели. Далее, в зависимости от переменных запроса, из модели выбираются либо варианты, начинающиеся со строки term, либо варианты, имеющие id равные переданным в переменной ids. Второй случай происходит при инициализации плагина с уже введенными ранее данными.
Я добавил в response заголовок Cache-Control, с временем жизни данных в кэше — сутки. Это чтоб не дергать базу однотипными запросами. Очень помогает при использовании поля с огромными справочниками типа КЛАДР/ФИАС.

А так выглядит запись в urls.py для нашей вьюхи.

from django.conf.urls import patterns, url
from views import infinite_choice_data

urlpatterns = patterns('',
                       url(r'^(?P<app_name>[wd_]+)/(?P<model_name>[wd_]+)/$',
                           view=infinite_choice_data,
                           name='infinite_choice_data'),
                       )

Поле формы

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

from django.forms import fields as f_fields
from django.core.exceptions import ValidationError
from django.core import validators
from django.utils.translation import ugettext_lazy as _

class InfiniteChoiceField(f_fields.Field):
    '''Infinite choice field'''

    default_error_messages = {
        'invalid_choice': _(u'Select a valid choice. %(value)s is not one of the available choices.'),
    }

    def __init__(self, data_model, multiple=False, disabled=False, widget_attrs=None, **kwargs):
        self.data_model = data_model
        self.disabled = disabled
        self.multiple = multiple

        widget = InfiniteChoiceWidget(data_model, multiple, disabled, widget_attrs)
        super(InfiniteChoiceField, self).__init__(widget=widget, **kwargs)

    def to_python(self, value):
        if value in validators.EMPTY_VALUES:
            return None

        if self.multiple:
            values = value.split(',')

            qs = self.data_model.objects.filter(pk__in=values)
            pks = set([i.pk for i in qs])

            for val in values:
                if not int(val) in pks:
                    raise ValidationError(self.error_messages['invalid_choice'] % {'value': val})

            return qs
        else:
            try:
                return self.data_model.objects.get(pk=value)
            except self.data_model.DoesNotExists:
                raise ValidationError(self.error_messages['invalid_choice'] % {'value': value})

    def prepare_value(self, value):

        if value is not None and hasattr(value, '__iter__') and self.multiple:
            return u','.join(unicode(v.pk) for v in value)
        return unicode(value.pk)

Я не стал наследоваться от ModelMultipleChoiceField, так как оно работает с queryset, а нам надо работать с моделью.
Конструктор инициализирует виджет переданными моделью, флагами multiple и disabled и специфичными аттрибутами.
Метод to_python получает в качестве value либо одиночный id, либо несколько id в одной строке через запятую и обрабатывает его в зависимости от флага multiple. В обоих случаях проверяется наличие выбранных id в модели.
Метод prepare_value подготавливает инициализирующие данные для отображения: если в параметре initial поля передан одиночный инстанс модели, то возвращает id этого инстанса в виде строки; если же передан список инстансов или QuerySet, то возвращает строку с id через запятую.

Заключение

Поле готово к употреблению. Приложение можно скачать здесь. Пользоваться полем очень легко:

from django import forms
from infinite_choice_field import InfiniteChoiceField
from models import ChoiceModel

class TestForm(forms.Form):
    choice = InfiniteChoiceField(ChoiceModel,
                                 multiple=True,
                                 disabled=False,
                                 required=False,
                                 initial=ChoiceModel.objects.filter(id__in=(7, 8, 12)))	

, где ChoiceModel — произвольная модель вида

class ChoiceModel(models.Model):

    title = models.CharField(max_length=100, verbose_name="Choice title")

    class Meta:
        verbose_name = 'ChoiceModel'
        verbose_name_plural = 'ChoiceModels'

    def __unicode__(self):
        return self.title	

Осталось не забыть подключить приложение в settings.py,

INSTALLED_APPS = (
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.sites',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'infinite_choice_field',
    ...
}

импортировать urls

urlpatterns = patterns('',
    # Examples:
    # url(r'^$', 'habr_test.views.home', name='home'),
    url(r'^', include('core.urls')),
    url(r'^infinite_choice_data/', include('infinite_choice_field.urls')),

    # Uncomment the next line to enable the admin:
    url(r'^admin/', include(admin.site.urls)),
)

и вывести статику формы TestForm в шаблоне

<!doctype html>
<html>
    <head>
        <title>Test InfiniteChoiceField</title>
        {{test_form.media}}
    </head>
    <body>
        <form action="">
            {{test_form}}    
        </form>
    </body>
</html>    

Автор: pokidovea

Источник


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


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