diff --git a/README.md b/README.md index ade2a13..5f398f5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,14 @@ -django-select2 +Django-Select2 ============== -This is a [Django](https://www.djangoproject.com/) integration for [Select2](http://ivaynberg.github.com/select2/) \ No newline at end of file +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. diff --git a/django_select2/__init__.py b/django_select2/__init__.py index 3f22fcf..c016a4e 100644 --- a/django_select2/__init__.py +++ b/django_select2/__init__.py @@ -1,3 +1,5 @@ -from .widgets import Select2Widget, Select2MultipleWidget, HeavySelect2Widget, HeavySelect2MultipleWidget -from .fields import Select2ChoiceField, Select2MultipleChoiceField, HeavySelect2ChoiceField, HeavySelect2MultipleChoiceField -from .views import Select2View \ No newline at end of file +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 diff --git a/django_select2/fields.py b/django_select2/fields.py index 2d2f7e2..49668db 100644 --- a/django_select2/fields.py +++ b/django_select2/fields.py @@ -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 + diff --git a/django_select2/static/js/heavy_data.js b/django_select2/static/js/heavy_data.js index 64aa8ae..ea170e9 100644 --- a/django_select2/static/js/heavy_data.js +++ b/django_select2/static/js/heavy_data.js @@ -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 $(function () { - $("#%s").select2(%s); + %s }); - """ % (id_, options) + """ % 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 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6c59951 --- /dev/null +++ b/setup.py @@ -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", + ], +)