Compare commits

...
11 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
18 changed files with 273 additions and 3175 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
+25
View File
@@ -1,6 +1,31 @@
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.
+1 -1
View File
@@ -9,4 +9,4 @@ The app includes Select2 driven Django Widgets and Form Fields.
"""
__version__ = "5.4.2"
__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'
+27 -18
View File
@@ -63,7 +63,6 @@ from .conf import settings
class Select2Mixin(object):
"""
The base mixin of all Select2 widgets.
@@ -102,16 +101,14 @@ 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 Select2TagMixin(object):
"""Mixin to add select2 tag functionality."""
def build_attrs(self, extra_attrs=None, **kwargs):
@@ -123,7 +120,6 @@ class Select2TagMixin(object):
class Select2Widget(Select2Mixin, forms.Select):
"""
Select2 drop in widget.
@@ -148,7 +144,6 @@ class Select2Widget(Select2Mixin, forms.Select):
class Select2MultipleWidget(Select2Mixin, forms.SelectMultiple):
"""
Select2 drop in widget for multiple select.
@@ -159,7 +154,6 @@ class Select2MultipleWidget(Select2Mixin, forms.SelectMultiple):
class Select2TagWidget(Select2TagMixin, Select2Mixin, forms.SelectMultiple):
"""
Select2 drop in widget for for tagging.
@@ -177,7 +171,6 @@ class Select2TagWidget(Select2TagMixin, Select2Mixin, forms.SelectMultiple):
class HeavySelect2Mixin(Select2Mixin):
"""Mixin that adds select2's ajax options and registers itself on django's cache."""
def __init__(self, **kwargs):
@@ -230,7 +223,10 @@ 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."""
@@ -244,7 +240,6 @@ class HeavySelect2Mixin(Select2Mixin):
class HeavySelect2Widget(HeavySelect2Mixin, forms.Select):
"""
Select2 widget with AJAX support that registers itself to Django's Cache.
@@ -268,14 +263,12 @@ class HeavySelect2Widget(HeavySelect2Mixin, forms.Select):
class HeavySelect2MultipleWidget(HeavySelect2Mixin, forms.SelectMultiple):
"""Select2 multi select widget similar to :class:`.HeavySelect2Widget`."""
pass
class HeavySelect2TagWidget(Select2TagMixin, HeavySelect2MultipleWidget):
"""Select2 tag widget."""
pass
@@ -285,7 +278,6 @@ class HeavySelect2TagWidget(Select2TagMixin, HeavySelect2MultipleWidget):
class ModelSelect2Mixin(object):
"""Widget mixin that provides attributes and methods for :class:`.AutoResponseView`."""
model = None
@@ -342,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):
@@ -399,7 +392,7 @@ 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)
@@ -409,9 +402,27 @@ class ModelSelect2Mixin(object):
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.
@@ -450,7 +461,6 @@ class ModelSelect2Widget(ModelSelect2Mixin, HeavySelect2Widget):
class ModelSelect2MultipleWidget(ModelSelect2Mixin, HeavySelect2MultipleWidget):
"""
Select2 drop in model multiple select widget.
@@ -461,7 +471,6 @@ class ModelSelect2MultipleWidget(ModelSelect2Mixin, HeavySelect2MultipleWidget):
class ModelSelect2TagWidget(Select2TagMixin, ModelSelect2MultipleWidget):
"""
Select2 model widget with tag support.
+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"),
)
]
+3 -3
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.
@@ -44,7 +42,7 @@ 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']
@@ -81,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
+26
View File
@@ -58,3 +58,29 @@ heavy fields. Simply call ``djangoSelect2(options)`` on your select fields.::
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)]
)
+50 -3
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
@@ -84,6 +83,28 @@ 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})
@@ -142,9 +163,18 @@ class TestModelSelect2Mixin(TestHeavySelect2Mixin):
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]
@@ -161,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):
+34 -1
View File
@@ -7,9 +7,13 @@ from django.core import signing
from django.core.urlresolvers import reverse
from django.utils.encoding import smart_text
from django_select2.cache import cache
from django_select2.forms import ModelSelect2Widget
from tests.testapp.forms import AlbumModelSelect2WidgetForm
from tests.testapp.forms import (
AlbumModelSelect2WidgetForm, ArtistCustomTitleWidget
)
from tests.testapp.models import Genre
from django_select2.conf import settings
class TestAutoResponseView(object):
@@ -66,3 +70,32 @@ class TestAutoResponseView(object):
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
+27 -8
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:
+3 -4
View File
@@ -1,7 +1,7 @@
# -*- 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,
@@ -9,8 +9,7 @@ from .forms import (
)
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/$',
@@ -31,4 +30,4 @@ urlpatterns = patterns(
url(r'^heavy_data_2/$', heavy_data_2, name='heavy_data_2'),
url(r'^select2/', include('django_select2.urls')),
)
]