diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01b11e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ + +testapp/tt.py + +*.pyc diff --git a/django_select2/__init__.py b/django_select2/__init__.py index 5c7ebe3..e155cd7 100644 --- a/django_select2/__init__.py +++ b/django_select2/__init__.py @@ -1,4 +1,4 @@ -__version__ = "1.0" +__version__ = "1.1" from django.conf import settings if settings.configured: diff --git a/django_select2/fields.py b/django_select2/fields.py index ececda6..e981934 100644 --- a/django_select2/fields.py +++ b/django_select2/fields.py @@ -12,11 +12,12 @@ class AutoViewFieldMixin(object): return True def get_results(self, request, term, page, context): - raise NotImplemented + raise NotImplementedError import copy from django import forms +from django.forms.models import ModelChoiceIterator from django.db.models import Q from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import smart_unicode @@ -28,8 +29,8 @@ from .views import NO_ERR_RESP class ModelResultJsonMixin(object): - def __init__(self, **kwargs): - if self.queryset is None: + def __init__(self, *args, **kwargs): + if self.queryset is None and not kwargs.has_key('queryset'): raise ValueError('queryset is required.') if not self.search_fields: @@ -38,7 +39,7 @@ class ModelResultJsonMixin(object): self.max_results = getattr(self, 'max_results', None) self.to_field_name = getattr(self, 'to_field_name', 'pk') - super(ModelResultJsonMixin, self).__init__(**kwargs) + super(ModelResultJsonMixin, self).__init__(*args, **kwargs) def label_from_instance(self, obj): return smart_unicode(obj) @@ -72,48 +73,105 @@ class ModelResultJsonMixin(object): 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.'), - } +class UnhideableQuerysetType(type): - def __init__(self, **kwargs): - if self.queryset is None: - raise ValueError('queryset is required.') + def __new__(cls, name, bases, dct): + _q = dct.get('queryset', None) + if _q is not None and not isinstance(_q, property): + # This hack is needed since users are allowed to + # provide queryset in sub-classes by declaring + # class variable named - queryset, which will + # effectively hide the queryset declared in this + # mixin. + dct.pop('queryset') # Throwing away the sub-class queryset + dct['_subclass_queryset'] = _q - self.to_field_name = getattr(self, 'to_field_name', 'pk') + return type.__new__(cls, name, bases, dct) - super(ModelValueMixin, self).__init__(**kwargs) + def __call__(cls, *args, **kwargs): + queryset = kwargs.get('queryset', None) + if not queryset and hasattr(cls, '_subclass_queryset'): + kwargs['queryset'] = getattr(cls, '_subclass_queryset') + return type.__call__(cls, *args, **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 ChoiceMixin(object): + def _get_choices(self): + if hasattr(self, '_choices'): + return self._choices + + def _set_choices(self, value): + # Setting choices also sets the choices on the widget. + # choices can be any iterable, but we call list() on it because + # it will be consumed more than once. + self._choices = self.widget.choices = list(value) + + choices = property(_get_choices, _set_choices) + +class QuerysetChoiceMixin(ChoiceMixin): + def _get_choices(self): + # If self._choices is set, then somebody must have manually set + # the property self.choices. In this case, just return self._choices. + if hasattr(self, '_choices'): + return self._choices + + # Otherwise, execute the QuerySet in self.queryset to determine the + # choices dynamically. Return a fresh ModelChoiceIterator that has not been + # consumed. Note that we're instantiating a new ModelChoiceIterator *each* + # time _get_choices() is called (and, thus, each time self.choices is + # accessed) so that we can ensure the QuerySet has not been consumed. This + # construct might look complicated but it allows for lazy evaluation of + # the queryset. + return ModelChoiceIterator(self) + + choices = property(_get_choices, ChoiceMixin._set_choices) + +class ModelChoiceField(forms.ModelChoiceField): + def __init__(self, *args, **kwargs): + queryset = kwargs.pop('queryset', None) + empty_label = kwargs.pop('empty_label', u"---------") + cache_choices = kwargs.pop('cache_choices', False) + required = kwargs.pop('required', True) + widget = kwargs.pop('widget', getattr(self, 'widget', None)) + label = kwargs.pop('label', None) + initial = kwargs.pop('initial', None) + help_text = kwargs.pop('help_text', None) + to_field_name = kwargs.pop('to_field_name', 'pk') + + if hasattr(self, '_choices'): # If it exists then probably it is set by HeavySelect2FieldBase. + # We are not gonna use that anyway. + del self._choices + + super(ModelChoiceField, self).__init__(queryset, empty_label, cache_choices, required, + widget, label, initial, help_text, to_field_name) + + if hasattr(self, 'set_placeholder'): + self.widget.set_placeholder(self.empty_label) + + def _get_queryset(self): + if hasattr(self, '_queryset'): + return self._queryset + + queryset = property(_get_queryset, forms.ModelChoiceField._set_queryset) class Select2ChoiceField(forms.ChoiceField): widget = Select2Widget -class Select2MultipleChoiceField(forms.ChoiceField): +class Select2MultipleChoiceField(forms.MultipleChoiceField): widget = Select2MultipleWidget -class HeavySelect2FieldBase(forms.Field): - def __init__(self, **kwargs): +class HeavySelect2FieldBase(ChoiceMixin, forms.Field): + def __init__(self, *args, **kwargs): data_view = kwargs.pop('data_view', None) - kargs = {} + self.choices = kwargs.pop('choices', []) + kargs = {} if data_view is not None: kargs['widget'] = self.widget(data_view=data_view) elif kwargs.get('widget', None) is None: raise ValueError('data_view is required else you need to provide your own widget instance.') kargs.update(kwargs) - super(HeavySelect2FieldBase, self).__init__(**kargs) + super(HeavySelect2FieldBase, self).__init__(*args, **kargs) class HeavySelect2ChoiceField(HeavySelect2FieldBase): widget = HeavySelect2Widget @@ -121,6 +179,11 @@ class HeavySelect2ChoiceField(HeavySelect2FieldBase): class HeavySelect2MultipleChoiceField(HeavySelect2FieldBase): widget = HeavySelect2MultipleWidget +class HeavyModelSelect2ChoiceField(QuerysetChoiceMixin, HeavySelect2ChoiceField, ModelChoiceField): + def __init__(self, *args, **kwargs): + kwargs.pop('choices', None) + super(HeavyModelSelect2ChoiceField, self).__init__(*args, **kwargs) + class AutoSelect2Field(ModelResultJsonMixin, AutoViewFieldMixin, HeavySelect2ChoiceField): """ This needs to be subclassed. The first instance of a class (sub-class) is used to serve all incoming @@ -129,45 +192,26 @@ class AutoSelect2Field(ModelResultJsonMixin, AutoViewFieldMixin, HeavySelect2Cho widget = AutoHeavySelect2Widget - def __init__(self, **kwargs): + def __init__(self, *args, **kwargs): self.data_view = "django_select2_central_json" kwargs['data_view'] = self.data_view - super(AutoSelect2Field, self).__init__(**kwargs) + super(AutoSelect2Field, self).__init__(*args, **kwargs) -class AutoModelSelect2Field(ModelResultJsonMixin, AutoViewFieldMixin, ModelValueMixin, HeavySelect2ChoiceField): +class AutoModelSelect2Field(ModelResultJsonMixin, AutoViewFieldMixin, HeavyModelSelect2ChoiceField): """ 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). """ + __metaclass__ = UnhideableQuerysetType widget = AutoHeavySelect2Widget - def __init__(self, **kwargs): + def __init__(self, *args, **kwargs): self.data_view = "django_select2_central_json" kwargs['data_view'] = self.data_view - super(AutoModelSelect2Field, self).__init__(**kwargs) + super(AutoModelSelect2Field, self).__init__(*args, **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), )) +class ModelSelect2Field(ModelChoiceField) : + "Light Model Select2 field" + widget = Select2Widget - 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/css/extra.css b/django_select2/static/css/extra.css index a2db915..6ac6dd7 100644 --- a/django_select2/static/css/extra.css +++ b/django_select2/static/css/extra.css @@ -1,3 +1,6 @@ .error a.select2-choice { border: 1px solid #B94A48; +} +.select2-container { + min-width: 150px; } \ No newline at end of file diff --git a/django_select2/static/js/heavy_data.js b/django_select2/static/js/heavy_data.js index ea170e9..3a7da47 100644 --- a/django_select2/static/js/heavy_data.js +++ b/django_select2/static/js/heavy_data.js @@ -1,5 +1,6 @@ var django_select2 = { + MULTISEPARATOR: String.fromCharCode(0), get_url_params: function (term, page, context) { var field_id = $(this).data('field_id'), res = { @@ -35,56 +36,173 @@ var django_select2 = { return results; }, setCookie: function (c_name, value) { - document.cookie=c_name + "=" + escape(value); + document.cookie = c_name + "=" + escape(value); }, getCookie: function (c_name) { - var i,x,y,ARRcookies=document.cookie.split(";"); - for (i=0; i 0) { + return data[0]; + } else { + return null; } } - 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'); + getValText: function ($e, isGetFromCookieAllowed) { + var val = $e.select2('val'), res = $e.data('results'), txt = $e.txt(), isMultiple = !!$e.attr('multiple'), + f, id = $e.attr('id'); + if (val || val === 0) { // Means value is set. A numerical 0 is also a valid value. + + if (!isMultiple) { + val = [val]; + if (txt || txt === 0) { + txt = [txt]; + } + } + + if (txt || txt === 0) { + return [val, txt]; + } + + f = $e.data('userGetValText'); + if (f) { + txt = f($e, val, isMultiple); + if (txt || txt === 0) { + return [val, txt]; + } + } + + if (res) { + txt = []; + $(val).each(function (idx) { + var i, value = this; + + for (i in res) { + if (res[i].id == value) { + val[idx] = res[i].id; // To set it to correct data type. + txt.push(res[i].text); + } + } + }); + if (txt || txt === 0) { + return [val, txt]; + } + } + + if (isGetFromCookieAllowed) { + txt = []; + $(val).each(function (idx) { + var value = this, cookieVal; + + cookieVal = django_select2.getCookie(id + '_heavy_val:' + idx); + + if (cookieVal == value) { + txt.push(django_select2.getCookie(id + '_heavy_txt:' + idx)); + } + }); + if (txt || txt === 0) { + return [val, txt]; + } + } - if (txt && e.val() == val) { - // Restores persisted value text. - return {'id': val, 'text': txt}; - } else { - e.val(null); } return null; + }, + onInit: function (e, callback) { + e = $(e); + var id = e.attr('id'), data = null, val = e.select2('val'); + + if (val || val === 0) { + // Value is set so need to get the text. + data = django_select2.getValText(e); + if (data && data[0]) { + data = django_select2.prepareValText(data[0], data[1], !!e.attr('multiple')); + } + } + if (!data) { + e.val(null); // Nulling out set value so as not to confuse users. + } + callback(data); // Change for 2.3.x + }, + onMultipleHiddenChange: function () { + var $e = $(this), valContainer = $e.data('valContainer'), name = $e.data('name'); + valContainer.empty(); + $($e.val()).each(function () { + var inp = $('').appendTo(valContainer); + inp.attr('type', 'hidden'); + inp.attr('name', name); + inp.val(this); + }); + }, + initMultipleHidden: function ($e) { + var valContainer; + + $e.data('name', $e.attr('name')); + $e.attr('name', ''); + + valContainer = $e.after('
').css({'display': 'none'}); + $e.data('valContainer', valContainer); + + $e.change(django_select2.onMultipleHiddenChange); + }, + convertArrToStr: function (arr) { + return arr.join(django_select2.MULTISEPARATOR); } -}; \ No newline at end of file +}; + +(function( $ ){ + $.fn.txt = function() { + return this.attr('txt'); + }; +})( jQuery ); \ No newline at end of file diff --git a/django_select2/util.py b/django_select2/util.py index 1e725b4..83ff375 100644 --- a/django_select2/util.py +++ b/django_select2/util.py @@ -1,3 +1,17 @@ +def convert_to_js_string_arr(lst): + lst = ['"%s"' % l for l in lst] + return u"[%s]" % (",".join(lst)) + +def render_js_script(inner_code): + return u""" + """ % inner_code + +### Auto view helper utils ### + import re import threading import datetime diff --git a/django_select2/views.py b/django_select2/views.py index 6124e68..6e9d196 100644 --- a/django_select2/views.py +++ b/django_select2/views.py @@ -104,7 +104,7 @@ class Select2View(JSONResponseMixin, View): When everything is fine then the `err` must be 'nil'. `has_more` should be true if there are more rows. """ - raise NotImplemented + raise NotImplementedError class AutoResponseView(Select2View): diff --git a/django_select2/widgets.py b/django_select2/widgets.py index 61d1e31..84c22a4 100644 --- a/django_select2/widgets.py +++ b/django_select2/widgets.py @@ -1,17 +1,24 @@ import types +from itertools import chain from django import forms from django.utils.safestring import mark_safe from django.core.urlresolvers import reverse -class JSFunction(str): +from .util import render_js_script, convert_to_js_string_arr + +class JSVar(unicode): + "Denotes a JS variable name, so it must not be quoted while rendering." + pass + +class JSFunction(JSVar): """ 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): +class JSFunctionInContext(JSVar): """ 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, @@ -23,10 +30,10 @@ class Select2Mixin(object): # For details on these options refer: http://ivaynberg.github.com/select2/#documentation options = { 'minimumResultsForSearch': 6, # Only applicable for single value select. - 'placeholder': '', + 'placeholder': '', # Empty text label 'allowClear': True, # Not allowed when field is multiple since there each value has a clear button. 'multiple': False, # Not allowed when attached to + \ No newline at end of file diff --git a/testapp/testapp/templates/index.html b/testapp/testapp/templates/index.html new file mode 100644 index 0000000..566c975 --- /dev/null +++ b/testapp/testapp/templates/index.html @@ -0,0 +1,5 @@ +{% load url from future %} +

