Django + Select2 интеграция

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

Установка и настройка

Сначала установи необходимые пакеты:

1pip install django-select2

Или через poetry:

1poetry add django-select2

Добавь в INSTALLED_APPS:

1INSTALLED_APPS = [
2    # ... другие приложения
3    'django_select2',
4]
5
6# Настройки Select2
7SELECT2_CACHE_BACKEND = "default"
8SELECT2_CACHE_PREFIX = "select2_"

Базовые виджеты Select2

Используй готовые виджеты для различных сценариев:

 1from django import forms
 2from django_select2.forms import (
 3    Select2Widget,
 4    Select2MultipleWidget,
 5    ModelSelect2Widget,
 6    ModelSelect2MultipleWidget
 7)
 8from .models import Book, Author, Category
 9
10class BookForm(forms.ModelForm):
11    # Простой Select2 для ForeignKey
12    author = forms.ModelChoiceField(
13        queryset=Author.objects.all(),
14        widget=Select2Widget,
15        label='Автор'
16    )
17
18    # Select2 с множественным выбором для ManyToMany
19    categories = forms.ModelMultipleChoiceField(
20        queryset=Category.objects.all(),
21        widget=Select2MultipleWidget,
22        label='Категории'
23    )
24
25    class Meta:
26        model = Book
27        fields = ['title', 'author', 'categories', 'price', 'description']

AJAX поиск с ModelSelect2Widget

Создавай виджеты с AJAX поиском для больших списков:

 1from django_select2.forms import ModelSelect2Widget
 2
 3class AuthorSelect2Widget(ModelSelect2Widget):
 4    search_fields = ['name__icontains', 'email__icontains']
 5
 6    def get_queryset(self):
 7        return Author.objects.filter(is_active=True).order_by('name')
 8
 9class CategorySelect2Widget(ModelSelect2Widget):
10    search_fields = ['name__icontains', 'description__icontains']
11
12    def get_queryset(self):
13        return Category.objects.filter(is_active=True).order_by('name')
14
15class BookForm(forms.ModelForm):
16    author = forms.ModelChoiceField(
17        queryset=Author.objects.all(),
18        widget=AuthorSelect2Widget,
19        label='Автор'
20    )
21
22    categories = forms.ModelMultipleChoiceField(
23        queryset=Category.objects.all(),
24        widget=CategorySelect2Widget,
25        label='Категории'
26    )
27
28    class Meta:
29        model = Book
30        fields = ['title', 'author', 'categories', 'price']

Кастомные виджеты с дополнительными полями

Создавай виджеты, которые отображают дополнительную информацию:

 1class AuthorSelect2Widget(ModelSelect2Widget):
 2    search_fields = ['name__icontains']
 3
 4    def get_queryset(self):
 5        return Author.objects.select_related('country').filter(is_active=True)
 6
 7    def label_from_instance(self, obj):
 8        # Показываем имя и страну автора
 9        return f"{obj.name} ({obj.country.name})"
10
11    def get_queryset(self):
12        return Author.objects.select_related('country').filter(is_active=True)
13
14class BookSelect2Widget(ModelSelect2Widget):
15    search_fields = ['title__icontains', 'author__name__icontains']
16
17    def get_queryset(self):
18        return Book.objects.select_related('author').filter(is_available=True)
19
20    def label_from_instance(self, obj):
21        # Показываем название книги и автора
22        return f"{obj.title} - {obj.author.name}"
23
24    def get_queryset(self):
25        return Book.objects.select_related('author').filter(is_available=True)

Виджеты с зависимостями

Создавай связанные Select2 поля:

 1class CategorySelect2Widget(ModelSelect2Widget):
 2    search_fields = ['name__icontains']
 3
 4    def get_queryset(self):
 5        return Category.objects.filter(is_active=True)
 6
 7class SubcategorySelect2Widget(ModelSelect2Widget):
 8    search_fields = ['name__icontains']
 9
