Enhanced Heavy fields. Added 'auto' fields. Some bug fixes.

This commit is contained in:
AppleGrew (applegrew) 2012-08-05 13:00:44 +05:30
parent 243c614c3a
commit d5aa9b5ddb
9 changed files with 533 additions and 37 deletions

View file

@ -1,4 +1,14 @@
django-select2
Django-Select2
==============
This is a [Django](https://www.djangoproject.com/) integration for [Select2](http://ivaynberg.github.com/select2/)
This is a [Django](https://www.djangoproject.com/) integration of [Select2](http://ivaynberg.github.com/select2/).
The app includes Select2 driven Django Widgets and Form Fields.
More details can be found on my blog at - [http://blog.applegrew.com/2012/08/django-select2/](http://blog.applegrew.com/2012/08/django-select2/).
External dependencies
=====================
* Django - This is obvious.
* jQuery - This is not included in the package since it is expected that in most scenarios this would already be available.

View file

@ -1,3 +1,5 @@
from .widgets import Select2Widget, Select2MultipleWidget, HeavySelect2Widget, HeavySelect2MultipleWidget
from .fields import Select2ChoiceField, Select2MultipleChoiceField, HeavySelect2ChoiceField, HeavySelect2MultipleChoiceField
from .views import Select2View
from .widgets import Select2Widget, Select2MultipleWidget, HeavySelect2Widget, HeavySelect2MultipleWidget, AutoHeavySelect2Widget
from .fields import Select2ChoiceField, Select2MultipleChoiceField, \
HeavySelect2ChoiceField, HeavySelect2MultipleChoiceField, \
ModelSelect2Field, AutoSelect2Field, AutoModelSelect2Field
from .views import Select2View, NO_ERR_RESP

View file

@ -1,6 +1,100 @@
from django import forms
class AutoViewFieldMixin(object):
"""Registers itself with AutoResponseView."""
def __init__(self, *args, **kwargs):
name = self.__class__.__name__
from .util import register_field
if name not in ['AutoViewFieldMixin', 'AutoModelSelect2Field']:
id_ = register_field("%s.%s" % (self.__module__, name), self)
self.widget.field_id = id_
super(AutoViewFieldMixin, self).__init__(*args, **kwargs)
from .widgets import Select2Widget, Select2MultipleWidget, HeavySelect2Widget, HeavySelect2MultipleWidget
def security_check(self, request, *args, **kwargs):
return True
def get_results(self, request, term, page, context):
raise NotImplemented
import copy
from django import forms
from django.utils.translation import ugettext_lazy as _
from django.utils.encoding import smart_unicode
from django.db.models import Q
from django.core.validators import EMPTY_VALUES
from .widgets import Select2Widget, Select2MultipleWidget,\
HeavySelect2Widget, HeavySelect2MultipleWidget, AutoHeavySelect2Widget
from .views import NO_ERR_RESP
class ModelResultJsonMixin(object):
def __init__(self, **kwargs):
if self.queryset is None:
raise ValueError('queryset is required.')
if not self.search_fields:
raise ValueError('search_fields is required.')
self.max_results = getattr(self, 'max_results', None)
self.to_field_name = getattr(self, 'to_field_name', 'pk')
super(ModelResultJsonMixin, self).__init__(**kwargs)
def label_from_instance(self, obj):
return smart_unicode(obj)
def prepare_qs_params(self, request, search_term, search_fields):
q = None
for field in search_fields:
kwargs = {}
kwargs[field] = search_term
if q is None:
q = Q(**kwargs)
else:
q = q | Q(**kwargs)
return {'or': [q], 'and': {},}
def get_results(self, request, term, page, context):
qs = copy.deepcopy(self.queryset)
params = self.prepare_qs_params(request, term, self.search_fields)
if self.max_results:
min_ = (page - 1) * self.max_results
max_ = min_ + self.max_results + 1 # fetching one extra row to check if it has more rows.
res = list(qs.filter(*params['or'], **params['and'])[min_:max_])
has_more = len(res) == (max_ - min_)
if has_more:
res = res[:-1]
else:
res = list(qs.filter(*params['or'], **params['and']))
has_more = False
res = [ (getattr(obj, self.to_field_name), self.label_from_instance(obj), ) for obj in res ]
return (NO_ERR_RESP, has_more, res, )
class ModelValueMixin(object):
default_error_messages = {
'invalid_choice': _(u'Select a valid choice. That choice is not one of'
u' the available choices.'),
}
def __init__(self, **kwargs):
if self.queryset is None:
raise ValueError('queryset is required.')
self.to_field_name = getattr(self, 'to_field_name', 'pk')
super(ModelValueMixin, self).__init__(**kwargs)
def to_python(self, value):
if value in EMPTY_VALUES:
return None
try:
key = self.to_field_name
value = self.queryset.get(**{key: value})
except (ValueError, self.queryset.model.DoesNotExist):
raise ValidationError(self.error_messages['invalid_choice'])
return value
class Select2ChoiceField(forms.ChoiceField):
widget = Select2Widget
@ -27,5 +121,53 @@ class HeavySelect2ChoiceField(HeavySelect2FieldBase):
class HeavySelect2MultipleChoiceField(HeavySelect2FieldBase):
widget = HeavySelect2MultipleWidget
class AutoSelect2Field(ModelResultJsonMixin, AutoViewFieldMixin, HeavySelect2ChoiceField):
"""
This needs to be subclassed. The first instance of a class (sub-class) is used to serve all incoming
json query requests for that type (class).
"""
widget = AutoHeavySelect2Widget
def __init__(self, **kwargs):
self.data_view = "django_select2_central_json"
kwargs['data_view'] = self.data_view
super(AutoSelect2Field, self).__init__(**kwargs)
class AutoModelSelect2Field(ModelResultJsonMixin, AutoViewFieldMixin, ModelValueMixin, HeavySelect2ChoiceField):
"""
This needs to be subclassed. The first instance of a class (sub-class) is used to serve all incoming
json query requests for that type (class).
"""
widget = AutoHeavySelect2Widget
def __init__(self, **kwargs):
self.data_view = "django_select2_central_json"
kwargs['data_view'] = self.data_view
super(AutoModelSelect2Field, self).__init__(**kwargs)
class ModelSelect2Field(ModelValueMixin, Select2ChoiceField):
def __init__(self, **kwargs):
self.queryset = kwargs.pop('queryset', None)
self.to_field_name = kwargs.pop('to_field_name', 'pk')
choices = kwargs.pop('choices', None)
if choices is None:
choices = []
for obj in self.queryset.all():
choices.append((getattr(obj, self.to_field_name), smart_unicode(obj), ))
kwargs['choices'] = choices
super(ModelSelect2Field, self).__init__(**kwargs)
def valid_value(self, value):
val = getattr(value, self.to_field_name)
for k, v in self.choices:
if k == val:
return True
return False

View file

@ -1,11 +1,16 @@
var django_select2 = {
get_url_params: function (term, page, context) {
return {
'term': term,
'page': page,
'context': context
};
var field_id = $(this).data('field_id'),
res = {
'term': term,
'page': page,
'context': context
};
if (field_id) {
res['field_id'] = field_id;
}
return res;
},
process_results: function (data, page, context) {
var results;
@ -22,6 +27,64 @@ var django_select2 = {
} else {
results = {'results':[]};
}
if (results.results) {
$(this).data('results', results.results);
} else {
$(this).removeData('results');
}
return results;
},
setCookie: function (c_name, value) {
document.cookie=c_name + "=" + escape(value);
},
getCookie: function (c_name) {
var i,x,y,ARRcookies=document.cookie.split(";");
for (i=0; i<ARRcookies.length; i++) {
x=ARRcookies[i].substr(0,ARRcookies[i].indexOf("="));
y=ARRcookies[i].substr(ARRcookies[i].indexOf("=")+1);
x=x.replace(/^\s+|\s+$/g,"");
if (x==c_name) {
return unescape(y);
}
}
},
delCookie: function (c_name) {
document.cookie = c_name + '=; expires=Thu, 01 Jan 1970 00:00:01 GMT;';
},
onValChange: function () {
var e = $(this), res = e.data('results'), val = e.val(), txt, id = e.attr('id');
if (res) {
for (var i in res) {
if (res[i].id == val) {
val = res[i].id; // To set it to correct data type.
txt = res[i].text;
break;
}
}
if (txt) {
// Cookies are used to persist selection's text. This needed
//when the form springs back if there is any validation failure.
django_select2.setCookie(id + '_heavy_val', val);
django_select2.setCookie(id + '_heavy_txt', txt);
return;
}
}
django_select2.delCookie(id + '_heavy_val');
django_select2.delCookie(id + '_heavy_txt');
},
onInit: function (e) {
e = $(e);
var id = e.attr('id'),
val = django_select2.getCookie(id + '_heavy_val'),
txt = django_select2.getCookie(id + '_heavy_txt');
if (txt && e.val() == val) {
// Restores persisted value text.
return {'id': val, 'text': txt};
} else {
e.val(null);
}
return null;
}
};

7
django_select2/urls.py Normal file
View file

@ -0,0 +1,7 @@
from django.conf.urls.defaults import *
from .views import AutoResponseView
urlpatterns = patterns("",
url(r"^fields/auto.json$", AutoResponseView.as_view(), name="django_select2_central_json"),
)

43
django_select2/util.py Normal file
View file

@ -0,0 +1,43 @@
import re
import threading
import datetime
def synchronized(f):
f.__lock__ = threading.Lock()
def synced_f(*args, **kwargs):
with f.__lock__:
return f(*args, **kwargs)
return synced_f
__id_store = {}
__field_store = {}
ID_PATTERN = r"[0-9_a-zA-Z.:+\- ]+"
def is_valid_id(val):
regex = "^%s$" % ID_PATTERN
if re.match(regex, val) is None:
return False
else:
return True
@synchronized
def register_field(name, field):
global __id_store, __field_store
if name not in __field_store:
# Generating id
id_ = "%d:%s" % (len(__id_store), str(datetime.datetime.now()))
__field_store[name] = id_
__id_store[id_] = field
else:
id_ = __field_store[name]
return id_
def get_field(id_):
return __id_store.get(id_, None)

View file

@ -1,7 +1,13 @@
import json
from django.http import HttpResponse
from django.views.generic import View
from django.core.exceptions import PermissionDenied
from django.http import Http404
from .util import get_field, is_valid_id
NO_ERR_RESP = 'nil'
class JSONResponseMixin(object):
"""
@ -26,18 +32,31 @@ class JSONResponseMixin(object):
class Select2View(JSONResponseMixin, View):
def dispatch(self, request, *args, **kwargs):
self.check_all_permissions(request, *args, **kwargs)
try:
self.check_all_permissions(request, *args, **kwargs)
except Exception, e:
return self.respond_with_exception(e)
return super(Select2View, self).dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
if request.method == 'GET':
term = request.GET.get('term', None)
if term is None:
return self.render_to_response(self._results_to_context(('missing term', False, [])))
page = request.GET.get('page', None)
return self.render_to_response(self._results_to_context(('missing term', False, [], )))
if not term:
return self.render_to_response(self._results_to_context((NO_ERR_RESP, False, [], )))
try:
page = int(request.GET.get('page', None))
if page <= 0:
page = -1
except ValueError:
page = -1
if page == -1:
return self.render_to_response(self._results_to_context(('bade page no.', False, [], )))
context = request.GET.get('context', None)
else:
return self.render_to_response(self._results_to_context(('not a get request', False, [])))
return self.render_to_response(self._results_to_context(('not a get request', False, [], )))
return self.render_to_response(
self._results_to_context(
@ -45,16 +64,26 @@ class Select2View(JSONResponseMixin, View):
)
)
def respond_with_exception(self, e):
if isinstance(e, Http404):
status = 404
else:
status = getattr(e, 'status_code', 400)
return self.render_to_response(
self._results_to_context((str(e), False, [],)),
status=status
)
def _results_to_context(self, output):
err, has_more, results = output
res = []
if err == 'nil':
if err == NO_ERR_RESP:
for id_, text in results:
res.append({'id': id_, 'text': text})
return {
'err': err,
'more': has_more,
'results': res
'results': res,
}
def check_all_permissions(self, request, *args, **kwargs):
@ -76,3 +105,26 @@ class Select2View(JSONResponseMixin, View):
`has_more` should be true if there are more rows.
"""
raise NotImplemented
class AutoResponseView(Select2View):
"""A central view meant to respond to Ajax queries for all Heavy fields. Although it is not mandatory to use. This is just a helper."""
def check_all_permissions(self, request, *args, **kwargs):
id_ = request.GET.get('field_id', None)
if id_ is None or not is_valid_id(id_):
raise Http404('field_id not found or is invalid')
field = get_field(id_)
if field is None:
raise Http404('field_id not found')
if not field.security_check(request, *args, **kwargs):
raise PermissionDenied('permission denied')
request.__django_select2_local = field
def get_results(self, request, term, page, context):
field = request.__django_select2_local
del request.__django_select2_local
return field.get_results(request, term, page, context)

View file

@ -5,12 +5,23 @@ from django.utils.safestring import mark_safe
from django.core.urlresolvers import reverse
class JSFunction(str):
"""
Flags that the string is the name of a JS function. Used by Select2Mixin.render_options()
to make sure that this string is not quoted like other strings.
"""
pass
class JSFunctionInContext(str):
"""
Like JSFunction, this too flags the string as JS function, but with a special requirement.
The JS function needs to be invoked in the context of the current Select2 Html DOM,
such that 'this' inside the function refers to the source Select2 DOM.
"""
pass
class Select2Mixin(object):
# For details on these options refer: http://ivaynberg.github.com/select2/#documentation
options = {
'minimumInputLength': 2,
'minimumResultsForSearch': 6, # Only applicable for single value select.
'placeholder': '',
'allowClear': True, # Not allowed when field is multiple since there each value has a clear button.
@ -18,11 +29,8 @@ class Select2Mixin(object):
'closeOnSelect': False
}
class Media:
js = ('js/select2.min.js', )
css = {'screen': ('css/select2.css', 'css/extra.css', )}
def __init__(self, **kwargs):
self.options = dict(self.options) # Making an instance specific copy
self.init_options()
attrs = kwargs.pop('attrs', None)
if attrs:
@ -42,7 +50,7 @@ class Select2Mixin(object):
options['allowClear'] = not self.is_required
return options
def render_options(self, options):
def render_options_code(self, options, id_):
out = '{'
is_first = True
for name in options:
@ -57,10 +65,15 @@ class Select2Mixin(object):
out += 'true' if val else 'false'
elif type(val) in [types.IntType, types.LongType, types.FloatType]:
out += str(val)
elif isinstance(val, JSFunctionInContext):
out += """function () {
var args = Array.prototype.slice.call(arguments);
return %s.apply($('#%s').get(0), args);
}""" % (val, id_)
elif isinstance(val, JSFunction):
out += val # No quotes here
elif isinstance(val, dict):
out += self.render_options(val)
out += self.render_options_code(val, id_)
else:
out += "'%s'" % val
@ -68,17 +81,20 @@ class Select2Mixin(object):
def render_js_code(self, id_):
if id_:
options = dict(self.get_options())
options = self.render_options(options)
return u"""
<script>
$(function () {
$("#%s").select2(%s);
%s
});
</script>""" % (id_, options)
</script>""" % self.render_inner_js_code(id_);
return u''
def render_inner_js_code(self, id_):
options = dict(self.get_options())
options = self.render_options_code(options, id_)
return '$("#%s").select2(%s);' % (id_, options)
def render(self, name, value, attrs=None):
s = str(super(Select2Mixin, self).render(name, value, attrs))
s += self.media.render()
@ -87,24 +103,26 @@ class Select2Mixin(object):
s += self.render_js_code(id_)
return mark_safe(s)
class HeavySelect2Mixin(Select2Mixin):
class Media:
js = ('js/select2.min.js', 'js/heavy_data.js', )
js = ('js/select2.min.js', )
css = {'screen': ('css/select2.css', 'css/extra.css', )}
class HeavySelect2Mixin(Select2Mixin):
def __init__(self, **kwargs):
self.options = dict(self.options) # Making an instance specific copy
self.view = kwargs.pop('data_view', None)
self.url = kwargs.pop('data_url', None)
if not (self.view and self.url):
if not self.view and not self.url:
raise ValueError('data_view or data_url is required')
self.url = None
self.options['ajax'] = {
'dataType': 'json',
'quietMillis': 100,
'data': JSFunction('django_select2.get_url_params'),
'results': JSFunction('django_select2.process_results')
'data': JSFunctionInContext('django_select2.get_url_params'),
'results': JSFunctionInContext('django_select2.process_results'),
}
self.options['minimumInputLength'] = 2
self.options['initSelection'] = JSFunction('django_select2.onInit')
super(HeavySelect2Mixin, self).__init__(**kwargs)
def get_options(self):
@ -114,10 +132,31 @@ class HeavySelect2Mixin(Select2Mixin):
self.options['ajax']['url'] = self.url
return super(HeavySelect2Mixin, self).get_options()
def render_inner_js_code(self, id_):
js = super(HeavySelect2Mixin, self).render_inner_js_code(id_)
js += "$('#%s').change(django_select2.onValChange);" % id_
return js
class Media:
js = ('js/select2.min.js', 'js/heavy_data.js', )
css = {'screen': ('css/select2.css', 'css/extra.css', )}
class AutoHeavySelect2Mixin(HeavySelect2Mixin):
def render_inner_js_code(self, id_):
js = super(AutoHeavySelect2Mixin, self).render_inner_js_code(id_)
js += "$('#%s').data('field_id', '%s');" % (id_, self.field_id)
return js
class Select2Widget(Select2Mixin, forms.Select):
def init_options(self):
self.options.pop('multiple', None)
def render_options(self, choices, selected_choices):
if not self.is_required:
choices = list(choices)
choices.append(('', '', )) # Adding an empty choice
return super(Select2Widget, self).render_options(choices, selected_choices)
class Select2MultipleWidget(Select2Mixin, forms.SelectMultiple):
def init_options(self):
self.options.pop('multiple', None)
@ -136,3 +175,5 @@ class HeavySelect2MultipleWidget(HeavySelect2Mixin, forms.TextInput):
self.options.pop('allowClear', None)
self.options.pop('minimumResultsForSearch', None)
class AutoHeavySelect2Widget(AutoHeavySelect2Mixin, HeavySelect2Widget):
pass

136
setup.py Normal file
View file

@ -0,0 +1,136 @@
import codecs
import os
import sys
from distutils.util import convert_path
from fnmatch import fnmatchcase
from setuptools import setup, find_packages
def read(fname):
return codecs.open(os.path.join(os.path.dirname(__file__), fname)).read()
# Provided as an attribute, so you can append to these instead
# of replicating them:
standard_exclude = ["*.py", "*.pyc", "*$py.class", "*~", ".*", "*.bak"]
standard_exclude_directories = [
".*", "CVS", "_darcs", "./build", "./dist", "EGG-INFO", "*.egg-info"
]
# (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org)
# Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
# Note: you may want to copy this into your setup.py file verbatim, as
# you can't import this from another package, when you don't know if
# that package is installed yet.
def find_package_data(
where=".",
package="",
exclude=standard_exclude,
exclude_directories=standard_exclude_directories,
only_in_packages=True,
show_ignored=False):
"""
Return a dictionary suitable for use in ``package_data``
in a distutils ``setup.py`` file.
The dictionary looks like::
{"package": [files]}
Where ``files`` is a list of all the files in that package that
don"t match anything in ``exclude``.
If ``only_in_packages`` is true, then top-level directories that
are not packages won"t be included (but directories under packages
will).
Directories matching any pattern in ``exclude_directories`` will
be ignored; by default directories with leading ``.``, ``CVS``,
and ``_darcs`` will be ignored.
If ``show_ignored`` is true, then all the files that aren"t
included in package data are shown on stderr (for debugging
purposes).
Note patterns use wildcards, or can be exact paths (including
leading ``./``), and all searching is case-insensitive.
"""
out = {}
stack = [(convert_path(where), "", package, only_in_packages)]
while stack:
where, prefix, package, only_in_packages = stack.pop(0)
for name in os.listdir(where):
fn = os.path.join(where, name)
if os.path.isdir(fn):
bad_name = False
for pattern in exclude_directories:
if (fnmatchcase(name, pattern)
or fn.lower() == pattern.lower()):
bad_name = True
if show_ignored:
print >> sys.stderr, (
"Directory %s ignored by pattern %s"
% (fn, pattern))
break
if bad_name:
continue
if (os.path.isfile(os.path.join(fn, "__init__.py"))
and not prefix):
if not package:
new_package = name
else:
new_package = package + "." + name
stack.append((fn, "", new_package, False))
else:
stack.append((fn, prefix + name + "/", package, only_in_packages))
elif package or not only_in_packages:
# is a file
bad_name = False
for pattern in exclude:
if (fnmatchcase(name, pattern)
or fn.lower() == pattern.lower()):
bad_name = True
if show_ignored:
print >> sys.stderr, (
"File %s ignored by pattern %s"
% (fn, pattern))
break
if bad_name:
continue
out.setdefault(package, []).append(prefix+name)
return out
PACKAGE = "django_select2"
NAME = "Django-Select2"
DESCRIPTION = "Select2 option fields for Django"
AUTHOR = "AppleGrew"
AUTHOR_EMAIL = "admin@applegrew.com"
URL = "https://github.com/applegrew/django-select2"
VERSION = __import__(PACKAGE).__version__
setup(
name=NAME,
version=VERSION,
description=DESCRIPTION,
long_description=read("README.md"),
author=AUTHOR,
author_email=AUTHOR_EMAIL,
license="MIT",
url=URL,
packages=find_packages(exclude=["tests.*", "tests"]),
package_data=find_package_data(PACKAGE, only_in_packages=False),
include_package_data=True,
classifiers=[
"Development Status :: 4 - Beta",
"Environment :: Web Environment",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Framework :: Django",
],
)