Manual Tests

+ \ No newline at end of file diff --git a/testapp/testapp/templates/list.html b/testapp/testapp/templates/list.html new file mode 100644 index 0000000..45a5dcd --- /dev/null +++ b/testapp/testapp/templates/list.html @@ -0,0 +1,8 @@ +{% load url from future %} +

{{title}}

+ + diff --git a/testapp/testapp/urls.py b/testapp/testapp/urls.py new file mode 100644 index 0000000..7cd3d20 --- /dev/null +++ b/testapp/testapp/urls.py @@ -0,0 +1,15 @@ +from django.conf.urls import patterns, include, url +from django.views.generic import TemplateView + +# Uncomment the next two lines to enable the admin: +# from django.contrib import admin +# admin.autodiscover() + +urlpatterns = patterns('', + url(r'^$', TemplateView.as_view(template_name="index.html"), name='home'), + url(r'^test/', include('testmain.urls')), + url(r'^ext/', include('django_select2.urls')), + + # Uncomment the next line to enable the admin: + # url(r'^admin/', include(admin.site.urls)), +) diff --git a/testapp/testapp/wsgi.py b/testapp/testapp/wsgi.py new file mode 100644 index 0000000..35b5f32 --- /dev/null +++ b/testapp/testapp/wsgi.py @@ -0,0 +1,28 @@ +""" +WSGI config for testapp project. + +This module contains the WSGI application used by Django's development server +and any production WSGI deployments. It should expose a module-level variable +named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover +this application via the ``WSGI_APPLICATION`` setting. + +Usually you will have the standard Django WSGI application here, but it also +might make sense to replace the whole Django WSGI application with a custom one +that later delegates to the Django one. For example, you could introduce WSGI +middleware here, or combine a Django application with an application of another +framework. + +""" +import os + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") + +# This application object is used by any WSGI server configured to use this +# file. This includes Django's development server, if the WSGI_APPLICATION +# setting points here. +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() + +# Apply WSGI middleware here. +# from helloworld.wsgi import HelloWorldApplication +# application = HelloWorldApplication(application) diff --git a/testapp/testmain/__init__.py b/testapp/testmain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/testapp/testmain/fixtures/initial_data.json b/testapp/testmain/fixtures/initial_data.json new file mode 100644 index 0000000..adb5e59 --- /dev/null +++ b/testapp/testmain/fixtures/initial_data.json @@ -0,0 +1,81 @@ +[ + { + "pk": 1, + "model": "testmain.dept", + "fields": { + "name": "Chemistry" + } + }, + { + "pk": 2, + "model": "testmain.dept", + "fields": { + "name": "Biology" + } + }, + { + "pk": 3, + "model": "testmain.dept", + "fields": { + "name": "Physics" + } + }, + { + "pk": 1, + "model": "testmain.employee", + "fields": { + "name": "John Roger", + "salary": 8000, + "dept": 1 + } + }, + { + "pk": 2, + "model": "testmain.employee", + "fields": { + "name": "John Doe", + "salary": 9000, + "dept": 2 + } + }, + { + "pk": 3, + "model": "testmain.employee", + "fields": { + "name": "John Mark", + "salary": 10000, + "dept": 3 + } + }, + { + "pk": 4, + "model": "testmain.employee", + "fields": { + "name": "Mary Jane", + "salary": 2000, + "dept": 1, + "manager": 1 + } + }, + { + "pk": 5, + "model": "testmain.employee", + "fields": { + "name": "Hulk", + "salary": 7000, + "dept": 2, + "manager": 2 + } + }, + { + "pk": 6, + "model": "testmain.employee", + "fields": { + "name": "Green Gold", + "salary": 4000, + "dept": 2, + "manager": 2 + } + } +] + diff --git a/testapp/testmain/forms.py b/testapp/testmain/forms.py new file mode 100644 index 0000000..7d8cb90 --- /dev/null +++ b/testapp/testmain/forms.py @@ -0,0 +1,17 @@ +from django import forms + +from django_select2 import * + +from .models import Employee, Dept + +class EmployeeChoices(AutoModelSelect2Field): + queryset = Employee.objects + search_fields = ['name__icontains', ] + +class EmployeeForm(forms.ModelForm): + manager = EmployeeChoices() + dept = ModelSelect2Field(queryset=Dept.objects) + + class Meta: + model = Employee + \ No newline at end of file diff --git a/testapp/testmain/models.py b/testapp/testmain/models.py new file mode 100644 index 0000000..0c585ee --- /dev/null +++ b/testapp/testmain/models.py @@ -0,0 +1,16 @@ +from django.db import models + +class Dept(models.Model): + name = models.CharField(max_length=10) + + def __unicode__(self): + return unicode(self.name) + +class Employee(models.Model): + name = models.CharField(max_length=30) + salary = models.FloatField() + dept = models.ForeignKey(Dept) + manager = models.ForeignKey('Employee', null=True, blank=True) + + def __unicode__(self): + return unicode(self.name) diff --git a/testapp/testmain/urls.py b/testapp/testmain/urls.py new file mode 100644 index 0000000..61188ee --- /dev/null +++ b/testapp/testmain/urls.py @@ -0,0 +1,6 @@ +from django.conf.urls.defaults import * + +urlpatterns = patterns("", + url(r'auto/model/field/$', 'testmain.views.test_auto_model_field', name='test_auto_model_field'), + url(r'auto/model/field/([0-9]+)/$', 'testmain.views.test_auto_model_field1', name='test_auto_model_field2'), +) diff --git a/testapp/testmain/views.py b/testapp/testmain/views.py new file mode 100644 index 0000000..0c728d2 --- /dev/null +++ b/testapp/testmain/views.py @@ -0,0 +1,25 @@ +from django.core.urlresolvers import reverse +from django.http import HttpResponseRedirect +from django.shortcuts import render_to_response +from django.template import RequestContext + +from .forms import EmployeeForm +from .models import Employee + +def test_auto_model_field(request): + return render_to_response('list.html', RequestContext(request, { + 'title': 'Employees', + 'href': 'test_auto_model_field2', + 'object_list': Employee.objects.all() + })) + +def test_auto_model_field1(request, id): + emp = Employee.objects.get(id=id) + if request.POST: + form = EmployeeForm(data=request.POST, instance=emp) + if form.is_valid(): + form.save() + return HttpResponseRedirect(reverse('home')) + else: + form = EmployeeForm(instance=emp) + return render_to_response('form.html', RequestContext(request, {'form': form})) \ No newline at end of file