10    def get_queryset(self):
11        # Получаем категорию из формы
12        category_id = self.form.initial.get('category') if self.form else None
13        if category_id:
14            return Subcategory.objects.filter(category_id=category_id, is_active=True)
15        return Subcategory.objects.none()
16
17class BookForm(forms.ModelForm):
18    category = forms.ModelChoiceField(
19        queryset=Category.objects.all(),
20        widget=CategorySelect2Widget,
21        label='Категория'
22    )
23
24    subcategory = forms.ModelChoiceField(
25        queryset=Subcategory.objects.all(),
26        widget=SubcategorySelect2Widget,
27        label='Подкатегория',
28        required=False
29    )
30
31    class Meta:
32        model = Book
33        fields = ['title', 'category', 'subcategory', 'author', 'price']
34
35# JavaScript для обновления подкатегорий при изменении категории
36class Media:
37    js = ('js/book_form.js',)

JavaScript для зависимых полей

Создавай JavaScript для обновления связанных полей:

 1// static/js/book_form.js
 2$(document).ready(function() {
 3    $('#id_category').on('change', function() {
 4        var categoryId = $(this).val();
 5        var subcategorySelect = $('#id_subcategory');
 6
 7        // Очищаем подкатегорию
 8        subcategorySelect.empty().append('<option value="">---------</option>');
 9
10        if (categoryId) {
11            // Загружаем подкатегории для выбранной категории
12            $.ajax({
13                url: '/api/subcategories/',
14                data: {
15                    'category': categoryId
16                },
17                success: function(data) {
18                    $.each(data, function(index, item) {
19                        subcategorySelect.append(
20                            $('<option></option>').val(item.id).text(item.name)
21                        );
22                    });
23                }
24            });
25        }
26    });
27});

Виджеты с тегами

Создавай поля для ввода тегов:

 1from django_select2.forms import TagWidget
 2
 3class BookForm(forms.ModelForm):
 4    tags = forms.CharField(
 5        widget=TagWidget,
 6        required=False,
 7        help_text='Введите теги через запятую'
 8    )
 9
10    class Meta:
11        model = Book
12        fields = ['title', 'author', 'tags', 'price']
13
14# Кастомный виджет для тегов с автодополнением
15class CustomTagWidget(TagWidget):
16    def __init__(self, *args, **kwargs):
17        super().__init__(*args, **kwargs)
18        self.attrs.update({
19            'data-placeholder': 'Выберите или введите теги...',
20            'data-tags': 'true',
21            'data-token-separators': '[",", " "]'
22        })
23
24class BookForm(forms.ModelForm):
25    tags = forms.CharField(
26        widget=CustomTagWidget,
27        required=False,
28        help_text='Введите теги через запятую или выберите из списка'
29    )
30
31    class Meta:
32        model = Book
33        fields = ['title', 'author', 'tags', 'price']

Виджеты с фильтрацией

Добавляй фильтры в виджеты:

 1class FilteredAuthorSelect2Widget(ModelSelect2Widget):
 2    search_fields = ['name__icontains']
 3
 4    def get_queryset(self):
 5        queryset = Author.objects.filter(is_active=True)
 6
 7        # Фильтр по стране
 8        country = self.form.initial.get('country') if self.form else None
 9        if country:
10            queryset = queryset.filter(country=country)
11
12        # Фильтр по жанру
13        genre = self.form.initial.get('genre') if self.form else None
14        if genre:
15            queryset = queryset.filter(books__genre=genre).distinct()
16
17        return queryset.order_by('name')
18
19class BookForm(forms.ModelForm):
20    country = forms.ModelChoiceField(
21        queryset=Country.objects.all(),
22        widget=Select2Widget,
23        label='Страна'
24    )
25
26    genre = forms.ModelChoiceField(
27        queryset=Genre.objects.all(),
28        widget=Select2Widget,
29        label='Жанр'
30    )
31
32    author = forms.ModelChoiceField(
33        queryset=Author.objects.all(),
34        widget=FilteredAuthorSelect2Widget,
35        label='Автор'
36    )
37
38    class Meta:
39        model = Book
40        fields = ['title', 'country', 'genre', 'author', 'price']

