Django + Select2 = select autocomplete

в 22:18, , рубрики: django, django framework, метки:

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

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. Успешных проектов в новом году, комрады!

Автор: dibrovsd

Источник


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


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