Compare commits

...
52 Commits
Author SHA1 Message Date
Johannes Hoppe 99aed19308 Write changelog bump version to 5.8.6 2016-06-19 12:29:45 +02:00
Jonas Haag 74bf15ce48 Drop six requirement, use django.utils.six instead (#282)
* Drop six requirement, use django.utils.six instead

* Drop six requirement, use django.utils.six instead

* Make isort happy
2016-06-19 12:27:49 +02:00
Johannes Hoppe 57dfd53d55 Bump version number 2016-05-23 16:18:39 +02:00
Johannes Hoppe f943fe07f5 Fixes #256 -- Don't render empty option for multiselect (#275) 2016-05-23 16:17:31 +02:00
AppleGrew a6a12d7392 Doc link's domain name updated 2016-04-28 13:12:16 +05:30
Johannes Hoppe f579f4af35 Adds travis-ci auto deploy 2016-04-22 15:25:04 +02:00
Julian 0b3abb8f1f Fixes minor documentation typo 2016-04-03 13:20:53 +02:00
Jônatas Castro 01c8979100 Fixes #238 -- Changed Token Separators
When Django was parsing the separator attribute, it was comming with 'u' char from unicode.
So the trick was to put the value in a String.
Solved https://github.com/applegrew/django-select2/issues/238

Closes #268
2016-03-10 15:34:30 +01:00
Damir Arbula f6ee1f57db Add exception handling for cPickle.PicklingError to widget caching
Closes #265
2016-03-08 16:30:47 +01:00
Johannes Hoppe 894a251be3 Switch coverage badge to SVG 2016-03-01 10:16:11 +01:00
Damir Arbula 9ed8f58fa6 Fixes #260 -- Normalize choices keys and selected choices to strings 2016-03-01 10:08:41 +01:00
Johannnes Hoppe a8a4baf04d Fixes #261 -- Adds better hints if widget is not serialisable to django cache
Closes #263
2016-02-24 18:51:39 +01:00
Johannes Hoppe 5b0400dfaa Bump version number 2016-02-08 10:44:22 +01:00
Johannes Hoppe 7923eeb0ee Removes choices from render and render_options signature
Choices has been removed by @jpic in
https://github.com/django/django/commit/926e90132dc15d76bb8d16e2f9f1279566cac3c3
2016-02-08 10:13:10 +01:00
Johannes Hoppe f09f9e5249 Adds sphinx spelling to read the docs requirements 2016-02-04 12:09:18 +01:00
Johannes Hoppe 031ec6682c Fixes isort for future builds 2016-02-04 11:59:50 +01:00
Johannes Hoppe 4dacbdf283 enchant the docs -- adds spell checking 2016-02-04 11:48:59 +01:00
Johannes Hoppe bc9601b3d3 Refactor inheritance tree 2016-02-04 11:48:42 +01:00
Johannes Hoppe d57b726d34 Adds napoleon to march to google style signatures 2016-02-04 11:48:07 +01:00
Johannes Hoppe d25e733c85 Fixes #245 -- Fixes ModelSelect2TagWidget documentation
There where multiple typos and missing references

Closed #248
2016-02-04 10:59:42 +01:00
Johannes Hoppe 27091f5b49 Fixes #250 -- Adds link to ModelWidgets in quick start guide 2016-02-03 19:47:40 +01:00
Johannes Hoppe 8d48887f5c Fixes tests for python 2.7 2016-01-28 14:45:28 +01:00
Johannes Hoppe fa6a841746 Adds test for get_url type issue 2016-01-27 10:20:27 +01:00
Johannes Hoppe 0ee50d9866 Fixes pickel issue of lazy object 2016-01-27 10:01:19 +01:00
Johannes Hoppe 7d8908daab Fixes version number and QA issue 2016-01-26 17:55:56 +01:00
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
Johannes Hoppe a91e66bf9e Created v5.1.0 release 2015-10-01 14:09:41 +02:00
Johannes Hoppe 75c8c38e2d Impooved cacheing to reduce pickling issues
Not not the entire widget gets cached nor the entire queryset.
The query and gets pickeld separately.
2015-10-01 14:07:51 +02:00
Johannes Hoppe dc81c2b3b5 Added search term splitting 2015-10-01 13:05:52 +02:00
Johannes Hoppe 48ea7549a6 Freeze dev requirements to avoid travis error 2015-10-01 13:01:52 +02:00
29 changed files with 806 additions and 3312 deletions
+41 -25
View File
@@ -1,39 +1,55 @@
language: python
sudo: false
cache:
- pip
- apt
- pip
services:
- memcached
- memcached
python:
- "2.7"
- "3.3"
- "3.4"
- "pypy"
- "pypy3"
- '2.7'
- '3.4'
- '3.5'
addons:
apt:
packages:
- python3-enchant
- python2-enchant
- graphviz
env:
global:
- DISPLAY=:99.0
- DISPLAY=:99.0
matrix:
- DJANGO="Django<1.8,>=1.7"
- DJANGO="Django<1.9,>=1.8"
- DJANGO="-e git+https://github.com/django/django.git@master#egg=Django"
- 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
allow_failures:
- env: DJANGO="-e git+https://github.com/django/django.git@master#egg=Django"
- env: DJANGO="-e git+https://github.com/django/django.git@master#egg=Django"
install:
- pip install --upgrade pip
- pip install -e .
- pip install -r requirements_dev.txt
- if [[ $TRAVIS_PYTHON_VERSION == 2* ]] || [[ $TRAVIS_PYTHON_VERSION == pypy ]]; then pip install python-memcached; fi
- if [[ $TRAVIS_PYTHON_VERSION == 3* ]] || [[ $TRAVIS_PYTHON_VERSION == pypy3 ]]; then pip install python3-memcached; fi
- pip install $DJANGO
- pip install coveralls
- sh -e /etc/init.d/xvfb start
- pip install --upgrade pip
- pip install -r requirements_dev.txt
- if [[ $TRAVIS_PYTHON_VERSION == 2* ]] || [[ $TRAVIS_PYTHON_VERSION == pypy ]]; then
pip install python-memcached; fi
- if [[ $TRAVIS_PYTHON_VERSION == 3* ]] || [[ $TRAVIS_PYTHON_VERSION == pypy3 ]];
then pip install python3-memcached; fi
- pip install $DJANGO
- pip install coveralls
- sh -e /etc/init.d/xvfb start
script:
- isort --check-only --recursive --diff .
- flake8 --jobs=2 .
- pep257 django_select2
- coverage run --source=django_select2 runtests.py
- isort --check-only --recursive --diff .
- flake8 --jobs=2 .
- pep257 --explain --source --count django_select2
- "(cd docs; make spelling)"
- coverage run --source=django_select2 -m py.test
after_success:
- coveralls
- coveralls
deploy:
provider: pypi
user: codingjoe
password:
secure: fEP9K7y0ZF9fRvQEUN4kgPXQEZvi3Cx3ikUebG2UM/2uhcaUQm0+KpgZ2S+lvOTYcBnNgzPzFsVIZMcVcTxwIKuQDEMq9y2eop2hnisb9KXsIm9qPYSzOnRm74VuiOt4hNOZMe0qVBK2cO3vC9NDXuzdI8A0JynJhthfl4t+kFM=
on:
tags: true
distributions: sdist bdist_wheel
repo: applegrew/django-select2
+58
View File
@@ -1,6 +1,64 @@
Changelog Summary
=================
### v5.8.3
* Remove six dependency -- User django.utils.six instead.
### v5.8.2
* Fixes #260 -- Fixes bug in render choices
### v5.8.0
* Changed signature of `render` and `render_choices` to satisfy Django 1.10 changes.
* Changed widgets' inheritance tree to be more consistent.
### v5.7.1
* Fixes pickle bug of lazy object
### 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
### v5.0.0
Version 5 is a complete rewrite of the package to drastically reduce
the code base and to ensure a future maintainability.
+3
View File
@@ -3,3 +3,6 @@ recursive-include django_select2/static *.*
recursive-exclude django_select2/static .DS_Store
exclude requirements_dev.txt
prune tests
[bdist_wheel]
universal=1
+2 -2
View File
@@ -3,7 +3,7 @@ Django-Select2
[![PyPi Version](https://img.shields.io/pypi/v/Django-Select2.svg)](https://pypi.python.org/pypi/Django-Select2/)
[![Build Status](https://travis-ci.org/applegrew/django-select2.svg?branch=master)](https://travis-ci.org/applegrew/django-select2)
[![Test Coverage](https://coveralls.io/repos/applegrew/django-select2/badge.png?branch=master)](https://coveralls.io/r/applegrew/django-select2)
[![Test Coverage](https://coveralls.io/repos/applegrew/django-select2/badge.svg?branch=master)](https://coveralls.io/r/applegrew/django-select2)
[![GitHub license](https://img.shields.io/badge/license-APL2-blue.svg)](https://raw.githubusercontent.com/applegrew/django-select2/master/LICENSE.txt)
[![Join the chat at https://gitter.im/applegrew/django-select2](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/applegrew/django-select2?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
@@ -54,7 +54,7 @@ Major changes:
## Documentation
Documentation available at http://django-select2.readthedocs.org/.
Documentation available at http://django-select2.readthedocs.io/.
## External Dependencies
+1 -1
View File
@@ -9,4 +9,4 @@ The app includes Select2 driven Django Widgets and Form Fields.
"""
__version__ = "5.0.3"
__version__ = "5.8.6"
+2 -2
View File
@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
"""
Shared memory across multiple machines to the heavy ajax lookups.
Shared memory across multiple machines to the heavy AJAX lookups.
Select2 uses django.core.cache_ to share fields across
multiple threads and even machines.
Select2 uses the cabhe backend defind in the setting
Select2 uses the cache backend defined in the setting
``SELECT2_CACHE_BACKEND`` [default=``default``].
It is advised to always setup a separate cache server for Select2.
+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'
+165 -81
View File
@@ -49,20 +49,22 @@ 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 pickle import PicklingError
from django import forms
from django.core import signing
from django.core.urlresolvers import reverse_lazy
from django.core.urlresolvers import reverse
from django.db.models import Q
from django.forms.models import ModelChoiceIterator
from django.utils.encoding import force_text
from django.utils.six.moves.cPickle import PicklingError as cPicklingError
from .cache import cache
from .conf import settings
class Select2Mixin(object):
"""
The base mixin of all Select2 widgets.
@@ -87,10 +89,10 @@ class Select2Mixin(object):
attrs['class'] = 'django-select2'
return attrs
def render_options(self, choices, selected_choices):
def render_options(self, *args, **kwargs):
"""Render options including an empty one, if the field is not required."""
output = '<option></option>' if not self.is_required else ''
output += super(Select2Mixin, self).render_options(choices, selected_choices)
output = '<option></option>' if not self.is_required and not self.allow_multiple_selected else ''
output += super(Select2Mixin, self).render_options(*args, **kwargs)
return output
def _get_media(self):
@@ -101,16 +103,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 +146,6 @@ class Select2Widget(Select2Mixin, forms.Select):
class Select2MultipleWidget(Select2Mixin, forms.SelectMultiple):
"""
Select2 drop in widget for multiple select.
@@ -145,19 +155,34 @@ class Select2MultipleWidget(Select2Mixin, forms.SelectMultiple):
pass
class HeavySelect2Mixin(Select2Mixin):
class Select2TagWidget(Select2TagMixin, Select2Mixin, forms.SelectMultiple):
"""
Select2 drop in widget for for tagging.
"""Mixin that adds select2's ajax options and registers itself on django's cache."""
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(object):
"""Mixin that adds select2's AJAX options and registers itself on Django's cache."""
def __init__(self, **kwargs):
"""
Return HeavySelect2Mixin.
:param data_view: url pattern name
:type data_view: str
:param data_url: url
:type data_url: str
:return:
Args:
data_view (str): URL pattern name
data_url (str): URL
"""
self.data_view = kwargs.pop('data_view', None)
self.data_url = kwargs.pop('data_url', None)
@@ -167,13 +192,13 @@ class HeavySelect2Mixin(Select2Mixin):
super(HeavySelect2Mixin, self).__init__(**kwargs)
def get_url(self):
"""Return url from instance or by reversing :attr:`.data_view`."""
"""Return URL from instance or by reversing :attr:`.data_view`."""
if self.data_url:
return self.data_url
return reverse_lazy(self.data_view)
return reverse(self.data_view)
def build_attrs(self, extra_attrs=None, **kwargs):
"""Set select2's ajax attributes."""
"""Set select2's AJAX attributes."""
attrs = super(HeavySelect2Mixin, self).build_attrs(extra_attrs=extra_attrs, **kwargs)
# encrypt instance Id
@@ -188,9 +213,9 @@ class HeavySelect2Mixin(Select2Mixin):
attrs['class'] += ' django-select2-heavy'
return attrs
def render(self, name, value, attrs=None, choices=()):
def render(self, *args, **kwargs):
"""Render widget and register it in Django's cache."""
output = super(HeavySelect2Mixin, self).render(name, value, attrs=attrs, choices=choices)
output = super(HeavySelect2Mixin, self).render(*args, **kwargs)
self.set_to_cache()
return output
@@ -198,34 +223,52 @@ class HeavySelect2Mixin(Select2Mixin):
return "%s%s" % (settings.SELECT2_CACHE_PREFIX, id(self))
def set_to_cache(self):
"""Add widget object to Djnago's cache."""
cache.set(self._get_cache_key(), self)
"""
Add widget object to Django's cache.
def render_options(self, choices, selected_choices):
You may need to overwrite this method, to pickle all information
that is required to serve your JSON response view.
"""
try:
cache.set(self._get_cache_key(), {
'widget': self,
'url': self.get_url(),
})
except (PicklingError, cPicklingError, AttributeError):
msg = "You need to overwrite \"set_to_cache\" or ensure that %s is serialisable."
raise NotImplementedError(msg % self.__class__.__name__)
def render_options(self, *args):
"""Render only selected options."""
output = ['<option></option>' if not self.is_required else '']
choices = {(k, v) for k, v in choices if k in selected_choices}
try:
selected_choices, = args
except ValueError: # Signature contained `choices` prior to Django 1.10
choices, selected_choices = args
choices = chain(self.choices, choices)
else:
choices = self.choices
output = ['<option></option>' if not self.is_required and not self.allow_multiple_selected else '']
selected_choices = {force_text(v) for v in selected_choices}
choices = {(k, v) for k, v in choices if force_text(k) 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)
class HeavySelect2Widget(HeavySelect2Mixin, forms.Select):
class HeavySelect2Widget(HeavySelect2Mixin, Select2Widget):
"""
Select2 widget with AJAX support that registers itself to Django's Cache.
Usage example::
class MyWidget(HeavySelectWidget):
class MyWidget(HeavySelect2Widget):
data_view = 'my_view_name'
or::
class MyForm(forms.Form):
my_field = forms.ChoicesField(
widget=HeavySelectWidget(
widget=HeavySelect2Widget(
data_url='/url/to/json/response'
)
)
@@ -235,38 +278,29 @@ class HeavySelect2Widget(HeavySelect2Mixin, forms.Select):
pass
class HeavySelect2MultipleWidget(HeavySelect2Mixin, forms.SelectMultiple):
class HeavySelect2MultipleWidget(HeavySelect2Mixin, Select2MultipleWidget):
"""Select2 multi select widget similar to :class:`.HeavySelect2Widget`."""
pass
class HeavySelect2TagWidget(HeavySelect2MultipleWidget):
class HeavySelect2TagWidget(HeavySelect2Mixin, Select2TagWidget):
"""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
queryset = None
search_fields = []
"""
Model lookups that are used to filter the queryset.
Model lookups that are used to filter the QuerySet.
Example::
@@ -283,14 +317,12 @@ class ModelSelect2Mixin(object):
"""
Overwrite class parameters if passed as keyword arguments.
:param model: model to select choices from
:type model: django.db.models.Model
:param queryset: queryset to select choices from
:type queryset: django.db.models.query.QuerySet
:param search_fields: list of model lookup strings
:type search_fields: list
:param max_results: max. JsonResponse view page size
:type max_results: int
Args:
model (django.db.models.Model): Model to select choices from.
queryset (django.db.models.QuerySet): QuerySet to select choices from.
search_fields (list): List of model lookup strings.
max_results (int): Max. JsonResponse view page size.
"""
self.model = kwargs.pop('model', self.model)
self.queryset = kwargs.pop('queryset', self.queryset)
@@ -300,27 +332,54 @@ class ModelSelect2Mixin(object):
defaults.update(kwargs)
super(ModelSelect2Mixin, self).__init__(*args, **defaults)
def filter_queryset(self, term):
def set_to_cache(self):
"""
Return queryset filtered by search_fields matching the passed term.
Add widget's attributes to Django's cache.
:param term: Search term
:type term: str
:return: Filtered queryset
:rtype: :class:`.django.db.models.QuerySet`
Split the QuerySet, to not pickle the result set.
"""
qs = self.get_queryset()
queryset = self.get_queryset()
cache.set(self._get_cache_key(), {
'queryset':
[
queryset.none(),
queryset.query,
],
'cls': self.__class__,
'search_fields': self.search_fields,
'max_results': self.max_results,
'url': self.get_url(),
})
def filter_queryset(self, term, queryset=None):
"""
Return QuerySet filtered by search_fields matching the passed term.
Args:
term (str): Search term
Returns:
QuerySet: Filtered QuerySet
"""
if queryset is None:
queryset = self.get_queryset()
search_fields = self.get_search_fields()
select = reduce(lambda x, y: x | Q(**{y: term}), search_fields,
Q(**{search_fields.pop(): term}))
return qs.filter(select).distinct()
select = Q()
term = term.replace('\t', ' ')
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[0]: t}))
return queryset.filter(select).distinct()
def get_queryset(self):
"""
Return queryset based on :attr:`.queryset` or :attr:`.model`.
Return QuerySet based on :attr:`.queryset` or :attr:`.model`.
Returns:
QuerySet: QuerySet of available choices.
:return: queryset of available choices
:rtype: :class:`.django.db.models.QuerySet`
"""
if self.queryset is not None:
queryset = self.queryset
@@ -342,26 +401,53 @@ class ModelSelect2Mixin(object):
return self.search_fields
raise NotImplementedError('%s, must implement "search_fields".' % self.__class__.__name__)
def render_options(self, choices, selected_choices):
"""Render only selected options and set queryset from :class:`ModelChoicesIterator`."""
output = ['<option></option>' if not self.is_required else '']
def render_options(self, *args):
"""Render only selected options and set QuerySet from :class:`ModelChoicesIterator`."""
try:
selected_choices, = args
except ValueError:
choices, selected_choices = args
choices = chain(self.choices, choices)
else:
choices = self.choices
selected_choices = {force_text(v) for v in selected_choices}
output = ['<option></option>' if not self.is_required and not self.allow_multiple_selected else '']
if isinstance(self.choices, ModelChoiceIterator):
if not self.queryset:
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 = {(k, v) for k, v in choices if k in selected_choices}
selected_choices = {force_text(v) for v in selected_choices}
choices = {(k, v) for k, v in choices if force_text(k) 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()
Args:
obj (django.db.models.Model): Instance of Django Model.
Returns:
str: Option label.
"""
return force_text(obj)
class ModelSelect2Widget(ModelSelect2Mixin, HeavySelect2Widget):
"""
Select2 drop in model select widget.
@@ -369,7 +455,7 @@ class ModelSelect2Widget(ModelSelect2Mixin, HeavySelect2Widget):
class MyWidget(ModelSelect2Widget):
search_fields = [
'title__icontians',
'title__icontains',
]
class MyModelForm(forms.ModelForm):
@@ -391,8 +477,8 @@ class ModelSelect2Widget(ModelSelect2Mixin, HeavySelect2Widget):
)
.. tip:: The ModelSelect2(Multiple)Widget will try
to get the queryset from the fields choices.
Therefore you don't need to define a queryset,
to get the QuerySet from the fields choices.
Therefore you don't need to define a QuerySet,
if you just drop in the widget for a ForeignKey field.
"""
@@ -400,7 +486,6 @@ class ModelSelect2Widget(ModelSelect2Mixin, HeavySelect2Widget):
class ModelSelect2MultipleWidget(ModelSelect2Mixin, HeavySelect2MultipleWidget):
"""
Select2 drop in model multiple select widget.
@@ -411,13 +496,12 @@ class ModelSelect2MultipleWidget(ModelSelect2Mixin, HeavySelect2MultipleWidget):
class ModelSelect2TagWidget(ModelSelect2Mixin, HeavySelect2TagWidget):
"""
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`
that adds missing tags to you queryset.
that adds missing tags to you QuerySet.
Example::
@@ -425,7 +509,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));
+5 -6
View File
@@ -1,20 +1,19 @@
# -*- coding: utf-8 -*-
"""
Django-Select2 url config.
Django-Select2 URL configuration.
Add `django_select` to your urlconf **if** you use any 'Model' fields::
Add `django_select` to your ``urlconf`` **if** you use any 'Model' fields::
url(r'^select2/', include('django_select2.urls')),
"""
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"),
)
]
+20 -12
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,16 +42,17 @@ 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):
"""Get queryset from cached widget."""
return self.widget.filter_queryset(self.term)
"""Get QuerySet from cached widget."""
return self.widget.filter_queryset(self.term, self.queryset)
def get_paginate_by(self, queryset):
"""Paginate response by size of widget's `max_results` parameter."""
@@ -62,10 +62,12 @@ class AutoResponseView(BaseListView):
"""
Get and return widget from cache.
Raises a 404 if the widget can not be found or no id is provided.
Raises:
Http404: If if the widget can not be found or no id is provided.
Returns:
ModelSelect2Mixin: Widget from cache.
:raises: Http404
:return: ModelSelect2Mixin
"""
field_id = self.kwargs.get('field_id', self.request.GET.get('field_id', None))
if not field_id:
@@ -76,7 +78,13 @@ class AutoResponseView(BaseListView):
raise Http404('Invalid "field_id".')
else:
cache_key = '%s%s' % (settings.SELECT2_CACHE_PREFIX, key)
widget = cache.get(cache_key)
if widget is None:
widget_dict = cache.get(cache_key)
if widget_dict is None:
raise Http404('field_id not found')
return widget
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
widget_cls = widget_dict.pop('cls')
return widget_cls(**widget_dict)
+5
View File
@@ -46,6 +46,11 @@ html:
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
spelling:
$(SPHINXBUILD) -b spelling -W $(ALLSPHINXOPTS) $(BUILDDIR)/spelling
@echo
@echo "Spell check finished. The results are in $(BUILDDIR)/spelling."
dirhtml:
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
@echo
+13 -1
View File
@@ -41,7 +41,14 @@ sys.path.insert(0, os.path.abspath('..'))
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.inheritance_diagram', 'sphinx.ext.intersphinx', 'sphinx.ext.viewcode']
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.napoleon',
'sphinx.ext.inheritance_diagram',
'sphinx.ext.intersphinx',
'sphinx.ext.viewcode',
'sphinxcontrib.spelling',
]
intersphinx_mapping = {
'python': ('http://docs.python.org/3.5', None),
@@ -49,6 +56,11 @@ intersphinx_mapping = {
'https://docs.djangoproject.com/en/dev/_objects/'),
}
# spell check
spelling_word_list_filename = 'spelling_wordlist.txt'
spelling_show_suggestions = True
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
+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']
+3 -1
View File
@@ -1 +1,3 @@
Django >= 1.4.1
Django >= 1.8
sphinxcontrib-spelling
pyenchant
+2 -1
View File
@@ -18,7 +18,8 @@ Installation
2. Add ``django_select2`` to your ``INSTALLED_APPS`` in your project settings.
3. Add ``django_select`` to your urlconf **if** you use any 'Auto' fields::
3. Add ``django_select`` to your ``urlconf`` **if** you use any
:class:`ModelWidgets <.django_select2.forms.ModelSelect2Mixin>`::
url(r'^select2/', include('django_select2.urls')),
+17
View File
@@ -0,0 +1,17 @@
jQuery
Django
mixin
backend
redis
memcached
AJAX
Cloudflare
lookup
QuerySet
pre
py
lookups
functionalities
plugin
multi
Indices
+12
View File
@@ -0,0 +1,12 @@
django-appconf
flake8
pep8-naming
mccabe
isort
pep257
pytest
pytest-django
selenium
sphinx
sphinxcontrib-spelling
pyenchant
+31 -9
View File
@@ -1,9 +1,31 @@
pytest
pytest-django
pep257
selenium
model_mommy
isort
requests
flake8
pep8-naming
#
# This file is autogenerated by pip-compile
# Make changes in requirements_dev.in, then run this to update:
#
# pip-compile requirements_dev.in
#
alabaster==0.7.7 # via sphinx
babel==2.2.0 # via sphinx
django-appconf==1.0.1
docutils==0.12 # via sphinx
flake8==2.5.2
isort==4.2.2
Jinja2==2.8
MarkupSafe==0.23
mccabe==0.4.0
pep257==0.7.0
pep8-naming==0.3.3
pep8==1.7.0 # via flake8
py==1.4.31 # via pytest
pyenchant==1.6.6
pyflakes==1.0.0 # via flake8
Pygments==2.1
pytest-django==2.9.1
pytest==2.8.7
pytz==2015.7 # via babel
selenium==2.50.1
six==1.10.0 # via django-appconf, sphinx, sphinxcontrib-spelling
snowballstemmer==1.2.1 # via sphinx
sphinx-rtd-theme==0.1.9 # via sphinx
sphinx==1.3.5
sphinxcontrib-spelling==2.1.2
-3086
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -11,13 +11,12 @@ show-source = true
exclude = docs,runtests.py,setup.py,env
[pep257]
explain = true
count = true
[isort]
atomic = true
multi_line_output = 5
line_length = 79
skip = manage.py,docs
known_first_party = django_select2
known_first_party = django_select2, tests
known_third_party = django
combine_as_imports = 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)]
)
+167 -20
View File
@@ -9,26 +9,32 @@ 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 django.utils.six import text_type
from selenium.common.exceptions import NoSuchElementException
from six import text_type
from django_select2.cache import cache
from django_select2.forms import (
HeavySelect2Widget, ModelSelect2TagWidget, ModelSelect2Widget,
Select2Widget
HeavySelect2MultipleWidget, HeavySelect2Widget, ModelSelect2TagWidget,
ModelSelect2Widget, Select2Widget
)
from tests.testapp import forms
from tests.testapp.forms import NUMBER_CHOICES, HeavySelect2MultipleWidgetForm, \
TitleModelSelect2Widget
from tests.testapp.forms import (
NUMBER_CHOICES, HeavySelect2MultipleWidgetForm, TitleModelSelect2Widget
)
from tests.testapp.models import Genre
class TestSelect2Mixin(object):
url = reverse('select2_widget')
form = forms.AlbumSelect2WidgetForm()
multiple_form = forms.AlbumSelect2MultipleWidgetForm()
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)
@@ -77,14 +83,51 @@ class TestSelect2Mixin(object):
widget = HeavySelect2Widget(data_url='/foo/bar')
assert widget.get_url() == '/foo/bar'
def test_empty_option(self, db):
# Empty options is only required for single selects
# https://select2.github.io/options.html#allowClear
single_select = self.form.fields['primary_genre']
assert single_select.required is False
assert '<option></option>' in single_select.widget.render('primary_genre', None)
multiple_select = self.multiple_form.fields['featured_artists']
assert multiple_select.required is False
assert multiple_select.widget.allow_multiple_selected
assert '<option></option>' not in multiple_select.widget.render('featured_artists', None)
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)
@@ -93,27 +136,72 @@ class TestHeavySelect2Mixin(TestSelect2Mixin):
not_required_field = self.form.fields['primary_genre']
assert not_required_field.required is False
assert '<option value="1" selected="selected">One</option>' in \
not_required_field.widget.render('primary_genre', 1, choices=NUMBER_CHOICES), \
not_required_field.widget.render('primary_genre', 1, choices=NUMBER_CHOICES)
not_required_field.widget.render('primary_genre', 1), \
not_required_field.widget.render('primary_genre', 1)
def test_many_selected_option(self, db, genres):
field = HeavySelect2MultipleWidgetForm().fields['genres']
widget_output = field.widget.render(
'genres', [1, 2],
choices=NUMBER_CHOICES)
field.widget.choices = NUMBER_CHOICES
widget_output = field.widget.render('genres', [1, 2])
selected_option = '<option value="{pk}" selected="selected">{value}</option>'.format(pk=1, value='One')
selected_option2 = '<option value="{pk}" selected="selected">{value}</option>'.format(pk=2, value='Two')
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'))
def test_get_url(self):
widget = self.widget_cls(data_view='heavy_data_1', attrs={'class': 'my-class'})
assert isinstance(widget.get_url(), text_type)
def test_can_not_pickle(self):
widget = self.widget_cls(data_view='heavy_data_1', attrs={'class': 'my-class'})
class NoPickle(object):
pass
widget.no_pickle = NoPickle()
with pytest.raises(NotImplementedError):
widget.set_to_cache()
class TestModelSelect2Mixin(TestHeavySelect2Mixin):
form = forms.AlbumModelSelect2WidgetForm(initial={'primary_genre': 1})
multiple_form = forms.ArtistModelSelect2MultipleWidgetForm()
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]
@@ -130,6 +218,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):
@@ -153,6 +258,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()
@@ -160,13 +270,13 @@ class TestModelSelect2Mixin(TestHeavySelect2Mixin):
assert result.exists()
def test_queryset_kwarg(self):
widget = ModelSelect2Widget(queryset=Genre.objects, search_fields=['title__icontains'])
widget = ModelSelect2Widget(queryset=Genre.objects.all(), search_fields=['title__icontains'])
genre = Genre.objects.last()
result = widget.filter_queryset(genre.title)
assert result.exists()
def test_ajax_view_registration(self, client):
widget = ModelSelect2Widget(queryset=Genre.objects, search_fields=['title__icontains'])
widget = ModelSelect2Widget(queryset=Genre.objects.all(), search_fields=['title__icontains'])
widget.render('name', 'value')
url = reverse('django_select2-json')
genre = Genre.objects.last()
@@ -177,17 +287,54 @@ class TestModelSelect2Mixin(TestHeavySelect2Mixin):
assert genre.pk in [result['id'] for result in data['results']]
def test_render(self):
widget = ModelSelect2Widget()
widget = ModelSelect2Widget(queryset=Genre.objects.all())
widget.render('name', 'value')
cached_widget = cache.get(widget._get_cache_key())
assert isinstance(cached_widget, ModelSelect2Widget)
assert cached_widget['max_results'] == widget.max_results
assert cached_widget['search_fields'] == widget.search_fields
qs = widget.get_queryset()
assert isinstance(cached_widget['queryset'][0], qs.__class__)
assert text_type(cached_widget['queryset'][1]) == text_type(qs.query)
def test_get_url(self):
widget = ModelSelect2Widget(queryset=Genre.objects.all(), search_fields=['title__icontains'])
assert isinstance(widget.get_url(), text_type)
class TestHeavySelect2TagWidget(TestHeavySelect2Mixin):
def test_tag_attrs(self):
widget = ModelSelect2TagWidget(queryset=Genre.objects, search_fields=['title__icontains'])
widget = ModelSelect2TagWidget(queryset=Genre.objects.all(), search_fields=['title__icontains'])
output = widget.render('name', 'value')
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
class TestHeavySelect2MultipleWidget(object):
url = reverse('heavy_select2_multiple_widget')
form = forms.HeavySelect2MultipleWidgetForm()
widget_cls = HeavySelect2MultipleWidget
def test_widgets_selected_after_validation_error(self, db, live_server, driver):
driver.get(live_server + self.url)
title = driver.find_element_by_id('id_title')
title.send_keys('fo')
genres, fartists = driver.find_elements_by_css_selector('.select2-selection--multiple')
genres.click()
genres.send_keys('o') # results are Zero One Two Four
# select second element - One
driver.find_element_by_css_selector('.select2-results li:nth-child(2)').click()
genres.submit()
# there is a ValidationError raised, check for it
errstring = driver.find_element_by_css_selector('ul.errorlist li').text
assert errstring == "Title must have more than 3 characters."
# 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'
+58 -1
View File
@@ -7,7 +7,12 @@ 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
class TestAutoResponseView(object):
@@ -41,3 +46,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
+48 -14
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:
@@ -98,7 +117,12 @@ class ArtistModelSelect2MultipleWidgetForm(forms.Form):
genres = forms.ModelMultipleChoiceField(widget=ModelSelect2MultipleWidget(
queryset=models.Genre.objects.all(),
search_fields=['title__icontains'],
), queryset=models.Genre.objects.all(), required=False)
), queryset=models.Genre.objects.all(), required=True)
featured_artists = forms.ModelMultipleChoiceField(widget=ModelSelect2MultipleWidget(
queryset=models.Artist.objects.all(),
search_fields=['title__icontains'],
), queryset=models.Artist.objects.all(), required=False)
NUMBER_CHOICES = [
(1, 'One'),
@@ -114,23 +138,33 @@ 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):
title = forms.CharField(max_length=50)
genres = forms.MultipleChoiceField(
widget=HeavySelect2MultipleWidget(data_view='heavy_data', choices=NUMBER_CHOICES)
widget=HeavySelect2MultipleWidget(data_view='heavy_data_1', choices=NUMBER_CHOICES),
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),
choices=NUMBER_CHOICES,
required=False
)
def clean_title(self):
if len(self.cleaned_data['title']) < 3:
raise forms.ValidationError("Title must have more than 3 characters.")
return self.cleaned_data["title"]
class ModelSelect2TagWidgetForm(forms.ModelForm):
class Meta:
+6 -3
View File
@@ -33,9 +33,12 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
SITE_ID = 1
ROOT_URLCONF = 'tests.testapp.urls'
TEMPLATE_DIRS = (
os.path.join(BASE_DIR, "templates"),
)
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'APP_DIRS': True,
},
]
SECRET_KEY = '123456'
+7 -7
View File
@@ -1,22 +1,21 @@
# -*- 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/$',
TemplateFormView.as_view(form_class=HeavySelect2WidgetForm), name='heavy_select2_widget'),
url(r'^heavy_select2_multiple_widget/$',
TemplateFormView.as_view(form_class=HeavySelect2MultipleWidgetForm),
TemplateFormView.as_view(form_class=HeavySelect2MultipleWidgetForm, success_url='/'),
name='heavy_select2_multiple_widget'),
url(r'^model_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 -1
View File
@@ -11,7 +11,17 @@ class TemplateFormView(FormView):
template_name = 'form.html'
def heavy_data(request):
def heavy_data_1(request):
term = request.GET.get("term", "")
numbers = ['Zero', 'One', 'Two', 'Three', 'Four', 'Five']
numbers = filter(lambda num: term.lower() in num.lower(), numbers)
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):
term = request.GET.get("term", "")
numbers = ['Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Fortytwo']
numbers = filter(lambda num: term.lower() in num.lower(), numbers)
results = [{'id': index, 'text': value} for (index, value) in enumerate(numbers)]
return HttpResponse(json.dumps({'err': 'nil', 'results': results}), content_type='application/json')