Виджеты с кастомными шаблонами

Создавай виджеты с HTML шаблонами:

 1class CustomAuthorSelect2Widget(ModelSelect2Widget):
 2    search_fields = ['name__icontains']
 3
 4    def get_queryset(self):
 5        return Author.objects.select_related('country').filter(is_active=True)
 6
 7    def label_from_instance(self, obj):
 8        # Возвращаем HTML для отображения
 9        return f'<div class="author-option"><strong>{obj.name}</strong><br><small>{obj.country.name}</small></div>'
10
11    class Media:
12        css = {
13            'all': ('css/select2-custom.css',)
14        }
15        js = ('js/select2-custom.js',)
16
17# CSS для кастомного отображения
18# static/css/select2-custom.css
19.select2-results__option {
20    padding: 8px 12px;
21}
22
23.author-option {
24    line-height: 1.4;
25}
26
27.author-option strong {
28    color: #333;
29}
30
31.author-option small {
32    color: #666;
33}

Виджеты с валидацией

Добавляй кастомную валидацию в виджеты:

 1class ValidatedAuthorSelect2Widget(ModelSelect2Widget):
 2    search_fields = ['name__icontains']
 3
 4    def get_queryset(self):
 5        return Author.objects.filter(is_active=True)
 6
 7    def validate(self, value):
 8        if value:
 9            try:
10                author = Author.objects.get(id=value)
11                if not author.is_active:
12                    raise forms.ValidationError('Этот автор неактивен')
13                if author.books.count() > 100:
14                    raise forms.ValidationError('У автора слишком много книг')
15            except Author.DoesNotExist:
16                raise forms.ValidationError('Автор не найден')
17        return value
18
19class BookForm(forms.ModelForm):
20    author = forms.ModelChoiceField(
21        queryset=Author.objects.all(),
22        widget=ValidatedAuthorSelect2Widget,
23        label='Автор'
24    )
25
26    def clean_author(self):
27        author = self.cleaned_data['author']
28        if author:
29            # Дополнительная валидация
30            if author.books.count() >= 50:
31                raise forms.ValidationError(
32                    'У этого автора уже достаточно книг в системе'
33                )
34        return author
35
36    class Meta:
37        model = Book
38        fields = ['title', 'author', 'price']

Виджеты с кэшированием

Оптимизируй производительность с кэшированием:

 1from django.core.cache import cache
 2from django_select2.forms import ModelSelect2Widget
 3
 4class CachedAuthorSelect2Widget(ModelSelect2Widget):
 5    search_fields = ['name__icontains']
 6
 7    def get_queryset(self):
 8        cache_key = f'select2_authors_{self.form.initial.get("country", "all")}'
 9        queryset = cache.get(cache_key)
10
11        if queryset is None:
12            queryset = Author.objects.filter(is_active=True)
13
14            country = self.form.initial.get('country')
15            if country:
16                queryset = queryset.filter(country=country)
17
18            queryset = list(queryset.order_by('name'))
19            cache.set(cache_key, queryset, 300)  # 5 минут
20
21        return queryset
22
23class BookForm(forms.ModelForm):
24    author = forms.ModelChoiceField(
25        queryset=Author.objects.all(),
26        widget=CachedAuthorSelect2Widget,
27        label='Автор'
28    )
29
30    class Meta:
31        model = Book
32        fields = ['title', 'author', 'price']

Виджеты для админки Django

Используй Select2 в Django Admin:

 1from django.contrib import admin
 2from django_select2.forms import Select2Widget, Select2MultipleWidget
 3from .models import Book, Author, Category
 4
 5class BookAdmin(admin.ModelAdmin):
 6    formfield_overrides = {
 7        models.ForeignKey: {'widget': Select2Widget},
 8        models.ManyToManyField: {'widget': Select2MultipleWidget},
 9    }
