Compare commits

...
23 Commits
Author SHA1 Message Date
Johannes Hoppe 4a1b83ad1e Fixes security bug and adds secruity documentation
An attacker was able to use a `field_id` from a "secret" field
and use if on any even the default public select2 view and
receive the data without authentication.
2016-01-26 13:18:36 +01:00
mostafa-anm 33a00c1e39 Fixed #218 -- Add custom label support
Added `label_from_instance` method for model widgets to define custom option labels.

Closed #233
2016-01-19 08:50:09 +01:00
Andrew Dodd 89674ec375 Update conf.py
Minor copy-paste error.

Closed #240
2015-12-17 10:15:11 +01:00
Andrew Dodd e655ed7a59 Add setting to change source of select2 assets
These changes introduce additional (optional) configuration parameters.
The parameters allow the user of the library to select different JS/CSS
libraries from the ones shipped. In particular, this allows serving from
the local server and/or in private-network-only environments.

Refs #220
Closed #239
2015-12-15 19:57:48 +01:00
Elijah Karari 3c06873148 Replace "patterns" with list in url config
`django.conf.urls.patterns` has been remmoved in Django 1.9.

Closed #236
2015-12-10 16:05:29 +01:00
Johannes Hoppe 84e1abaf4e Remove model_mommy test dependency 2015-12-02 20:05:38 +01:00
Johannes Hoppe 6ebeb4c6d7 Fixed pep257 issues 2015-12-02 19:40:14 +01:00
Johannes Hoppe 7d8a7e7a9f Fixed pytest setup on travis 2015-12-02 19:32:28 +01:00
Johannes Hoppe 9ba75bb820 Updated test suite for python 3.5 support 2015-12-02 19:28:16 +01:00
Johannes Hoppe cfc7c2c541 Added pip-tools for requirement manageing
Updated requirements
2015-12-02 19:27:23 +01:00
Johannes Hoppe 6c5e2036ca Updated test suite and setup file for Django 1.9 release 2015-12-02 18:57:28 +01:00
Johannes Hoppe 4defb8112b Fixed #222 -- Display intital data for heavy widgets 2015-12-01 10:19:02 +01:00
Johannes Hoppe bb90be77b7 Fixed #231 -- Avoid fetching entire queryset from db
Patched ciritcal memory leak in ModelSelect2 widgets.
Boolean evaluation of queryset makes django fetch all
elements.

Thanks to @magicjohnson for discovering this issue
2015-11-30 13:17:02 +01:00
Johannes Hoppe 18647161a1 Fixed docs 2015-11-29 20:47:22 +01:00
Royendgel Silberie 3e6c1fc3ba fixing small typo
Closed #227
2015-11-29 20:03:01 +01:00
Johannes Hoppe 19a8d63bb1 Refactors tag widgets and adds light tag widget
Currently there was no light version of the tag widget.
A light version might come handy if you use a postgres
`ArrayField` and a corresponding `SimpleArrayField`.

