Django + Select2 интеграция
Select2 значительно улучшает пользовательский опыт работы с выпадающими списками, добавляя AJAX поиск, множественный выбор, теги и множество других возможностей.
Установка и настройка
Сначала установи необходимые пакеты:
1pip install django-select2
Или через poetry:
1poetry add django-select2
Добавь в INSTALLED_APPS:
Базовые виджеты 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: Да, для больших списков и часто используемых данных рекомендуется кэширование.