Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a1b83ad1e | ||
|
|
33a00c1e39 | ||
|
|
89674ec375 | ||
|
|
e655ed7a59 | ||
|
|
3c06873148 | ||
|
|
84e1abaf4e | ||
|
|
6ebeb4c6d7 | ||
|
|
7d8a7e7a9f | ||
|
|
9ba75bb820 | ||
|
|
cfc7c2c541 | ||
|
|
6c5e2036ca | ||
|
|
4defb8112b | ||
|
|
bb90be77b7 | ||
|
|
18647161a1 | ||
|
|
3e6c1fc3ba | ||
|
|
19a8d63bb1 | ||
|
|
0457617738 | ||
|
|
974ba552b0 | ||
|
|
2beb5d23c7 | ||
|
|
b8d53aa714 | ||
|
|
b0d2325c43 | ||
|
|
9d1bcfee6a | ||
|
|
2c1eb8bc4a |
+4
-6
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,4 +9,4 @@ The app includes Select2 driven Django Widgets and Form Fields.
|
||||
|
||||
"""
|
||||
|
||||
__version__ = "5.1.0"
|
||||
__version__ = "5.6.0"
|
||||
|
||||
+26
-1
@@ -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
@@ -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));
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
django-appconf
|
||||
flake8
|
||||
pep8-naming
|
||||
mccabe
|
||||
isort
|
||||
pep257
|
||||
pytest
|
||||
pytest-django
|
||||
selenium
|
||||
+15
-15
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -11,8 +11,6 @@ show-source = true
|
||||
exclude = docs,runtests.py,setup.py,env
|
||||
|
||||
[pep257]
|
||||
explain = true
|
||||
count = true
|
||||
|
||||
[isort]
|
||||
atomic = true
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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')),
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user