Resolved #226
2015-11-23 11:54:51 +01:00
Anne Fleischer 0457617738 Fixed javaScript bug for multiple different select2 fields on one page
Closed #224
2015-11-18 00:03:02 +01:00
Anne Fleischer 974ba552b0 Converted initialization code into configurable jQuery plugin
Closed #211
2015-11-12 12:50:58 +01:00
Thomas Schreiber 2beb5d23c7 Fix HeavySelect2TagWidget to respect custom attrs and default overrides
Closed #214
2015-10-27 17:44:22 +01:00
Johannes Hoppe b8d53aa714 Added v5.2.0 changelog 2015-10-05 19:34:52 +02:00
Johannes Hoppe b0d2325c43 Resolved #209 -- Added pagination 2015-10-05 19:01:43 +02:00
Johannes Hoppe 9d1bcfee6a Fixed flake8 error 2015-10-05 19:01:21 +02:00
Johannes Hoppe 2c1eb8bc4a Fixed -- IndexError: pop from empty list 2015-10-05 12:59:35 +02:00
20 changed files with 472 additions and 3211 deletions
+4 -6
View File
@@ -6,16 +6,14 @@ services:
- memcached
python:
- "2.7"
- "3.3"
- "3.4"
- "pypy"
- "pypy3"
- "3.5"
env:
global:
- DISPLAY=:99.0
matrix:
- DJANGO="Django<1.8,>=1.7"
- DJANGO="Django<1.9,>=1.8"
- DJANGO="Django<1.10,>=1.9"
- DJANGO="-e git+https://github.com/django/django.git@master#egg=Django"
matrix:
fast_finish: true
@@ -32,7 +30,7 @@ install:
script:
- isort --check-only --recursive --diff .
- flake8 --jobs=2 .
- pep257 django_select2
- coverage run --source=django_select2 runtests.py
- pep257 --explain --source --count django_select2
- coverage run --source=django_select2 -m py.test
after_success:
- coveralls
+41
View File
@@ -1,6 +1,47 @@
Changelog Summary
=================
### v5.7.0
* Security fix that allows a `field_id` to only be used for the intended JSON endpoint.
Prior to that change you could use any `field_id` on any select2 JSON endpoint.
Even if the id was intended to be used on a private endpoint if could be used on
the default one and therefore leak sensitive data.
* Breaking change on how `Heavy` widgets are being cached.
Heavy widgets used to add themselves to the cache. Now they add a dictionary to
the cache containing themselves and the target url.
```python
{
'widget': self,
'url': self.get_url(),
}
```
### v5.6.0
* Added `label_from_instance` method for model widgets to define custom option labels.
### v5.5.0
* Added settings to delivery static assets from different source.
### v5.4.2
* Fixed initial data not being shown for heavy widgets.
### v5.4.1
* Fixed memory leak in `ModelSelect2Mixin` and subclasses
### v5.4.0
* Added `Select2TagWidget` a light widget with tagging support
### v5.3.0
* Added djangoSelect2 jQuery plugin to support
dynamic field initialisation
### v5.2.0
* Added pagination
### v5.1.0
* Added search term splitting
* Model widgets get smarter pickling to reduce size and avoid pickling issues
+1 -1
View File
@@ -9,4 +9,4 @@ The app includes Select2 driven Django Widgets and Form Fields.
"""
__version__ = "5.1.0"
__version__ = "5.6.0"
+26 -1
View File
@@ -9,7 +9,6 @@ __all__ = ('settings', 'Select2Conf')
class Select2Conf(AppConf):
"""Settings for Django-Select2."""
CACHE_BACKEND = 'default'
@@ -49,5 +48,31 @@ class Select2Conf(AppConf):
It has set `select2_` as a default value, which you can change if needed.
"""
JS = '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/js/select2.min.js'
"""
The URI for the Select2 JS file. By default this points to the Cloudflare CDN.
If you want to select the version of the JS library used, or want to serve it from
the local 'static' resources, add a line to your settings.py like so::
SELECT2_JS = 'assets/js/select2.min.js'
.. tip:: Change this setting to a local asset in your development environment to
develop without an internet connection.
"""
CSS = '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/css/select2.min.css'
"""
The URI for the Select2 CSS file. By default this points to the Cloudflare CDN.
If you want to select the version of the library used, or want to serve it from
the local 'static' resources, add a line to your settings.py like so::
SELECT2_CSS = 'assets/css/select2.css'
.. tip:: Change this setting to a local asset in your development environment to
develop without an internet connection.
"""
class Meta:
prefix = 'SELECT2'
+67 -31
View File
@@ -49,6 +49,7 @@ Light widgets are normally named, i.e. there is no
from __future__ import absolute_import, unicode_literals
from functools import reduce
from itertools import chain
from django import forms
from django.core import signing
@@ -62,7 +63,6 @@ from .conf import settings
class Select2Mixin(object):
"""
The base mixin of all Select2 widgets.
@@ -101,16 +101,25 @@ class Select2Mixin(object):
https://docs.djangoproject.com/en/1.8/topics/forms/media/#media-as-a-dynamic-property
"""
return forms.Media(
js=('//cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/js/select2.min.js',
'django_select2/django_select2.js'),
css={'screen': ('//cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/css/select2.min.css',)}
js=(settings.SELECT2_JS, 'django_select2/django_select2.js'),
css={'screen': (settings.SELECT2_CSS,)}
)
media = property(_get_media)
class Select2Widget(Select2Mixin, forms.Select):
class Select2TagMixin(object):
"""Mixin to add select2 tag functionality."""
def build_attrs(self, extra_attrs=None, **kwargs):
"""Add select2's tag attributes."""
self.attrs.setdefault('data-minimum-input-length', 1)
self.attrs.setdefault('data-tags', 'true')
self.attrs.setdefault('data-token-separators', [",", " "])
return super(Select2TagMixin, self).build_attrs(extra_attrs, **kwargs)
class Select2Widget(Select2Mixin, forms.Select):
"""
Select2 drop in widget.
@@ -135,7 +144,6 @@ class Select2Widget(Select2Mixin, forms.Select):
class Select2MultipleWidget(Select2Mixin, forms.SelectMultiple):
"""
Select2 drop in widget for multiple select.
@@ -145,8 +153,24 @@ class Select2MultipleWidget(Select2Mixin, forms.SelectMultiple):
pass
class HeavySelect2Mixin(Select2Mixin):
class Select2TagWidget(Select2TagMixin, Select2Mixin, forms.SelectMultiple):
"""
Select2 drop in widget for for tagging.
Example for :class:`.django.contrib.postgres.fields.ArrayField`::
class MyWidget(Select2TagWidget):
def value_from_datadict(self, data, files, name):
values = super(MyWidget, self).value_from_datadict(data, files, name):
return ",".join(values)
"""
pass
class HeavySelect2Mixin(Select2Mixin):
"""Mixin that adds select2's ajax options and registers itself on django's cache."""
def __init__(self, **kwargs):
@@ -199,10 +223,14 @@ class HeavySelect2Mixin(Select2Mixin):
def set_to_cache(self):
"""Add widget object to Djnago's cache."""
cache.set(self._get_cache_key(), self)
cache.set(self._get_cache_key(), {
'widget': self,
'url': self.get_url(),
})
def render_options(self, choices, selected_choices):
"""Render only selected options."""
choices = chain(choices, self.choices)
output = ['<option></option>' if not self.is_required else '']
choices = {(k, v) for k, v in choices if k in selected_choices}
selected_choices = {force_text(v) for v in selected_choices}
@@ -212,7 +240,6 @@ class HeavySelect2Mixin(Select2Mixin):
class HeavySelect2Widget(HeavySelect2Mixin, forms.Select):
"""
Select2 widget with AJAX support that registers itself to Django's Cache.
@@ -236,30 +263,21 @@ class HeavySelect2Widget(HeavySelect2Mixin, forms.Select):
class HeavySelect2MultipleWidget(HeavySelect2Mixin, forms.SelectMultiple):
"""Select2 multi select widget similar to :class:`.HeavySelect2Widget`."""
pass
class HeavySelect2TagWidget(HeavySelect2MultipleWidget):
class HeavySelect2TagWidget(Select2TagMixin, HeavySelect2MultipleWidget):
"""Select2 tag widget."""
"""Mixin to add select2 tag functionality."""
def build_attrs(self, extra_attrs=None, **kwargs):
"""Add select2's tag attributes."""
attrs = super(HeavySelect2TagWidget, self).build_attrs(extra_attrs, **kwargs)
attrs['data-minimum-input-length'] = 1
attrs['data-tags'] = 'true'
attrs['data-token-separators'] = [",", " "]
return attrs
pass
# Auto Heavy widgets
class ModelSelect2Mixin(object):
"""Widget mixin that provides attributes and methods for :class:`.AutoResponseView`."""
model = None
@@ -316,6 +334,7 @@ class ModelSelect2Mixin(object):
'cls': self.__class__,
'search_fields': self.search_fields,
'max_results': self.max_results,
'url': self.get_url(),
})
def filter_queryset(self, term, queryset=None):
@@ -327,7 +346,7 @@ class ModelSelect2Mixin(object):
:return: Filtered queryset
:rtype: :class:`.django.db.models.QuerySet`
"""
if not queryset:
if queryset is None:
queryset = self.get_queryset()
search_fields = self.get_search_fields()
select = Q()
@@ -335,7 +354,7 @@ class ModelSelect2Mixin(object):
term = term.replace('\n', ' ')
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.pop(): t}))
Q(**{search_fields[0]: t}))
return queryset.filter(select).distinct()
def get_queryset(self):
@@ -373,18 +392,37 @@ class ModelSelect2Mixin(object):
self.queryset = self.choices.queryset
selected_choices = {c for c in selected_choices
if c not in self.choices.field.empty_values}
choices = {self.choices.choice(obj)
choices = {(obj.pk, self.label_from_instance(obj))
for obj in self.choices.queryset.filter(pk__in=selected_choices)}
else:
choices = chain(choices, self.choices)
choices = {(k, v) for k, v in choices if k in selected_choices}
selected_choices = {force_text(v) for v in selected_choices}
for option_value, option_label in choices:
output.append(self.render_option(selected_choices, option_value, option_label))
return '\n'.join(output)
def label_from_instance(self, obj):
"""
Return option label representation from instance.
Can be overridden to change the representation of each choice.
Example usage::
class MyWidget(ModelSelect2Widget):
def label_from_instance(obj):
return force_text(obj.title).upper()
:param obj: Instance of django model.
:type obj: django.db.models.Model
:return Option label.
:rtype: str
"""
return force_text(obj)
class ModelSelect2Widget(ModelSelect2Mixin, HeavySelect2Widget):
"""
Select2 drop in model select widget.
@@ -392,7 +430,7 @@ class ModelSelect2Widget(ModelSelect2Mixin, HeavySelect2Widget):
class MyWidget(ModelSelect2Widget):
search_fields = [
'title__icontians',
'title__icontains',
]
class MyModelForm(forms.ModelForm):
@@ -423,7 +461,6 @@ class ModelSelect2Widget(ModelSelect2Mixin, HeavySelect2Widget):
class ModelSelect2MultipleWidget(ModelSelect2Mixin, HeavySelect2MultipleWidget):
"""
Select2 drop in model multiple select widget.
@@ -433,10 +470,9 @@ class ModelSelect2MultipleWidget(ModelSelect2Mixin, HeavySelect2MultipleWidget):
pass
class ModelSelect2TagWidget(ModelSelect2Mixin, HeavySelect2TagWidget):
class ModelSelect2TagWidget(Select2TagMixin, ModelSelect2MultipleWidget):
"""
Select2 model field with tag support.
Select2 model widget with tag support.
This it not a simple drop in widget.
It requires to implement you own :func:`.value_from_datadict`
@@ -448,7 +484,7 @@ class ModelSelect2TagWidget(ModelSelect2Mixin, HeavySelect2TagWidget):
queryset = MyModel.objects.all()
def value_from_datadict(self, data, files, name):
values = super().value_from_datadict(self, data, files, name):
values = super().value_from_datadict(self, data, files, name)
qs = self.queryset.filter(**{'pk__in': list(values)})
pks = set(force_text(getattr(o, pk)) for o in qs)
cleaned_values = []
@@ -1,23 +1,48 @@
$(function () {
$('.django-select2').not('django-select2-heavy').select2();
$('.django-select2.django-select2-heavy').each(function () {
var field_id = $(this).data('field_id');
$(this).select2({
(function ($) {
var init = function ($element, options) {
$element.select2(options);
};
var initHeavy = function ($element, options) {
var settings = $.extend({
ajax: {
data: function (params) {
return {
term: params.term,
page: params.page,
field_id: field_id
field_id: $element.data('field_id')
};
},
processResults: function (data, page) {
return {
results: data.results
};
return {
results: data.results,
pagination: {
more: data.more
}
};
}
}
});
});
});
}, options);
$element.select2(settings);
};
$.fn.djangoSelect2 = function (options) {
var settings = $.extend({}, options);
$.each(this, function (i, element) {
var $element = $(element);
if ($element.hasClass('django-select2-heavy')) {
initHeavy($element, settings);
} else {
init($element, settings);
}
});
return this;
};
$(function () {
$('.django-select2').djangoSelect2();
});
}(this.jQuery));
+3 -4
View File
@@ -9,12 +9,11 @@ Add `django_select` to your urlconf **if** you use any 'Model' fields::
"""
from __future__ import absolute_import, unicode_literals
from django.conf.urls import patterns, url
from django.conf.urls import url
from .views import AutoResponseView
urlpatterns = patterns(
"",
urlpatterns = [
url(r"^fields/auto.json$",
AutoResponseView.as_view(), name="django_select2-json"),
)
]
+6 -4
View File
@@ -5,7 +5,6 @@ from __future__ import absolute_import, unicode_literals
from django.core import signing
from django.core.signing import BadSignature
from django.http import Http404, JsonResponse
from django.utils.encoding import smart_text
from django.views.generic.list import BaseListView
from .cache import cache
@@ -13,7 +12,6 @@ from .conf import settings
class AutoResponseView(BaseListView):
"""
View that handles requests from heavy model widgets.
@@ -32,7 +30,8 @@ class AutoResponseView(BaseListView):
'text': "foo",
'id': 123
}
]
],
'more': true
}
"""
@@ -43,11 +42,12 @@ class AutoResponseView(BaseListView):
return JsonResponse({
'results': [
{
'text': smart_text(obj),
'text': self.widget.label_from_instance(obj),
'id': obj.pk,
}
for obj in context['object_list']
],
'more': context['page_obj'].has_next()
})
def get_queryset(self):
@@ -79,6 +79,8 @@ class AutoResponseView(BaseListView):
widget_dict = cache.get(cache_key)
if widget_dict is None:
raise Http404('field_id not found')
if widget_dict.pop('url') != self.request.path:
raise Http404('field_id was issued for the view.')
qs, qs.query = widget_dict.pop('queryset')
self.queryset = qs.all()
widget_dict['queryset'] = self.queryset
+44
View File
@@ -40,3 +40,47 @@ Cache
:members:
:undoc-members:
:show-inheritance:
JavaScript
----------
DjangoSelect2 handles the initialization of select2 fields automatically. Just include
``{{ form.media.js }}`` in your template before the closing ``body`` tag. That's it!
If you insert forms after page load or if you want to handle the initialization
yourself, DjangoSelect2 provides a jQuery-Plugin. It will handle both normal and
heavy fields. Simply call ``djangoSelect2(options)`` on your select fields.::
$('.django-select2').djangoSelect2();
You can pass see `Select2 options <https://select2.github.io/options.html>`_ if needed::
$('.django-select2').djangoSelect2({placeholder: 'Select an option'});
Security & Authentication
-------------------------
Security is important. Therefore make sure to read and understand what
the security measures in place and their limitations.
Set up a separate cache. If you have a public form that uses a model widget
make sure to setup a separate cache database for Select2. An attacker
could constantly reload your site and fill up the select2 cache.
Having a separate cache allows you to limit the effect to select2 only.
You might want to add a secure select2 JSON endpoint for data you don't
want to be accessible to the general public. Doing so is easy::
class UserSelect2View(LoginRequiredMixin, AutoResponseView):
pass
class UserSelect2WidgetMixin(object):
def __init__(self, *args, **kwargs):
kwargs['data_view'] = 'user-select2-view'
super(UserSelect2WidgetMixin, self).__init__(*args, **kwargs)
class MySecretWidget(UserSelect2WidgetMixin, Select2ModelWidget):
model = MySecretModel
search_fields = ['title__icontains']
+9
View File
@@ -0,0 +1,9 @@
django-appconf
flake8
pep8-naming
mccabe
isort
pep257
pytest
pytest-django
selenium
+15 -15
View File
@@ -1,19 +1,19 @@
-e .
#
# This file is autogenerated by pip-compile
# Make changes in requirements_dev.in, then run this to update:
#
# pip-compile requirements_dev.in
#
django-appconf==1.0.1
django==1.8.4 # via model-mommy
flake8==2.4.1
flake8==2.5.0
isort==4.2.2
mccabe==0.3.1 # via flake8
mock==1.3.0 # via model-mommy
model-mommy==1.2.5
pbr==1.8.0 # via mock
pep257==0.5.0
mccabe==0.3.1
pep257==0.7.0
pep8-naming==0.3.3
pep8==1.5.7 # via flake8
py==1.4.30 # via pytest
pyflakes==0.8.1 # via flake8
pytest-django==2.8.0
pytest==2.7.2
requests==2.7.0
selenium==2.46.0
six==1.9.0 # via django-appconf, mock, model-mommy
py==1.4.31 # via pytest
pyflakes==1.0.0 # via flake8
pytest-django==2.9.1
pytest==2.8.3
selenium==2.48.0
six==1.10.0 # via django-appconf
-3086
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -11,8 +11,6 @@ show-source = true
exclude = docs,runtests.py,setup.py,env
[pep257]
explain = true
count = true
[isort]
atomic = true
+2 -20
View File
@@ -22,23 +22,6 @@ URL = "https://github.com/applegrew/django-select2"
VERSION = __import__(PACKAGE).__version__
class PyTest(Command):
user_options = []
def initialize_options(self):
pass
def finalize_options(self):
pass
def run(self):
import sys
import subprocess
errno = subprocess.call([sys.executable, 'runtests.py'])
raise SystemExit(errno)
setup(
name=NAME,
version=VERSION,
@@ -59,13 +42,12 @@ setup(
"Programming Language :: Python",
"Programming Language :: Python :: 2",
"Programming Language :: Python :: 3",
"Framework :: Django :: 1.7",
"Framework :: Django :: 1.8",
"Framework :: Django",
"Framework :: Django :: 1.8",
"Framework :: Django :: 1.9",
],
install_requires=[
'django-appconf>=0.6.0',
],
zip_safe=False,
cmdclass={'test': PyTest},
)
+18 -3
View File
@@ -2,9 +2,10 @@
from __future__ import absolute_import, print_function, unicode_literals
import os
import random
import string
import pytest
from model_mommy import mommy
from selenium import webdriver
from selenium.common.exceptions import WebDriverException
@@ -15,6 +16,13 @@ browsers = {
}
def random_string(n):
return ''.join(
random.choice(string.ascii_uppercase + string.digits)
for _ in range(n)
)
@pytest.fixture(scope='session',
params=browsers.keys())
def driver(request):
@@ -33,9 +41,16 @@ def driver(request):
@pytest.fixture
def genres(db):
return mommy.make('testapp.Genre', _quantity=100)
from .testapp.models import Genre
return Genre.objects.bulk_create(
[Genre(pk=pk, title=random_string(50)) for pk in range(100)]
)
@pytest.fixture
def artists(db):
return mommy.make('testapp.Artist', _quantity=100)
from .testapp.models import Artist
return Artist.objects.bulk_create(
[Artist(pk=pk, title=random_string(50)) for pk in range(100)]
)
+93 -5
View File
@@ -9,7 +9,6 @@ from django.core import signing
from django.core.urlresolvers import reverse
from django.db.models import QuerySet
from django.utils.encoding import force_text
from model_mommy import mommy
from selenium.common.exceptions import NoSuchElementException
from six import text_type
@@ -30,6 +29,11 @@ class TestSelect2Mixin(object):
form = forms.AlbumSelect2WidgetForm()
widget_cls = Select2Widget
def test_initial_data(self, genres):
genre = genres[0]
form = self.form.__class__(initial={'primary_genre': genre.pk})
assert text_type(genre) in form.as_p()
def test_initial_form_class(self):
widget = self.widget_cls(attrs={'class': 'my-class'})
assert 'my-class' in widget.render('name', None)
@@ -79,13 +83,38 @@ class TestSelect2Mixin(object):
assert widget.get_url() == '/foo/bar'
class TestSelect2MixinSettings(object):
def test_default_media(self):
sut = Select2Widget()
result = sut.media.render()
assert '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/js/select2.min.js' in result
assert '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/css/select2.min.css' in result
assert 'django_select2/django_select2.js' in result
def test_js_setting(self, settings):
settings.SELECT2_JS = 'alternate.js'
sut = Select2Widget()
result = sut.media.render()
assert 'alternate.js' in result
assert 'django_select2/django_select2.js' in result
def test_css_setting(self, settings):
settings.SELECT2_CSS = 'alternate.css'
sut = Select2Widget()
result = sut.media.render()
assert 'alternate.css' in result
class TestHeavySelect2Mixin(TestSelect2Mixin):
url = reverse('heavy_select2_widget')
form = forms.HeavySelect2WidgetForm(initial={'primary_genre': [1]})
form = forms.HeavySelect2WidgetForm(initial={'primary_genre': 1})
widget_cls = HeavySelect2Widget
def test_initial_data(self):
assert 'One' in self.form.as_p()
def test_initial_form_class(self):
widget = self.widget_cls(data_view='heavy_data', attrs={'class': 'my-class'})
widget = self.widget_cls(data_view='heavy_data_1', attrs={'class': 'my-class'})
assert 'my-class' in widget.render('name', None)
assert 'django-select2' in widget.render('name', None)
assert 'django-select2-heavy' in widget.render('name', None), widget.render('name', None)
@@ -108,13 +137,44 @@ class TestHeavySelect2Mixin(TestSelect2Mixin):
assert selected_option in widget_output, widget_output
assert selected_option2 in widget_output
def test_multiple_widgets(self, db, live_server, driver):
driver.get(live_server + self.url)
with pytest.raises(NoSuchElementException):
driver.find_element_by_css_selector('.select2-results')
elem1, elem2 = driver.find_elements_by_css_selector('.select2-selection')
elem1.click()
result1 = driver.find_element_by_css_selector('.select2-results li:first-child').text
elem2.click()
result2 = driver.find_element_by_css_selector('.select2-results li:first-child').text
assert result1 != result2
with pytest.raises(NoSuchElementException):
error = driver.find_element_by_xpath('//body[@JSError]')
pytest.fail(error.get_attribute('JSError'))
class TestModelSelect2Mixin(TestHeavySelect2Mixin):
form = forms.AlbumModelSelect2WidgetForm(initial={'primary_genre': 1})
def test_initial_data(self, genres):
genre = genres[0]
form = self.form.__class__(initial={'primary_genre': genre.pk})
assert text_type(genre) in form.as_p()
def test_label_from_instance_initial(self, genres):
genre = genres[0]
genre.title = genre.title.lower()
genre.save()
form = self.form.__class__(initial={'primary_genre': genre.pk})
assert genre.title not in form.as_p()
assert genre.title.upper() in form.as_p()
@pytest.fixture(autouse=True)
def genres(self, db):
return mommy.make(Genre, 100)
def genres(self, genres):
return genres
def test_selected_option(self, db, genres):
genre = genres[0]
@@ -131,6 +191,23 @@ class TestModelSelect2Mixin(TestHeavySelect2Mixin):
assert selected_option in widget_output, widget_output
assert unselected_option not in widget_output
def test_selected_option_label_from_instance(self, db, genres):
genre = genres[0]
genre.title = genre.title.lower()
genre.save()
field = self.form.fields['primary_genre']
widget_output = field.widget.render('primary_genre', genre.pk)
def get_selected_option(genre):
return '<option value="{pk}" selected="selected">{value}</option>'.format(
pk=genre.pk, value=force_text(genre))
assert get_selected_option(genre) not in widget_output
genre.title = genre.title.upper()
assert get_selected_option(genre) in widget_output
def test_get_queryset(self):
widget = ModelSelect2Widget()
with pytest.raises(NotImplementedError):
@@ -154,6 +231,11 @@ class TestModelSelect2Mixin(TestHeavySelect2Mixin):
widget = TitleModelSelect2Widget(queryset=Genre.objects.all())
assert widget.filter_queryset(genres[0].title[:3]).exists()
widget = TitleModelSelect2Widget(search_fields=['title__icontains'],
queryset=Genre.objects.all())
qs = widget.filter_queryset(" ".join([genres[0].title[:3], genres[0].title[3:]]))
assert qs.exists()
def test_model_kwarg(self):
widget = ModelSelect2Widget(model=Genre, search_fields=['title__icontains'])
genre = Genre.objects.last()
@@ -196,3 +278,9 @@ class TestHeavySelect2TagWidget(TestHeavySelect2Mixin):
assert 'data-minimum-input-length="1"' in output
assert 'data-tags="true"' in output
assert 'data-token-separators' in output
def test_custom_tag_attrs(self):
widget = ModelSelect2TagWidget(
queryset=Genre.objects.all(), search_fields=['title__icontains'], attrs={'data-minimum-input-length': '3'})
output = widget.render('name', 'value')
assert 'data-minimum-input-length="3"' in output
+59 -1
View File
@@ -7,7 +7,13 @@ from django.core import signing
from django.core.urlresolvers import reverse
from django.utils.encoding import smart_text
from tests.testapp.forms import AlbumModelSelect2WidgetForm
from django_select2.cache import cache
from django_select2.forms import ModelSelect2Widget
from tests.testapp.forms import (
AlbumModelSelect2WidgetForm, ArtistCustomTitleWidget
)
from tests.testapp.models import Genre
from django_select2.conf import settings
class TestAutoResponseView(object):
@@ -41,3 +47,55 @@ class TestAutoResponseView(object):
url = reverse('django_select2-json')
response = client.get(url, {'field_id': field_id, 'term': artist.title})
assert response.status_code == 404
def test_pagination(self, genres, client):
url = reverse('django_select2-json')
widget = ModelSelect2Widget(
max_results=10,
model=Genre,
search_fields=['title__icontains']
)
widget.render('name', None)
field_id = signing.dumps(id(widget))
response = client.get(url, {'field_id': field_id, 'term': ''})
assert response.status_code == 200
data = json.loads(response.content.decode('utf-8'))
assert data['more'] is True
response = client.get(url, {'field_id': field_id, 'term': '', 'page': 1000})
assert response.status_code == 404
response = client.get(url, {'field_id': field_id, 'term': '', 'page': 'last'})
assert response.status_code == 200
data = json.loads(response.content.decode('utf-8'))
assert data['more'] is False
def test_label_from_instance(self, artists, client):
url = reverse('django_select2-json')
form = AlbumModelSelect2WidgetForm()
form.fields['artist'].widget = ArtistCustomTitleWidget()
assert form.as_p()
field_id = signing.dumps(id(form.fields['artist'].widget))
artist = artists[0]
response = client.get(url, {'field_id': field_id, 'term': artist.title})
assert response.status_code == 200
data = json.loads(response.content.decode('utf-8'))
assert data['results']
assert {'id': artist.pk, 'text': smart_text(artist.title.upper())} in data['results']
def test_url_check(self, client, artists):
artist = artists[0]
form = AlbumModelSelect2WidgetForm()
assert form.as_p()
field_id = signing.dumps(id(form.fields['artist'].widget))
cache_key = form.fields['artist'].widget._get_cache_key()
widget_dict = cache.get(cache_key)
widget_dict['url'] = 'yet/another/url'
cache.set(cache_key, widget_dict)
url = reverse('django_select2-json')
response = client.get(url, {'field_id': field_id, 'term': artist.title})
assert response.status_code == 404
+34 -13
View File
@@ -2,6 +2,7 @@
from __future__ import absolute_import, unicode_literals
from django import forms
from django.utils.encoding import force_text
from django_select2.forms import (
HeavySelect2MultipleWidget, HeavySelect2Widget, ModelSelect2MultipleWidget,
@@ -34,6 +35,26 @@ class GenreSelect2TagWidget(TitleSearchFieldMixin, ModelSelect2TagWidget):
self.get_queryset().create(title=value)
class ArtistCustomTitleWidget(ModelSelect2Widget):
model = models.Artist
search_fields = [
'title__icontains'
]
def label_from_instance(self, obj):
return force_text(obj.title).upper()
class GenreCustomTitleWidget(ModelSelect2Widget):
model = models.Genre
search_fields = [
'title__icontains'
]
def label_from_instance(self, obj):
return force_text(obj.title).upper()
class AlbumSelect2WidgetForm(forms.ModelForm):
class Meta:
model = models.Album
@@ -69,16 +90,14 @@ class AlbumModelSelect2WidgetForm(forms.ModelForm):
'primary_genre',
)
widgets = {
'artist': ModelSelect2Widget(
model=models.Artist,
search_fields=['title__icontains']
),
'primary_genre': ModelSelect2Widget(
model=models.Genre,
search_fields=['title__icontains']
),
'artist': ArtistCustomTitleWidget(),
'primary_genre': GenreCustomTitleWidget(),
}
def __init__(self, *args, **kwargs):
super(AlbumModelSelect2WidgetForm, self).__init__(*args, **kwargs)
self.fields['primary_genre'].initial = 2
class AlbumModelSelect2MultipleWidgetRequiredForm(forms.ModelForm):
class Meta:
@@ -114,20 +133,22 @@ class Select2WidgetForm(forms.Form):
class HeavySelect2WidgetForm(forms.Form):
artist = forms.ChoiceField(
widget=HeavySelect2Widget(data_view='heavy_data', choices=NUMBER_CHOICES)
widget=HeavySelect2Widget(data_view='heavy_data_1'),
choices=NUMBER_CHOICES
)
primary_genre = forms.ChoiceField(
widget=HeavySelect2Widget(data_view='heavy_data', choices=NUMBER_CHOICES),
required=False
widget=HeavySelect2Widget(data_view='heavy_data_2'),
required=False,
choices=NUMBER_CHOICES
)
class HeavySelect2MultipleWidgetForm(forms.Form):
genres = forms.MultipleChoiceField(
widget=HeavySelect2MultipleWidget(data_view='heavy_data', choices=NUMBER_CHOICES)
widget=HeavySelect2MultipleWidget(data_view='heavy_data_1', choices=NUMBER_CHOICES)
)
featured_artists = forms.MultipleChoiceField(
widget=HeavySelect2MultipleWidget(data_view='heavy_data', choices=NUMBER_CHOICES),
widget=HeavySelect2MultipleWidget(data_view='heavy_data_2', choices=NUMBER_CHOICES),
required=False
)
+6 -6
View File
@@ -1,16 +1,15 @@
# -*- conding:utf-8 -*-
from __future__ import absolute_import, unicode_literals
from django.conf.urls import include, patterns, url
from django.conf.urls import include, url
from .forms import (
AlbumModelSelect2WidgetForm, HeavySelect2MultipleWidgetForm,
HeavySelect2WidgetForm, ModelSelect2TagWidgetForm, Select2WidgetForm
)
from .views import TemplateFormView, heavy_data
from .views import TemplateFormView, heavy_data_1, heavy_data_2
urlpatterns = patterns(
'',
urlpatterns = [
url(r'^select2_widget/$',
TemplateFormView.as_view(form_class=Select2WidgetForm), name='select2_widget'),
url(r'^heavy_select2_widget/$',
@@ -27,7 +26,8 @@ urlpatterns = patterns(
TemplateFormView.as_view(form_class=ModelSelect2TagWidgetForm),
name='model_select2_tag_widget'),
url(r'^heavy_data/$', heavy_data, name='heavy_data'),
url(r'^heavy_data_1/$', heavy_data_1, name='heavy_data_1'),
url(r'^heavy_data_2/$', heavy_data_2, name='heavy_data_2'),
url(r'^select2/', include('django_select2.urls')),
)
]
+7 -1
View File
@@ -11,7 +11,13 @@ class TemplateFormView(FormView):
template_name = 'form.html'
def heavy_data(request):
def heavy_data_1(request):
numbers = ['Zero', 'One', 'Two', 'Three', 'Four', 'Five']
results = [{'id': index, 'text': value} for (index, value) in enumerate(numbers)]
return HttpResponse(json.dumps({'err': 'nil', 'results': results}), content_type='application/json')
def heavy_data_2(request):
numbers = ['Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Fortytwo']
results = [{'id': index, 'text': value} for (index, value) in enumerate(numbers)]
return HttpResponse(json.dumps({'err': 'nil', 'results': results}), content_type='application/json')