10
11    list_display = ['title', 'author', 'price', 'created_at']
12    list_filter = ['author', 'categories', 'created_at']
13    search_fields = ['title', 'author__name', 'categories__name']
14
15admin.site.register(Book, BookAdmin)
16
17# Кастомный виджет для админки
18class AdminAuthorSelect2Widget(Select2Widget):
19    class Media:
20        css = {
21            'all': ('admin/css/select2-admin.css',)
22        }
23        js = ('admin/js/select2-admin.js',)
24
25class BookAdmin(admin.ModelAdmin):
26    formfield_overrides = {
27        models.ForeignKey: {'widget': AdminAuthorSelect2Widget},
28    }

Тестирование Select2 виджетов

Создавай тесты для проверки функциональности:

 1from django.test import TestCase
 2from django.test.client import Client
 3from django.urls import reverse
 4from .models import Book, Author, Category
 5from .forms import BookForm
 6
 7class Select2WidgetTestCase(TestCase):
 8    def setUp(self):
 9        self.client = Client()
10        self.author = Author.objects.create(name='Test Author')
11        self.category = Category.objects.create(name='Test Category')
12        self.book = Book.objects.create(
13            title='Test Book',
14            author=self.author,
15            price=19.99
16        )
17        self.book.categories.add(self.category)
18
19    def test_select2_widget_rendering(self):
20        form = BookForm(instance=self.book)
21        html = form.as_p()
22
23        # Проверяем, что Select2 виджеты присутствуют
24        self.assertIn('select2', html)
25        self.assertIn('data-placeholder', html)
26
27    def test_ajax_search_endpoint(self):
28        # Тестируем AJAX endpoint для поиска авторов
29        response = self.client.get(
30            reverse('select2_author_search'),
31            {'term': 'Test'}
32        )
33
34        self.assertEqual(response.status_code, 200)
35        data = response.json()
36        self.assertIn('results', data)
37        self.assertTrue(len(data['results']) > 0)
38
39    def test_form_validation_with_select2(self):
40        form_data = {
41            'title': 'New Book',
42            'author': self.author.id,
43            'categories': [self.category.id],
44            'price': '29.99'
45        }
46
47        form = BookForm(data=form_data)
48        self.assertTrue(form.is_valid())
49
50        book = form.save()
51        self.assertEqual(book.author, self.author)
52        self.assertIn(self.category, book.categories.all())
53
54class Select2WidgetIntegrationTestCase(TestCase):
55    def test_dependent_fields_update(self):
56        # Тестируем обновление зависимых полей
57        form = BookForm()
58
59        # Симулируем изменение категории
60        form.initial['category'] = self.category
61        form.fields['subcategory'].widget.form = form
62
63        # Проверяем, что queryset для подкатегории обновился
64        subcategory_queryset = form.fields['subcategory'].queryset
65        self.assertEqual(subcategory_queryset.count(), 0)

Лучшие практики

  • Всегда используй search_fields для AJAX поиска
  • Оптимизируй queryset с select_related и prefetch_related
  • Кэшируй результаты для часто используемых данных
  • Добавляй валидацию в виджеты и формы
  • Используй зависимые поля для связанных данных
  • Тестируй функциональность виджетов
  • Настраивай CSS для кастомизации внешнего вида
  • Мониторь производительность AJAX запросов
  • Используй теги для гибкого ввода данных
  • Документируй кастомные виджеты

FAQ

Q: Как настроить AJAX поиск в Select2?
A: Используй ModelSelect2Widget и настрой URL для AJAX запросов.

Q: Можно ли использовать Select2 без AJAX?
A: Да, используй Select2Widget для простых списков без AJAX.

Q: Как создать зависимые Select2 поля?
A: Используй JavaScript для обновления связанных полей при изменении родительского.

Q: Можно ли кастомизировать внешний вид Select2?
A: Да, используй CSS и кастомные шаблоны в виджетах.

Q: Как добавить валидацию в Select2 виджеты?
A: Переопредели метод validate в виджете или добавь clean методы в форму.

Q: Нужно ли кэшировать результаты Select2?
A: Да, для больших списков и часто используемых данных рекомендуется кэширование.