Django + Select2 = select autocomplete

    Доброго времени суток.


    В последнее время, я пишу на django.

    Возникла необходимость вывода в списках достаточно большого количества опций.
    Если оставлять просто поле типа models.ForeignKey со стандартным виджетом (Select, SelectMultiple),
    нагружаем и базу данных и сервер приложений.
    Давайте попробуем обращатся к этим данным только тогда, когда это нужно.

    На просторах интернета, не обнаружил готового решения (чтобы просто установить и это заработало).
    Есть наборы комментарий типа «наверное, вам нужно вот то-то» или «вот это»
    В связи с этим, решил выложить то, что получилось.

    Выкладываю небольшой application под django, содержащий
    • Составные числовые поля и поля с датами
    • TreeWidget для модели, основанной на MPTT
    • Виджет SelectAutocomplete
    • Виджет SelectMultipleAutocomplete


    Статья ориентированно на начинающих разработчиков, не успевших «обрасти» библиотеками функций на django.
    Думаю, что опытным разработчикам она не будет интересна.


    Для иерархического виджета, нужно вставить в шаблон модальное окно
    {% include 'forms_custom/tree_widget_modal.html' %}.
    Если кому-то интересно узнать о нем подробнее, напишу в личку или отдельным постом.

    Опишу только то, что касается списков Select и SelectMultiple.
    В этом проекте нет поля TextAutocomplete, потому что мне оно пока не понадобилось.
    Думаю, тут будет достаточно примеров, чтобы сделать его самостоятельно,
    благо виджеты и поля форм расширяются достаточно просто.
    Виджет основан на популярном плагине Select2 ivaynberg.github.io/select2

    Установка


    Скрипты и стили
    <link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}select2/select2.css"/>
    
    <!-- Лучше отложить загрузку скриптов, поместив директивы их загрузки в конец страницы (ваш К.О.) -->
    <script type="text/javascript" src="{{ STATIC_URL }}jquery/jquery.min.js"></script>
    <script type="text/javascript" src="{{ STATIC_URL }}jstree/jquery.jstree.js"></script>
    <script type="text/javascript" src="{{ STATIC_URL }}select2/select2.js"></script>
    
    <!-- Скрипты виджетов разделены на отдельные файлы, для облегчения веса, если вдруг нужно использовать что-то одно -->
    <script type="text/javascript" src="{{ STATIC_URL }}forms_custom/tree_widget.js"></script>
    <script type="text/javascript" src="{{ STATIC_URL }}forms_custom/autocomplete.js"></script>
    <script type="text/javascript" src="{{ STATIC_URL }}forms_custom/autocomplete_multiple.js"></script>
    



    Скопировать пакет и подключить его в settings.py (для поиска статики и шаблонов)
    Подключить urls (для отдачи контента через AJAX)
        url(r'^forms_custom/', include('lib.forms_custom.urls', namespace='forms_custom')),
    


    Использование


    from django import forms
    from django.contrib.auth import get_user_model
    from lib.forms_custom.widgets import SelectMultipleAutocomplete
    
    users_active_qs = get_user_model().objects.filter(is_active=True)
    
    class MessageCreateForm(forms.Form):
    
        recepients = forms.ModelMultipleChoiceField(label=u'Получатели', 
                                                    queryset=users_active_qs,
                                                    widget=SelectMultipleAutocomplete(queryset=users_active_qs, 
                                                        expression="last_name__startswith"))
    
        subject = forms.CharField(label=u'Тема', required=False)
        body = forms.CharField(label=u'Сообщение', required=False, widget=forms.Textarea)
        back_url = forms.CharField(widget=forms.HiddenInput)
    

    Виджет требует аргументами QuerySet и search expression для поиска
    Фильтры, наложеные на QuerySet поддерживаются.

    С SelectAutocomplete все то же самое, только используется он с полем ModelChoiceField.
    Далее, как все это работает.

    Виджет


    Метод «render» возвращает все то, что будет выведено на форме,
    то есть скрытое поле, содержащее все необходимое для «натравливания» на него скрипта select2

    Метод «value_from_datadict», достает из POST-массива, данные виджета,
    преобразует их и передает дальше полю для валидации.
    Тут нам нужно подменить скалярные значения идентификаторов, перечисленных через запятую
    на список идентификаторов (как ожидает ModelMultipleChoiceField от SelectMultiple),
    потому что select2 хранит идентификаторы в скрытом текстовом поле, разделенные запятыми.

    Из особенностей, могу отметить, что наложенные фильтры достаем через объект класса WhereNode,
    который мы получаем из QuerySet:
        where_node = self._queryset.query.__dict__['where']
        where, where_params = where_node.as_sql(connection.ops.quote_name, connection)
    


    where_params пакуем с помощью pickle и вместе с where отправляем в виде параметров через ajax обработчику

    исходный код
    import datetime
    
    from django import forms
    from django.db import connection
    from django.forms import widgets as widgets_django
    from django.forms import fields
    from django.template.loader import render_to_string
    from django.forms.widgets import HiddenInput
    import pickle
    
    class AutocompleteWidget(object):
    
        def _parse_queryset(self):
    
            self._application = self._queryset.model.__module__.split('.')[-0]
            self._model_name = self._queryset.model.__name__
            
            where_node = self._queryset.query.__dict__['where']
            where, where_params = where_node.as_sql(connection.ops.quote_name, connection)
            
            if where:
                self._queryset_where = where.replace('"', '\"')
                self._queryset_where_params = pickle.dumps(where_params)
            else:
                self._queryset_where = ""
                self._queryset_where_params = ""
    
    
    class SelectAutocomplete(widgets_django.Select, AutocompleteWidget):
        
        def __init__(self, queryset, attrs=None):
            super(SelectAutocomplete, self).__init__(attrs)
            self._queryset = queryset
            self._parse_queryset()
    
        def render(self, name, value, attrs=None, choices=()):
            
            application = self._queryset.model.__module__.split('.')[-0]
            model_name = self._queryset.model.__name__
    
            return render_to_string('forms_custom/autocomplete.html', {'value': value, 
                'attrs': attrs,
                'application': application,
                'model_name': model_name,
                'expression': 'title__startswith',
                'name': name,
                'where': self._queryset_where,
                'where_params': self._queryset_where_params
            })
    
    
    class SelectMultipleAutocomplete(widgets_django.SelectMultiple, AutocompleteWidget):
    
        def __init__(self, queryset, attrs=None, expression='title__startswith'):
            
            super(SelectMultipleAutocomplete, self).__init__(attrs)
            self._queryset = queryset
            self._expression = expression
            self._parse_queryset()
    
        def render(self, name, value, attrs=None, choices=()):
            
            return render_to_string('forms_custom/autocomplete_multiple.html', {'value': value, 
                'attrs': attrs,
                'application': self._application,
                'model_name': self._model_name,
                'expression': self._expression,
                'name': name,
                'where': self._queryset_where,
                'where_params': self._queryset_where_params
            })
    
        def value_from_datadict(self, data, files, name):
            """ replace scalar value ("1,2,3") to list ([1,2,3])"""
            
            data_dict = super(SelectMultipleAutocomplete, self).value_from_datadict(data, files, name)
            value = data_dict[0]
            
            if not value:
                return None
    
            return value.split(",")
            
    
    



    Поле в форме


    Получаем поле, которое содержит нужный набор данных для запуска скрипта select2
    <input type="hidden" 
           id="{{attrs.id}}" 
           class="autocomplete_multiple_widget" 
           value="{% if value %}{{value|join:","}}{% endif %}" 
           name="{{name}}"
           data-url="{% url 'forms_custom:autocomplete_widget' application=application model_name=model_name %}"
           data-expression="{{expression}}"
           data-where="{{where}}"
           data-where_params="{{where_params}}"/>
    


    Скрипт


    Обходим виджеты по классу autocomplete_multiple_widget и для каждого вызываем select2
    Запрос на инициализирование виджета ничем не отличается от работы самого виджета, просто вызывается с параметрами
    id__in=current_values

    исходный код
    $(document).ready(function() {
        $('.autocomplete_multiple_widget').each(function() {
            bind_autocomplete_multiple_widget(this);
        });
    });
    
    function bind_autocomplete_multiple_widget(element) {
        
        var j_element = $(element);
        url = j_element.attr('data-url');
        var expression = j_element.attr('data-expression');
        var where = j_element.attr('data-where');
        var where_params = j_element.attr('data-where_params');
    
        $(element).select2({
            placeholder: "Поиск элемента",
            minimumInputLength: 3,
            multiple: true,
            ajax: {
                url: url,
                quietMillis: 1000, // Ждем 1 секунду для отправки запроса, чтобы не флудить
                dataType: 'json',
                // В GET-запрос добавляем параметры искомой строки, условий отбора where и запакованные параметры
                data: function (term, page) { return {q: term, expression: expression, where: where, where_params: where_params}; },
                results: function (data, page) {
                    return {results: data};
                }
            },
            // Эта функция отрабатывает при загрузке формы
            // и используется для преобразования текущих значений из id (которые в виде value="1,2,3" в объекты виджета)
            // Для этого мы просто отправляем запрос на поиск id__in=current_values и через callback инициализируем виджет 
            initSelection: function(element, callback) {
                var id = $(element).val();
                if (id !== "") {
                    $.ajax(url, {
                        data: {q: id, expression: 'pk__in', where: where, where_params: where_params},
                        dataType: "json"
                    }).done(function(data) { 
                        callback(data); 
                    });
                }
            },
            dropdownCssClass: "bigdrop",
            escapeMarkup: function (m) { return m; }
        });
    
    }
    



    Обработчик поиска


    Получает запрос ajax с информацией о: приложении, модели, условиями и параметрами фильтрации QuerySet-а
    При инициализации виджета, в него значения для поиска передаются в виде pk__in=«1,2,3»
    Для обработки этого, мы подменяем строку на список, разбивая по запятой.

    исходный код
    import json
    import pickle
    from django.http import HttpResponse, HttpResponseForbidden
    from django.db.models.loading import get_model
    
    
    def autocomplete_widget(request, application, model_name):
        
        if not request.is_ajax():
            return HttpResponseForbidden(u'Возможно обращение только по ajax')
    
        data = []
        expression = request.GET.get('expression')
        
        token = request.GET.get('q')
        if expression == u'pk__in':
            token = token.split(",")
    
        objects = get_model(application, model_name).objects
        
        where = request.GET.get('where')
        if where:
            where_params = request.GET.get('where_params')
            where_params = pickle.loads(where_params)
            objects = objects.extra(where=[where], params=where_params)
    
        objects = objects.filter(**{expression: token})[:20]
        
        for item in objects.iterator():
            data.append({"id": item.id, "text": unicode(item)})
    
        return HttpResponse(json.dumps(data), content_type="application/json;charset=utf-8")
    
    



    Берем модель из кэша django, накладываем условия фильтрации, фильтр для поиска и отдаем список найденых объектов.
    На выходе получили виджет, которым можно легко подменить стандартный Select и получить
    удобство для пользователей (не особо удобно проматывать списки из тысяч элементов)
    и снизит нагрузку на вашу систему.

    drive.google.com/file/d/0B0GZGIoZAYTFNU9xd3dIR3FXU0k/edit?usp=sharing

    Спасибо за внимание.
    P.S. Успешных проектов в новом году, комрады!
    Поделиться публикацией

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

      +1
      Если уж публикуете код, выложите его на GitHub или Bitbucket

      А ещё, посмотрите сюда. Похоже, вы этот проект не видели.
        0
        Да, этот апп хорош. А еще автору стоило бы научиться пользоваться «class Media»(вместо инструкции «что вставить в темплейт»): github.com/applegrew/django-select2/blob/master/django_select2/widgets.py#L239
          0
          Автор умеет ими пользоваться.
          Замечание верное. Выкладываю в общий доступ все-таки.

          Поясню ход мыслей.
          На странице бывает много независимых форм в разных местах.
          Не хочу после каждой формы выводить {{form.media}}.

          Загрузку всех скриптов всегда выношу в конце тэга «body»,
          это действительно, ускоряет время отображения страницы (визуально).

          В шаблон я предлагал вставить еще кусок шаблона (через include) с модальным окном,
          куда загружается дерево иерархии через AJAX.

          Можно было дерево загружать возле виджета,
          вставлять этот контейнер скриптом после загрузки страницы.
          Основная цель статьи не в том, чтоб предложить идеальное решение типа (скачай и забудь), а максимально простое для изучения внутри
          0
          Спасибо. Не видел. Вполне рабочий вариант, должен работать. Поставлю. Если все ок, выкину свой велосипед, пожалуй.
            0
            Нет, все-таки не выкину. Django-select2 не подходит.

            При регистрации поля в замыкании или в Memcached, в виде ключа используется
            github.com/applegrew/django-select2/blob/master/django_select2/fields.py#L50
            name = kwargs.pop('auto_id', u"%s.%s" % (self.__module__, self.__class__.__name__))
            Так django-select2 достает QuerySet на другой стороне AJAX-а

            В нашем приложении каждый пользователь может увидеть разное количество опций одного и того же поля
            в зависимости от ролей доступа, параметров документа, различных ограничений доступа.
            А хранить копию каждого поле-проект-документ-пользователь замусорит систему и нужно думать о инвалидации кэша, к тому же (параметры у документа меняются и будут менятся список наложенных фильтров).

            В этом приложении все более прозрачно.
            При отрисовке, виджет выдирает наложенные фильтры из QuerySet-а и сохраняет их в параметрах,
            которые каждый раз добавляются к AJAX-запросу.

            Необходимости хранить QuerySet вообще нет. Все работает тупо и просто.
            Можно открыть 10 документов на разных вкладках и получить разный набор опций для каждого.
            Это могут сделать 5000 пользователей и ajax-обработчик будет корректно всех обслуживать.

            С моим вариантом это будет работать, а с django-select2 нет.
              0
              Столкнулся с похожей проблемой, а рабочей альтернативы вроде как и нет. Вы свой код на Github/PyPi так и не выкладывали?
                0
                Не выкладывал.
                Посчитал решение сильно тривиальным и слишком простым для того, чтобы куда-то его выкладывать.

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

                Да и весь код, достаточный для воссоздания на вашей стороне, есть в статье.
                Мне жаль, что потерялся файл в Google Disk (там все было).

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

                Попробуйте просмотреть эти куски кода и создать у себя модуль с виджетами, скрипт и обработчик поисковых запросов.

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

                Прямо сейчас мне немного стыдно за качество кода и концепцию вообще, все-таки статья довольно старая.

                Сейчас такое же решение есть для Flask, но код я не могу выложить в общий доступ целиком.
                Если кому-то интересно, могу похожую статью с общими принципами написать про Wtforms
                  0
                  Не хотелось городить свой велосипед, а чужие уже все попробовал. Придется, видимо, все таки свой :)
          +2
            0
            Спасибо. Интересная штука. Используем это.
          • НЛО прилетело и опубликовало эту надпись здесь
              0
              Это виджеты для стандартных форм Django и предназначены для использования с формами Django.
              В блоке «Использование» как раз пример использования одного из виджетов с формой Django.

              Можете конкретизировать вопрос? Постараюсь ответить.

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

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