diff --git a/CHANGELOG.md b/CHANGELOG.md index 306c6a0..158ac0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ Changelog Summary ================= +### v5.10.0 +* Add support for dependent select fields [321](github.com/applegrew/django-select2/pull/321/). + ### v5.9.0 * Add support for Django 1.11 LTS * Drop support for Django 1.9 diff --git a/django_select2/__init__.py b/django_select2/__init__.py index d3b714b..f638e09 100644 --- a/django_select2/__init__.py +++ b/django_select2/__init__.py @@ -2,11 +2,11 @@ """ This is a Django_ integration of Select2_. -The app includes Select2 driven Django Widgets and Form Fields. +The application includes Select2 driven Django Widgets and Form Fields. .. _Django: https://www.djangoproject.com/ .. _Select2: http://ivaynberg.github.com/select2/ """ -__version__ = "5.9.0" +__version__ = "5.10.0" diff --git a/django_select2/forms.py b/django_select2/forms.py index ffbbd7a..1a9989e 100644 --- a/django_select2/forms.py +++ b/django_select2/forms.py @@ -185,6 +185,8 @@ class Select2TagWidget(Select2TagMixin, Select2Mixin, forms.SelectMultiple): class HeavySelect2Mixin(object): """Mixin that adds select2's AJAX options and registers itself on Django's cache.""" + dependent_fields = {} + def __init__(self, attrs=None, choices=(), **kwargs): """ Return HeavySelect2Mixin. @@ -192,7 +194,12 @@ class HeavySelect2Mixin(object): Args: data_view (str): URL pattern name data_url (str): URL - + dependent_fields (dict): Dictionary of dependent parent fields. + The value of the dependent field will be passed as to :func:`.filter_queryset`. + It can be used to further restrict the search results. For example, a city + widget could be dependent on a country. + Key is a name of a field in a form. + Value is a name of a field in a model (used in `queryset`). """ self.choices = choices if attrs is not None: @@ -202,6 +209,10 @@ class HeavySelect2Mixin(object): self.data_view = kwargs.pop('data_view', None) self.data_url = kwargs.pop('data_url', None) + + dependent_fields = kwargs.pop('dependent_fields', None) + if dependent_fields is not None: + self.dependent_fields = dict(dependent_fields) if not (self.data_view or self.data_url): raise ValueError('You must ether specify "data_view" or "data_url".') self.userGetValTextFuncName = kwargs.pop('userGetValTextFuncName', 'null') @@ -224,6 +235,8 @@ class HeavySelect2Mixin(object): attrs.setdefault('data-ajax--cache', "true") attrs.setdefault('data-ajax--type', "GET") attrs.setdefault('data-minimum-input-length', 2) + if self.dependent_fields: + attrs.setdefault('data-select2-dependent-fields', " ".join(self.dependent_fields)) attrs['class'] += ' django-select2-heavy' return attrs @@ -334,7 +347,7 @@ class ModelSelect2Mixin(object): Args: model (django.db.models.Model): Model to select choices from. - queryset (django.db.models.QuerySet): QuerySet to select choices from. + queryset (django.db.models.query.QuerySet): QuerySet to select choices from. search_fields (list): List of model lookup strings. max_results (int): Max. JsonResponse view page size. @@ -364,14 +377,19 @@ class ModelSelect2Mixin(object): 'search_fields': self.search_fields, 'max_results': self.max_results, 'url': self.get_url(), + 'dependent_fields': self.dependent_fields, }) - def filter_queryset(self, term, queryset=None): + def filter_queryset(self, term, queryset=None, **dependent_fields): """ Return QuerySet filtered by search_fields matching the passed term. Args: term (str): Search term + queryset (django.db.models.query.QuerySet): QuerySet to select choices from. + **dependent_fields: Dependent fields and their values. If you want to inherit + from ModelSelect2Mixin and later call to this method, be sure to pop + from kwargs everything if it is not a dependent field. Returns: QuerySet: Filtered QuerySet @@ -386,6 +404,9 @@ class ModelSelect2Mixin(object): for t in [t for t in term.split(' ') if not t == '']: select &= reduce(lambda x, y: x | Q(**{y: t}), search_fields, Q(**{search_fields[0]: t})) + if dependent_fields: + select &= Q(**dependent_fields) + return queryset.filter(select).distinct() def get_queryset(self): diff --git a/django_select2/static/django_select2/django_select2.js b/django_select2/static/django_select2/django_select2.js index d6806fc..a196067 100644 --- a/django_select2/static/django_select2/django_select2.js +++ b/django_select2/static/django_select2/django_select2.js @@ -7,11 +7,21 @@ var settings = $.extend({ ajax: { data: function (params) { - return { + var result = { term: params.term, page: params.page, field_id: $element.data('field_id') } + + var dependentFields = $element.data('select2-dependent-fields') + if (dependentFields) { + dependentFields = dependentFields.trim().split(/\s+/) + $.each(dependentFields, function (i, dependentField) { + result[dependentField] = $('[name=' + dependentField + ']', $element.closest('form')).val() + }) + } + + return result }, processResults: function (data, page) { return { diff --git a/django_select2/views.py b/django_select2/views.py index 71a12aa..fb90df5 100644 --- a/django_select2/views.py +++ b/django_select2/views.py @@ -52,7 +52,12 @@ class AutoResponseView(BaseListView): def get_queryset(self): """Get QuerySet from cached widget.""" - return self.widget.filter_queryset(self.term, self.queryset) + kwargs = { + model_field_name: self.request.GET.get(form_field_name) + for form_field_name, model_field_name in self.widget.dependent_fields.items() + if form_field_name in self.request.GET and self.request.GET.get(form_field_name, '') != '' + } + return self.widget.filter_queryset(self.term, self.queryset, **kwargs) def get_paginate_by(self, queryset): """Paginate response by size of widget's `max_results` parameter.""" diff --git a/docs/extra.rst b/docs/extra.rst new file mode 100644 index 0000000..972902d --- /dev/null +++ b/docs/extra.rst @@ -0,0 +1,119 @@ +Extra +===== + +Chained select2 +--------------- + +Suppose you have an address form where a user should choose a Country and a City. +When the user selects a country we want to show only cities belonging to that country. +So the one selector depends on another one. + +Models +`````` + +Here are our two models: + +.. code-block:: python + + class Country(models.Model): + name = models.CharField(max_length=255) + + + class City(models.Model): + name = models.CharField(max_length=255) + country = models.ForeignKey('Country', related_name="cities") + + +Customizing a Form +`````````````````` + +Lets link two widgets via *dependent_fields*. + +.. code-block:: python + :emphasize-lines: 15 + + class AddressForm(forms.Form): + country = forms.ModelChoiceField( + queryset=Country.objects.all(), + label=u"Country", + widget=ModelSelect2Widget( + search_fields=['name__icontains'], + ) + ) + + city = forms.ModelChoiceField( + queryset=City.objects.all(), + label=u"City", + widget=ModelSelect2Widget( + search_fields=['name__icontains'], + dependent_fields={'country': 'country'}, + max_results=500, + ) + ) + + +Interdependent select2 +---------------------- + +Also you may want not to restrict the user to which field should be selected first. +Instead you want to suggest to the user options for any select2 depending of his selection in another one. + +Customize the form in a manner: + +.. code-block:: python + :emphasize-lines: 7 + + class AddressForm(forms.Form): + country = forms.ModelChoiceField( + queryset=Country.objects.all(), + label=u"Country", + widget=ModelSelect2Widget( + search_fields=['name__icontains'], + dependent_fields={'city': 'cities'}, + ) + ) + + city = forms.ModelChoiceField( + queryset=City.objects.all(), + label=u"City", + widget=ModelSelect2Widget( + search_fields=['name__icontains'], + dependent_fields={'country': 'country'}, + max_results=500, + ) + ) + +Take attention to country's dependent_fields. The value of 'city' is 'cities' because of +related name used in a filter condition `cities` which differs from widget field name `city`. + +.. caution:: + Be aware of using interdependent select2 in parent-child relation. + When a child is selected, you are restricted to change parent (only one value is available). + Probably you should let the user reset the child first to release parent select2. + + +Multi-dependent select2 +----------------------- + +Furthermore you may want to filter options on two or more select2 selections (some code is dropped for clarity): + +.. code-block:: python + :emphasize-lines: 14 + + class SomeForm(forms.Form): + field1 = forms.ModelChoiceField( + widget=ModelSelect2Widget( + ) + ) + + field2 = forms.ModelChoiceField( + widget=ModelSelect2Widget( + ) + ) + + field3 = forms.ModelChoiceField( + widget=ModelSelect2Widget( + dependent_fields={'field1': 'field1', 'field2': 'field2'}, + ) + ) + diff --git a/docs/index.rst b/docs/index.rst index 9af304b..b0e8051 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,6 +14,7 @@ Contents: get_started django_select2 + extra Indices and tables ================== diff --git a/tests/conftest.py b/tests/conftest.py index 3b3008e..a14bf1d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,11 @@ def random_string(n): ) +def random_name(n): + words = ''.join(random.choice(string.ascii_lowercase + ' ') for _ in range(n)).strip().split(' ') + return '-'.join([x.capitalize() for x in words]) + + @pytest.yield_fixture(scope='session', params=sorted(browsers.keys())) def driver(request): if 'DISPLAY' not in os.environ: @@ -58,3 +63,19 @@ def artists(db): return Artist.objects.bulk_create( [Artist(pk=pk, title=random_string(50)) for pk in range(100)] ) + + +@pytest.fixture +def countries(db): + from .testapp.models import Country + return Country.objects.bulk_create( + [Country(pk=pk, name=random_name(random.randint(10, 20))) for pk in range(10)] + ) + + +@pytest.fixture +def cities(db, countries): + from .testapp.models import City + return City.objects.bulk_create( + [City(pk=pk, name=random_name(random.randint(5, 15)), country=random.choice(countries)) for pk in range(100)] + ) diff --git a/tests/test_forms.py b/tests/test_forms.py index fa4fe62..c9bd6ab 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -23,7 +23,7 @@ from tests.testapp import forms from tests.testapp.forms import ( NUMBER_CHOICES, HeavySelect2MultipleWidgetForm, TitleModelSelect2Widget ) -from tests.testapp.models import Genre +from tests.testapp.models import City, Country, Genre try: from django.urls import reverse @@ -359,3 +359,65 @@ class TestHeavySelect2MultipleWidget(object): # genres should still have One as selected option result_title = driver.find_element_by_css_selector('.select2-selection--multiple li').get_attribute('title') assert result_title == 'One' + + +class TestAddressChainedSelect2Widget(object): + url = reverse('model_chained_select2_widget') + form = forms.AddressChainedSelect2WidgetForm() + + def test_widgets_selected_after_validation_error(self, db, live_server, driver, countries, cities): + driver.get(live_server + self.url) + + WebDriverWait(driver, 60).until( + expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-selection--single')) + ) + country_container, city_container = driver.find_elements_by_css_selector('.select2-selection--single') + + # clicking city select2 lists all available cities + city_container.click() + WebDriverWait(driver, 60).until( + expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-results li')) + ) + city_options = driver.find_elements_by_css_selector('.select2-results li') + city_names_from_browser = {option.text for option in city_options} + city_names_from_db = set(City.objects.values_list('name', flat=True)) + assert len(city_names_from_browser) == City.objects.count() + assert city_names_from_browser == city_names_from_db + + # selecting a country really does it + country_container.click() + WebDriverWait(driver, 60).until( + expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-results li:nth-child(2)')) + ) + country_option = driver.find_element_by_css_selector('.select2-results li:nth-child(2)') + country_name = country_option.text + country_option.click() + assert country_name == country_container.text + + # clicking city select2 lists reduced list of cities belonging to the country + city_container.click() + WebDriverWait(driver, 60).until( + expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-results li')) + ) + city_options = driver.find_elements_by_css_selector('.select2-results li') + city_names_from_browser = {option.text for option in city_options} + city_names_from_db = set(Country.objects.get(name=country_name).cities.values_list('name', flat=True)) + assert len(city_names_from_browser) != City.objects.count() + assert city_names_from_browser == city_names_from_db + + # selecting a city reaaly does it + city_option = driver.find_element_by_css_selector('.select2-results li:nth-child(2)') + city_name = city_option.text + city_option.click() + assert city_name == city_container.text + + # clicking country select2 lists reduced list to the only country available to the city + country_container.click() + WebDriverWait(driver, 60).until( + expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-results li')) + ) + country_options = driver.find_elements_by_css_selector('.select2-results li') + country_names_from_browser = {option.text for option in country_options} + country_names_from_db = {City.objects.get(name=city_name).country.name} + assert len(country_names_from_browser) != Country.objects.count() + assert country_names_from_browser == country_names_from_db diff --git a/tests/testapp/.coverage b/tests/testapp/.coverage new file mode 100644 index 0000000..fe8af93 --- /dev/null +++ b/tests/testapp/.coverage @@ -0,0 +1 @@ +!coverage.py: This is a private format, don't read it directly!{"lines":{}} \ No newline at end of file diff --git a/tests/testapp/forms.py b/tests/testapp/forms.py index 37692a2..d42198f 100644 --- a/tests/testapp/forms.py +++ b/tests/testapp/forms.py @@ -10,7 +10,7 @@ from django_select2.forms import ( Select2Widget ) from tests.testapp import models -from tests.testapp.models import Album +from tests.testapp.models import Album, City, Country class TitleSearchFieldMixin(object): @@ -174,3 +174,27 @@ class ModelSelect2TagWidgetForm(forms.ModelForm): widgets = { 'genres': GenreSelect2TagWidget } + + +class AddressChainedSelect2WidgetForm(forms.Form): + country = forms.ModelChoiceField( + queryset=Country.objects.all(), + label='Country', + widget=ModelSelect2Widget( + model=Country, + search_fields=['name__icontains'], + max_results=500, + dependent_fields={'city': 'cities'}, + ) + ) + + city = forms.ModelChoiceField( + queryset=City.objects.all(), + label='City', + widget=ModelSelect2Widget( + model=City, + search_fields=['name__icontains'], + dependent_fields={'country': 'country'}, + max_results=500, + ) + ) diff --git a/tests/testapp/models.py b/tests/testapp/models.py index 8733b44..34c0715 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -33,3 +33,20 @@ class Album(models.Model): def __str__(self): return self.title + + +@python_2_unicode_compatible +class Country(models.Model): + name = models.CharField(max_length=255) + + def __str__(self): + return self.name + + +@python_2_unicode_compatible +class City(models.Model): + name = models.CharField(max_length=255) + country = models.ForeignKey('Country', related_name="cities", on_delete=models.CASCADE) + + def __str__(self): + return self.name diff --git a/tests/testapp/urls.py b/tests/testapp/urls.py index 958e411..728cf8f 100644 --- a/tests/testapp/urls.py +++ b/tests/testapp/urls.py @@ -4,8 +4,9 @@ from __future__ import absolute_import, unicode_literals from django.conf.urls import include, url from .forms import ( - AlbumModelSelect2WidgetForm, HeavySelect2MultipleWidgetForm, - HeavySelect2WidgetForm, ModelSelect2TagWidgetForm, Select2WidgetForm + AddressChainedSelect2WidgetForm, AlbumModelSelect2WidgetForm, + HeavySelect2MultipleWidgetForm, HeavySelect2WidgetForm, + ModelSelect2TagWidgetForm, Select2WidgetForm ) from .views import TemplateFormView, heavy_data_1, heavy_data_2 @@ -26,6 +27,10 @@ urlpatterns = [ TemplateFormView.as_view(form_class=ModelSelect2TagWidgetForm), name='model_select2_tag_widget'), + url(r'^model_chained_select2_widget/$', + TemplateFormView.as_view(form_class=AddressChainedSelect2WidgetForm), + name='model_chained_select2_widget'), + url(r'^heavy_data_1/$', heavy_data_1, name='heavy_data_1'), url(r'^heavy_data_2/$', heavy_data_2, name='heavy_data_2'),