diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000000..cc19e55e05 --- /dev/null +++ b/.jshintrc @@ -0,0 +1,25 @@ +{ + "node": true, + "browser": true, + "esnext": true, + "bitwise": false, + "camelcase": false, + "curly": true, + "eqeqeq": true, + "immed": true, + "indent": 2, + "latedef": true, + "newcap": true, + "noarg": true, + "quotmark": "single", + "regexp": true, + "undef": true, + "unused": true, + "strict": true, + "trailing": true, + "smarttabs": true, + "globals": { + "angular": false, + "window": true + } +} diff --git a/MANIFEST.in b/MANIFEST.in index 36408f905d..f04bd56aaa 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,3 @@ -recursive-include ajax_select *.css *.py *.gif *.html *.txt *.js *.md recursive-include example *.py *.sh *.txt prune example/AJAXSELECTS prune example/ajax_select \ No newline at end of file diff --git a/README.md b/README.md index 47166f7078..c3807ddae4 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ selected: [Note: screen shots are from the older version. Styling has changed slightly] -1. The user types a search term into the text field -2. An ajax request is sent to the server. -3. The dropdown menu is populated with results. +1. User types a few characters +2. Ajax request sent to the server +3. The dropdown menu shows choices 4. User selects by clicking or using arrow keys 5. Selected result displays in the "deck" area directly below the input field. 6. User can click trashcan icon to remove a selected item @@ -24,15 +24,13 @@ selected: Features ======== -+ Django 1.2+ -+ Optional boostrap mode allows easy installation by automatic inclusion of jQueryUI from the googleapis CDN -+ Compatible with staticfiles, appmedia, django-compressor etc -+ Popup to add a new item is supported -+ Admin inlines now supported -+ Ajax Selects works in the admin and also in public facing forms. -+ Rich formatting can be easily defined for the dropdown display and the selected "deck" display. -+ Templates and CSS are fully customizable -+ JQuery triggers enable you to add javascript to respond when items are added or removed, so other interface elements on the page can react ++ Works in any form including the Django Admin ++ Popup to add a new item ++ Admin inlines ++ Compatible with widget/form media, staticfiles, asset compressors etc. ++ Automatically Loads jQuery UI mode allows easy installation by automatic inclusion of jQueryUI from the googleapis CDN ++ Customize HTML, CSS and JS ++ JQuery triggers allow you to customize interface behavior to respond when items are added or removed + Default (but customizable) security prevents griefers from pilfering your data via JSON requests @@ -43,16 +41,8 @@ Quick Installation Get it `pip install django-ajax-selects` -or - `easy_install django-ajax-selects` or download or checkout the distribution -or - install using buildout by adding `django-ajax-selects` to your `eggs` - -on fedora: - su -c 'yum install django-ajax-selects' -(note: this version may not be up to date) In settings.py : @@ -60,17 +50,18 @@ In settings.py : # add the app INSTALLED_APPS = ( ..., + 'django.contrib.staticfiles', 'ajax_select' ) # define the lookup channels in use on the site AJAX_LOOKUP_CHANNELS = { - # pass a dict with the model and the field to search against - 'person' : {'model':'example.person', 'search_field':'name'} + # simple: search Person.objects.filter(name__icontains=q) + 'person' : {'model': 'example.person', 'search_field': 'name'}, + # define a custom lookup channel + 'song' : ('example.lookups', 'SongLookup') } - # magically include jqueryUI/js/css - AJAX_SELECT_BOOTSTRAP = True - AJAX_SELECT_INLINES = 'inline' + In your urls.py: @@ -98,39 +89,50 @@ In your admin.py: pass admin.site.register(Person,PersonAdmin) - # subclass AjaxSelectAdmin - class LabelAdmin(AjaxSelectAdmin): + class SongAdmin(AjaxSelectAdmin): # create an ajax form class using the factory function # model,fieldlist, [form superclass] form = make_ajax_form(Label,{'owner':'person'}) admin.site.register(Label,LabelAdmin) +example/lookups.py: + + from ajax_select import LookupChannel + + class SongLookup(LookupChannel): + + model = Song + + def get_query(self,q,request): + return Song.objects.filter(title__icontains=q).order_by('title') -This setup will give most people the ajax powered editing they need by bootstrapping in JS/CSS and implementing default security and simple ajax lookup channels. NOT SO QUICK INSTALLATION ========================= Things that can be customized: -+ how and from where jQuery, jQueryUI, jQueryUI theme are loaded -+ whether to include js/css inline or for better performance via staticfiles or django-compress etc. + define custom `LookupChannel` classes to customize: + HTML formatting for the drop down results and the item-selected display + custom search queries, ordering, user specific filtered results + custom channel security (default is staff only) -+ customizing the CSS -+ each channel could define its own template to change display or add extra javascript -+ custom javascript can respond to jQuery triggers when items are selected or removed ++ each channel can define its own template to add controls or javascript ++ JS can respond to jQuery triggers when items are selected or removed ++ custom CSS ++ how and from where jQuery, jQueryUI, jQueryUI theme are loaded Architecture ============ -A single view services all of the ajax search requests, delegating the searches to named 'channels'. Each model that needs to be searched for has a channel defined for it. More than one channel may be defined for a Model to serve different needs such as public vs admin or channels that filter the query by specific categories etc. The channel also has access to the request and the user so it can personalize the query results. Those channels can be reused by any Admin that wishes to lookup that model for a ManyToMany or ForeignKey field. +A single view services all of the ajax search requests, delegating the searches to named 'channels'. A simple channel can be specified in settings.py, a more complex one (with custom search, formatting, personalization or auth requirements) can be written in a lookups.py file. +Each model that needs to be searched for has a channel defined for it. More than one channel may be defined for a Model to serve different needs such as public vs admin or channels that filter the query by specific categories etc. The channel also has access to the request and the user so it can personalize the query results. Those channels can be reused by any Admin that wishes to lookup that model for a ManyToMany or ForeignKey field. + + + There are three model field types with corresponding form fields and widgets: @@ -158,10 +160,10 @@ Defines the available lookup channels. + channel_name : {'model': 'app.modelname', 'search_field': 'name_of_field_to_search' } > This will create a channel automatically - chanel_name : ( 'app.lookups', 'YourLookup' ) - This points to a custom Lookup channel name YourLookup in app/lookups.py + chanel_name : ( 'app.lookups', 'YourLookup' ) + This points to a custom Lookup channel name YourLookup in app/lookups.py - AJAX_LOOKUP_CHANNELS = { + AJAX_LOOKUP_CHANNELS = { # channel : dict with settings to create a channel 'person' : {'model':'example.person', 'search_field':'name'}, @@ -171,43 +173,27 @@ Defines the available lookup channels. #### AJAX_SELECT_BOOTSTRAP -Sets if it should automatically include jQuery/jQueryUI/theme. On large formsets this will cause it to check each time but it will only jQuery the first time. - -+ True: [easiest] - use jQuery if already present, else use the admin's jQuery else load from google's CDN - use jqueryUI if present else load from google's CDN - use jqueryUI theme if present else load one from google's CDN +By default it will include bootstrap.js in the widget media which will locate or load jQuery and jQuery-UI. -+ False/None/Not set: [default] - you should then include jQuery, jqueryUI + theme in your template or js compressor stack +In other words, by default it will just work. +First one wins: -#### AJAX_SELECT_INLINES +* window.jQuery - if you included jQuery on the page +* django.jQuery - if you are on an admin page +* load from ajax.googleapis com CDN -This controls if and how these: +Likewise for jQuery-UI: - ajax_select/static/js/ajax_select.js - ajax_select/static/css/ajax_select.css +* window.jQuery.ui +* load from ajax.googleapis.com with default smoothness theme -are included inline in the html with each form field. +If you want your own theme then load it afterwards to override or: -+ 'inline': [easiest] - Includes the js and css inline - This gets you up and running easily and is fine for small sites. - But with many form fields this will be less efficient. + AJAX_SELECT_BOOTSTRAP = False -+ 'staticfiles': - @import the css/js from {{STATIC_URL}}/ajax_selects using `django.contrib.staticfiles` - Requires staticfiles to be installed and to run its management command to collect files. - This still imports the css/js multiple times and is thus inefficient but otherwise harmless. +and load your own jquery, jquery ui and theme. - When using staticfiles you may implement your own `ajax_select.css` and customize to taste as long - as your app is before ajax_select in the INSTALLED_APPS. - -+ False/None: [default] - Does not inline anything. You should include the css/js files in your compressor stack - or include them in the head of the admin/base_site.html template. - This is the most efficient but takes the longest to configure. urls.py @@ -332,7 +318,7 @@ ie. what is returned by yourmodel.fieldname_set.all() In most situations (especially postgres) this order is random, not the order that you originally added them in the interface. With a bit of hacking I have convinced it to preserve the order [see OrderedManyToMany.md for solution] -###### can_add(self,user,argmodel): +###### can_add(self, user, argmodel): Check if the user has permission to add one of these models. This enables the green popup + @@ -351,7 +337,7 @@ Also you could choose to return HttpResponseForbidden("who are you?") instead of admin.py -------- -#### make_ajax_form(model,fieldlist,superclass=ModelForm,show_help_text=False) +#### make_ajax_form(model, fieldlist, superclass=ModelForm, show_help_text=False) If your application does not otherwise require a custom Form class then you can use the make_ajax_form helper to create the entire form directly in admin.py. See forms.py below for cases where you wish to make your own Form. @@ -370,16 +356,16 @@ If your application does not otherwise require a custom Form class then you can class YourModelAdmin(AjaxSelectAdmin): # create an ajax form class using the factory function - # model,fieldlist, [form superclass] - form = make_ajax_form(Label,{'owner':'person'}) + # model, fieldlist, [form superclass] + form = make_ajax_form(Label, {'owner': 'person'}) admin.site.register(YourModel,YourModelAdmin) You may use AjaxSelectAdmin as a mixin class and multiple inherit if you have another Admin class that you would like to use. You may also just add the hook into your own Admin class: def get_form(self, request, obj=None, **kwargs): - form = super(YourAdminClass,self).get_form(request,obj,**kwargs) - autoselect_fields_check_can_add(form,self.model,request.user) + form = super(YourAdminClass, self).get_form(request, obj, **kwargs) + autoselect_fields_check_can_add(form, self.model, request.user) return form Note that ajax_selects does not need to be in an admin. Popups will still use an admin view (the registered admin for the model being added), even if the form from where the popup was launched does not. @@ -390,7 +376,7 @@ forms.py subclass ModelForm just as usual. You may add ajax fields using the helper or directly. -#### make_ajax_field(model,model_fieldname,channel,show_help_text = False,**kwargs) +#### make_ajax_field(model, model_fieldname, channel, show_help_text=False, **kwargs) A factory function to makes an ajax field + widget. The helper ensures things are set correctly and simplifies usage and imports thus reducing programmer error. All kwargs are passed into the Field so it is no less customizable. @@ -418,7 +404,7 @@ A factory function to makes an ajax field + widget. The helper ensures things a class Meta: model = Release - group = make_ajax_field(Release,'group','group',help_text=None) + group = make_ajax_field(Release, 'group', 'group', help_text=None) #### Without using the helper @@ -435,7 +421,7 @@ A factory function to makes an ajax field + widget. The helper ensures things a class ReleaseForm(ModelForm): - group = AutoCompleteSelectField('group', required=False, help_text=None,plugin_options = {'autoFocus':True,'minLength':4}) + group = AutoCompleteSelectField('group', required=False, help_text=None, plugin_options = {'autoFocus': True, 'minLength': 4}) #### Using ajax selects in a `FormSet` @@ -458,7 +444,7 @@ There is possibly a better way to do this, but here is an initial example: form.fields["project"] = AutoCompleteSelectField('project', required=False) # pass in the base formset class to the factory - TaskFormSet = modelformset_factory(Task,fields=('name','project','area'),extra=0,formset=BaseTaskFormSet) + TaskFormSet = modelformset_factory(Task, fields=('name', 'project', 'area'),extra=0, formset=BaseTaskFormSet) @@ -488,11 +474,6 @@ If you are doing your own compress stack then of course you can include whatever The display style now uses the jQuery UI theme and actually I find the drop down to be not very charming. The previous version (1.1x) which used the external jQuery AutoComplete plugin had nicer styling. I might decide to make the default more like that with alternating color rows and a stronger sense of focused item. Also the current jQuery one wiggles. -The CSS refers to one image that is served from github (as a CDN): -!['https://github.com/crucialfelix/django-ajax-selects/raw/master/ajax_select/static/images/loading-indicator.gif'](https://github.com/crucialfelix/django-ajax-selects/raw/master/ajax_select/static/images/loading-indicator.gif) 'https://github.com/crucialfelix/django-ajax-selects/raw/master/ajax_select/static/images/loading-indicator.gif' - -Your own site's CSS could redefine that with a stronger declaration to point to whatever you like. - The trashcan icon comes from the jQueryUI theme by the css classes: "ui-icon ui-icon-trash" @@ -553,19 +534,12 @@ There is no remove as there is no kill/delete button in a simple auto-complete. The user may clear the text themselves but there is no javascript involved. Its just a text field. -Planned Improvements --------------------- - -TODO: + pop ups are not working in AdminInlines yet - - - Contributors ------------ Many thanks to all who found bugs, asked for things, and hassled me to get a new release out. I'm glad people find good use out of the app. -In particular thanks for help in the 1.2 version: sjrd (Sébastien Doeraene), Brian May +In particular thanks for help in the 1.2 version: @sjrd (Sébastien Doeraene), @brianmay License diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index 0850246976..7bdf8b6550 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -1,5 +1,5 @@ """JQuery-Ajax Autocomplete fields for Django Forms""" -__version__ = "1.2.5" +__version__ = "1.3.0" __author__ = "crucialfelix" __contact__ = "crucialfelix@gmail.com" __homepage__ = "https://github.com/crucialfelix/django-ajax-selects/" @@ -10,7 +10,8 @@ from django.contrib.contenttypes.models import ContentType from django.forms.models import ModelForm from django.utils.text import capfirst -from django.utils.translation import ugettext_lazy as _, ugettext +from django.utils.html import escape +from django.utils.translation import ugettext_lazy as _ class LookupChannel(object): @@ -21,27 +22,27 @@ class LookupChannel(object): plugin_options = {} min_length = 1 - def get_query(self,q,request): + def get_query(self, q, request): """ return a query set searching for the query string q either implement this method yourself or set the search_field in the LookupChannel class definition """ - kwargs = { "%s__icontains" % self.search_field : q } + kwargs = {"%s__icontains" % self.search_field: q} return self.model.objects.filter(**kwargs).order_by(self.search_field) - def get_result(self,obj): + def get_result(self, obj): """ The text result of autocompleting the entered query """ - return unicode(obj) + return escape(unicode(obj)) - def format_match(self,obj): + def format_match(self, obj): """ (HTML) formatted item for displaying item in the dropdown """ - return unicode(obj) + return escape(unicode(obj)) - def format_item_display(self,obj): + def format_item_display(self, obj): """ (HTML) formatted item for displaying item in the selected deck area """ - return unicode(obj) + return escape(unicode(obj)) - def get_objects(self,ids): + def get_objects(self, ids): """ Get the currently selected objects when editing an existing model """ # return in the same order as passed in here # this will be however the related objects Manager returns them @@ -49,17 +50,17 @@ def get_objects(self,ids): # see OrdredManyToMany.md ids = [int(id) for id in ids] things = self.model.objects.in_bulk(ids) - return [things[aid] for aid in ids if things.has_key(aid)] + return [things[aid] for aid in ids if aid in things] - def can_add(self,user,argmodel): + def can_add(self, user, argmodel): """ Check if the user has permission to add one of these models. This enables the green popup + Default is the standard django permission check """ ctype = ContentType.objects.get_for_model(argmodel) - return user.has_perm("%s.add_%s" % (ctype.app_label,ctype.model)) + return user.has_perm("%s.add_%s" % (ctype.app_label, ctype.model)) - def check_auth(self,request): + def check_auth(self, request): """ to ensure that nobody can get your data via json simply by knowing the URL. public facing forms should write a custom LookupChannel to implement as you wish. also you could choose to return HttpResponseForbidden("who are you?") @@ -69,8 +70,7 @@ def check_auth(self,request): raise PermissionDenied - -def make_ajax_form(model,fieldlist,superclass=ModelForm,show_help_text=False,**kwargs): +def make_ajax_form(model, fieldlist, superclass=ModelForm, show_help_text=False, **kwargs): """ Creates a ModelForm subclass with autocomplete fields usage: @@ -93,17 +93,17 @@ class Meta: pass setattr(Meta, 'model', model) - for model_fieldname,channel in fieldlist.iteritems(): - f = make_ajax_field(model,model_fieldname,channel,show_help_text) + for model_fieldname, channel in fieldlist.iteritems(): + f = make_ajax_field(model, model_fieldname, channel, show_help_text) TheForm.declared_fields[model_fieldname] = f TheForm.base_fields[model_fieldname] = f - setattr(TheForm,model_fieldname,f) + setattr(TheForm, model_fieldname, f) return TheForm -def make_ajax_field(model,model_fieldname,channel,show_help_text = False,**kwargs): +def make_ajax_field(model, model_fieldname, channel, show_help_text=False, **kwargs): """ Makes a single autocomplete field for use in a Form optional args: @@ -125,21 +125,21 @@ def make_ajax_field(model,model_fieldname,channel,show_help_text = False,**kwarg AutoCompleteSelectField field = model._meta.get_field(model_fieldname) - if not kwargs.has_key('label'): + if not 'label' in kwargs: kwargs['label'] = _(capfirst(unicode(field.verbose_name))) - if not kwargs.has_key('help_text') and field.help_text: + if not 'help_text' in kwargs and field.help_text: kwargs['help_text'] = field.help_text - if not kwargs.has_key('required'): + if not 'required' in kwargs: kwargs['required'] = not field.blank kwargs['show_help_text'] = show_help_text - if isinstance(field,ManyToManyField): + if isinstance(field, ManyToManyField): f = AutoCompleteSelectMultipleField( channel, **kwargs ) - elif isinstance(field,ForeignKey): + elif isinstance(field, ForeignKey): f = AutoCompleteSelectField( channel, **kwargs @@ -163,34 +163,34 @@ def get_lookup(channel): except KeyError: raise ImproperlyConfigured("settings.AJAX_LOOKUP_CHANNELS not configured correctly for %r" % channel) - if isinstance(lookup_label,dict): + if isinstance(lookup_label, dict): # 'channel' : dict(model='app.model', search_field='title' ) # generate a simple channel dynamically - return make_channel( lookup_label['model'], lookup_label['search_field'] ) - else: # a tuple + return make_channel(lookup_label['model'], lookup_label['search_field']) + else: # a tuple # 'channel' : ('app.module','LookupClass') # from app.module load LookupClass and instantiate - lookup_module = __import__( lookup_label[0],{},{},['']) - lookup_class = getattr(lookup_module,lookup_label[1] ) + lookup_module = __import__(lookup_label[0], {}, {}, ['']) + lookup_class = getattr(lookup_module, lookup_label[1]) # monkeypatch older lookup classes till 1.3 - if not hasattr(lookup_class,'format_match'): + if not hasattr(lookup_class, 'format_match'): setattr(lookup_class, 'format_match', - getattr(lookup_class,'format_item', - lambda self,obj: unicode(obj))) - if not hasattr(lookup_class,'format_item_display'): + getattr(lookup_class, 'format_item', + lambda self, obj: unicode(obj))) + if not hasattr(lookup_class, 'format_item_display'): setattr(lookup_class, 'format_item_display', - getattr(lookup_class,'format_item', - lambda self,obj: unicode(obj))) - if not hasattr(lookup_class,'get_result'): + getattr(lookup_class, 'format_item', + lambda self, obj: unicode(obj))) + if not hasattr(lookup_class, 'get_result'): setattr(lookup_class, 'get_result', - getattr(lookup_class,'format_result', - lambda self,obj: unicode(obj))) + getattr(lookup_class, 'format_result', + lambda self, obj: unicode(obj))) return lookup_class() -def make_channel(app_model,arg_search_field): +def make_channel(app_model, arg_search_field): """ used in get_lookup app_model : app_name.model_name search_field : the field to search against and to display in search results @@ -205,5 +205,3 @@ class MadeLookupChannel(LookupChannel): search_field = arg_search_field return MadeLookupChannel() - - diff --git a/ajax_select/admin.py b/ajax_select/admin.py index c2ca3ef7a2..0dae1872f4 100644 --- a/ajax_select/admin.py +++ b/ajax_select/admin.py @@ -9,8 +9,15 @@ class AjaxSelectAdmin(admin.ModelAdmin): """ in order to get + popup functions subclass this or do the same hook inside of your get_form """ def get_form(self, request, obj=None, **kwargs): - form = super(AjaxSelectAdmin,self).get_form(request,obj,**kwargs) + form = super(AjaxSelectAdmin, self).get_form(request, obj, **kwargs) - autoselect_fields_check_can_add(form,self.model,request.user) + autoselect_fields_check_can_add(form, self.model, request.user) return form + +class AjaxSelectAdminTabularInline(admin.TabularInline): + + def get_formset(self, request, obj=None, **kwargs): + fs = super(AjaxSelectAdminTabularInline, self).get_formset(request, obj, **kwargs) + autoselect_fields_check_can_add(fs.form, self.model, request.user) + return fs diff --git a/ajax_select/fields.py b/ajax_select/fields.py index 5863c647cf..65e28743e0 100644 --- a/ajax_select/fields.py +++ b/ajax_select/fields.py @@ -5,31 +5,47 @@ from django.contrib.contenttypes.models import ContentType from django.core.urlresolvers import reverse from django.forms.util import flatatt -from django.template.defaultfilters import escapejs from django.template.loader import render_to_string +from django.template.defaultfilters import force_escape from django.utils.safestring import mark_safe from django.utils.translation import ugettext as _ -from django.conf import settings from django.utils import simplejson -import os as_default_help = u'Enter text to search.' + +def _media(self): + # unless AJAX_SELECT_BOOTSTRAP == False + # then load jquery and jquery ui + default css + # where needed + js = ('ajax_select/js/bootstrap.js', 'ajax_select/js/ajax_select.js') + try: + if not settings.AJAX_SELECT_BOOTSTRAP: + js = ('ajax_select/js/ajax_select.js',) + except AttributeError: + pass + return forms.Media(css={'all': ('ajax_select/css/ajax_select.css',)}, js=js) + + #################################################################################### + class AutoCompleteSelectWidget(forms.widgets.TextInput): """ widget to select a model and return it as text """ + media = property(_media) + add_link = None def __init__(self, channel, - help_text = u'', - show_help_text = True, - plugin_options = {}, - *args, **kwargs): + help_text=u'', + show_help_text=True, + plugin_options={}, + *args, + **kwargs): self.plugin_options = plugin_options super(forms.widgets.TextInput, self).__init__(*args, **kwargs) self.channel = channel @@ -52,7 +68,7 @@ def render(self, name, value, attrs=None): except IndexError: raise Exception("%s cannot find object:%s" % (lookup, value)) current_repr = lookup.format_item_display(obj) - initial = [current_repr,obj.pk] + initial = [current_repr, obj.pk] if self.show_help_text: help_text = self.help_text @@ -66,13 +82,12 @@ def render(self, name, value, attrs=None): 'current_repr': current_repr, 'help_text': help_text, 'extra_attrs': mark_safe(flatatt(final_attrs)), - 'func_slug': self.html_id.replace("-",""), + 'func_slug': self.html_id.replace("-", ""), 'add_link': self.add_link, } - context.update(plugin_options(lookup,self.channel,self.plugin_options,initial)) - context.update(bootstrap()) + context.update(plugin_options(lookup, self.channel, self.plugin_options, initial)) - return mark_safe(render_to_string(('autocompleteselect_%s.html' % self.channel, 'autocompleteselect.html'),context)) + return mark_safe(render_to_string(('autocompleteselect_%s.html' % self.channel, 'autocompleteselect.html'), context)) def value_from_datadict(self, data, files, name): @@ -86,7 +101,6 @@ def id_for_label(self, id_): return '%s_text' % id_ - class AutoCompleteSelectField(forms.fields.CharField): """ form field to select a model for a ForeignKey db field """ @@ -99,31 +113,31 @@ def __init__(self, channel, *args, **kwargs): if not widget or not isinstance(widget, AutoCompleteSelectWidget): widget_kwargs = dict( - channel = channel, - help_text = kwargs.get('help_text',_(as_default_help)), - show_help_text = kwargs.pop('show_help_text',True), - plugin_options = kwargs.pop('plugin_options',{}) + channel=channel, + help_text=kwargs.get('help_text', _(as_default_help)), + show_help_text=kwargs.pop('show_help_text', True), + plugin_options=kwargs.pop('plugin_options', {}) ) kwargs["widget"] = AutoCompleteSelectWidget(**widget_kwargs) - super(AutoCompleteSelectField, self).__init__(max_length=255,*args, **kwargs) + super(AutoCompleteSelectField, self).__init__(max_length=255, *args, **kwargs) def clean(self, value): if value: lookup = get_lookup(self.channel) - objs = lookup.get_objects( [ value] ) + objs = lookup.get_objects([value]) if len(objs) != 1: # someone else might have deleted it while you were editing # or your channel is faulty # out of the scope of this field to do anything more than tell you it doesn't exist - raise forms.ValidationError(u"%s cannot find object: %s" % (lookup,value)) + raise forms.ValidationError(u"%s cannot find object: %s" % (lookup, value)) return objs[0] else: if self.required: raise forms.ValidationError(self.error_messages['required']) return None - def check_can_add(self,user,model): - _check_can_add(self,user,model) + def check_can_add(self, user, model): + _check_can_add(self, user, model) #################################################################################### @@ -133,14 +147,17 @@ class AutoCompleteSelectMultipleWidget(forms.widgets.SelectMultiple): """ widget to select multiple models """ + media = property(_media) + add_link = None def __init__(self, channel, help_text='', show_help_text=True, - plugin_options = {}, - *args, **kwargs): + plugin_options={}, + *args, + **kwargs): super(AutoCompleteSelectMultipleWidget, self).__init__(*args, **kwargs) self.channel = channel @@ -160,7 +177,8 @@ def render(self, name, value, attrs=None): # eg. value = [3002L, 1194L] if value: - current_ids = "|" + "|".join( str(pk) for pk in value ) + "|" # |pk|pk| of current + # |pk|pk| of current + current_ids = "|" + "|".join(str(pk) for pk in value) + "|" else: current_ids = "|" @@ -170,7 +188,7 @@ def render(self, name, value, attrs=None): initial = [] for obj in objects: display = lookup.format_item_display(obj) - initial.append([display,obj.pk]) + initial.append([display, obj.pk]) if self.show_help_text: help_text = self.help_text @@ -178,30 +196,28 @@ def render(self, name, value, attrs=None): help_text = u'' context = { - 'name':name, - 'html_id':self.html_id, - 'current':value, - 'current_ids':current_ids, - 'current_reprs':mark_safe(simplejson.dumps(initial)), - 'help_text':help_text, + 'name': name, + 'html_id': self.html_id, + 'current': value, + 'current_ids': current_ids, + 'current_reprs': mark_safe(simplejson.dumps(initial)), + 'help_text': help_text, 'extra_attrs': mark_safe(flatatt(final_attrs)), - 'func_slug': self.html_id.replace("-",""), - 'add_link' : self.add_link, + 'func_slug': self.html_id.replace("-", ""), + 'add_link': self.add_link, } - context.update(plugin_options(lookup,self.channel,self.plugin_options,initial)) - context.update(bootstrap()) + context.update(plugin_options(lookup, self.channel, self.plugin_options, initial)) - return mark_safe(render_to_string(('autocompleteselectmultiple_%s.html' % self.channel, 'autocompleteselectmultiple.html'),context)) + return mark_safe(render_to_string(('autocompleteselectmultiple_%s.html' % self.channel, 'autocompleteselectmultiple.html'), context)) def value_from_datadict(self, data, files, name): # eg. u'members': [u'|229|4688|190|'] - return [long(val) for val in data.get(name,'').split('|') if val] + return [long(val) for val in data.get(name, '').split('|') if val] def id_for_label(self, id_): return '%s_text' % id_ - class AutoCompleteSelectMultipleField(forms.fields.CharField): """ form field to select multiple models for a ManyToMany db field """ @@ -212,7 +228,7 @@ def __init__(self, channel, *args, **kwargs): self.channel = channel help_text = kwargs.get('help_text') - show_help_text = kwargs.pop('show_help_text',False) + show_help_text = kwargs.pop('show_help_text', False) if not (help_text is None): # '' will cause translation to fail @@ -230,7 +246,7 @@ def __init__(self, channel, *args, **kwargs): translated = help_text django_default_help = _(u'Hold down "Control", or "Command" on a Mac, to select more than one.').translate(settings.LANGUAGE_CODE) if django_default_help in translated: - cleaned_help = translated.replace(django_default_help,'').strip() + cleaned_help = translated.replace(django_default_help, '').strip() # probably will not show up in translations if cleaned_help: help_text = cleaned_help @@ -249,7 +265,7 @@ def __init__(self, channel, *args, **kwargs): 'channel': channel, 'help_text': help_text, 'show_help_text': show_help_text, - 'plugin_options': kwargs.pop('plugin_options',{}) + 'plugin_options': kwargs.pop('plugin_options', {}) } kwargs['widget'] = AutoCompleteSelectMultipleWidget(**widget_kwargs) kwargs['help_text'] = help_text @@ -259,20 +275,24 @@ def __init__(self, channel, *args, **kwargs): def clean(self, value): if not value and self.required: raise forms.ValidationError(self.error_messages['required']) - return value # a list of IDs from widget value_from_datadict + return value # a list of IDs from widget value_from_datadict - def check_can_add(self,user,model): - _check_can_add(self,user,model) + def check_can_add(self, user, model): + _check_can_add(self, user, model) #################################################################################### class AutoCompleteWidget(forms.TextInput): + """ Widget to select a search result and enter the result as raw text in the text input field. the user may also simply enter text and ignore any auto complete suggestions. """ + + media = property(_media) + channel = None help_text = '' html_id = '' @@ -280,8 +300,8 @@ class AutoCompleteWidget(forms.TextInput): def __init__(self, channel, *args, **kwargs): self.channel = channel self.help_text = kwargs.pop('help_text', '') - self.show_help_text = kwargs.pop('show_help_text',True) - self.plugin_options = kwargs.pop('plugin_options',{}) + self.show_help_text = kwargs.pop('show_help_text', True) + self.plugin_options = kwargs.pop('plugin_options', {}) super(AutoCompleteWidget, self).__init__(*args, **kwargs) @@ -305,17 +325,15 @@ def render(self, name, value, attrs=None): 'html_id': self.html_id, 'name': name, 'extra_attrs': mark_safe(flatatt(final_attrs)), - 'func_slug': self.html_id.replace("-",""), + 'func_slug': self.html_id.replace("-", ""), } - context.update(plugin_options(lookup,self.channel,self.plugin_options,initial)) - context.update(bootstrap()) + context.update(plugin_options(lookup, self.channel, self.plugin_options, initial)) templates = ('autocomplete_%s.html' % self.channel, 'autocomplete.html') return mark_safe(render_to_string(templates, context)) - class AutoCompleteField(forms.CharField): """ Field uses an AutoCompleteWidget to lookup possible completions using a channel and stores raw text (not a foreign key) @@ -326,15 +344,15 @@ def __init__(self, channel, *args, **kwargs): self.channel = channel widget_kwargs = dict( - help_text = kwargs.get('help_text', _(as_default_help)), - show_help_text = kwargs.pop('show_help_text',True), - plugin_options = kwargs.pop('plugin_options',{}) + help_text=kwargs.get('help_text', _(as_default_help)), + show_help_text=kwargs.pop('show_help_text', True), + plugin_options=kwargs.pop('plugin_options', {}) ) if 'attrs' in kwargs: widget_kwargs['attrs'] = kwargs.pop('attrs') - widget = AutoCompleteWidget(channel,**widget_kwargs) + widget = AutoCompleteWidget(channel, **widget_kwargs) - defaults = {'max_length': 255,'widget': widget} + defaults = {'max_length': 255, 'widget': widget} defaults.update(kwargs) super(AutoCompleteField, self).__init__(*args, **defaults) @@ -342,67 +360,52 @@ def __init__(self, channel, *args, **kwargs): #################################################################################### -def _check_can_add(self,user,model): +def _check_can_add(self, user, model): """ check if the user can add the model, deferring first to the channel if it implements can_add() else using django's default perm check. if it can add, then enable the widget to show the + link """ lookup = get_lookup(self.channel) - if hasattr(lookup,'can_add'): - can_add = lookup.can_add(user,model) + if hasattr(lookup, 'can_add'): + can_add = lookup.can_add(user, model) else: ctype = ContentType.objects.get_for_model(model) - can_add = user.has_perm("%s.add_%s" % (ctype.app_label,ctype.model)) + can_add = user.has_perm("%s.add_%s" % (ctype.app_label, ctype.model)) if can_add: - self.widget.add_link = reverse('add_popup', - kwargs={'app_label':model._meta.app_label,'model':model._meta.object_name.lower()}) + self.widget.add_link = reverse('add_popup', kwargs={'app_label': model._meta.app_label, 'model': model._meta.object_name.lower()}) -def autoselect_fields_check_can_add(form,model,user): +def autoselect_fields_check_can_add(form, model, user): """ check the form's fields for any autoselect fields and enable their widgets with + sign add links if permissions allow""" - for name,form_field in form.declared_fields.iteritems(): - if isinstance(form_field,(AutoCompleteSelectMultipleField,AutoCompleteSelectField)): + for name, form_field in form.declared_fields.iteritems(): + if isinstance(form_field, (AutoCompleteSelectMultipleField, AutoCompleteSelectField)): db_field = model._meta.get_field_by_name(name)[0] - form_field.check_can_add(user,db_field.rel.to) + form_field.check_can_add(user, db_field.rel.to) + -def plugin_options(channel,channel_name,widget_plugin_options,initial): +def plugin_options(channel, channel_name, widget_plugin_options, initial): """ Make a JSON dumped dict of all options for the jquery ui plugin itself """ po = {} if initial: po['initial'] = initial - po.update(getattr(channel,'plugin_options',{})) + po.update(getattr(channel, 'plugin_options', {})) po.update(widget_plugin_options) if not po.get('min_length'): # backward compatibility: honor the channel's min_length attribute # will deprecate that some day and prefer to use plugin_options po['min_length'] = getattr(channel, 'min_length', 1) if not po.get('source'): - po['source'] = reverse('ajax_lookup',kwargs={'channel':channel_name}) + po['source'] = reverse('ajax_lookup', kwargs={'channel': channel_name}) + + # allow html unless explictly false + if po.get('html') is None: + po['html'] = True + return { 'plugin_options': mark_safe(simplejson.dumps(po)), + 'data_plugin_options': force_escape(simplejson.dumps(po)), # continue to support any custom templates that still expect these 'lookup_url': po['source'], 'min_length': po['min_length'] } - - -def bootstrap(): - b = {} - b['bootstrap'] = getattr(settings,'AJAX_SELECT_BOOTSTRAP',False) - inlines = getattr(settings,'AJAX_SELECT_INLINES',None) - - b['inline'] = '' - if inlines == 'inline': - directory = os.path.dirname( os.path.realpath(__file__) ) - f = open(os.path.join(directory,"static","css","ajax_select.css")) - css = f.read() - f = open(os.path.join(directory,"static","js","ajax_select.js")) - js = f.read() - b['inline'] = mark_safe(u"""""" % (css,js)) - elif inlines == 'staticfiles': - b['inline'] = mark_safe("""""" % (settings.STATIC_URL,settings.STATIC_URL)) - - return b - - diff --git a/ajax_select/static/ajax_select/css/ajax_select.css b/ajax_select/static/ajax_select/css/ajax_select.css new file mode 100644 index 0000000000..43336e92e6 --- /dev/null +++ b/ajax_select/static/ajax_select/css/ajax_select.css @@ -0,0 +1,46 @@ +.results_on_deck .ui-icon-trash { + float: left; + cursor: pointer; +} +.results_on_deck { + padding: 0.25em 0; +} +form .aligned .results_on_deck { + padding-left: 38px; + margin-left: 7em; +} +.results_on_deck > div { + margin-bottom: 0.5em; +} +.ui-autocomplete-loading { + background: url('../images/loading-indicator.gif') no-repeat; + background-origin: content-box; + background-position: right; +} +ul.ui-autocomplete { +/* + this is the dropdown menu. + + if max-width is not set and you are using django-admin + then the dropdown is the width of your whole page body (totally wrong). + + this sets max-width at 60% which is graceful at full page or in a popup + or on a small width window. + + fixed width is harder see http://stackoverflow.com/questions/4607164/changing-width-of-jquery-ui-autocomplete-widgets-individually +*/ + max-width: 60%; + + margin: 0; + padding: 0; + position: absolute; +} +ul.ui-autocomplete li { + list-style-type: none; + padding: 0; +} +ul.ui-autocomplete li a { + display: block; + padding: 2px 3px; + cursor: pointer; +} diff --git a/ajax_select/static/images/loading-indicator.gif b/ajax_select/static/ajax_select/images/loading-indicator.gif similarity index 100% rename from ajax_select/static/images/loading-indicator.gif rename to ajax_select/static/ajax_select/images/loading-indicator.gif diff --git a/ajax_select/static/ajax_select/js/ajax_select.js b/ajax_select/static/ajax_select/js/ajax_select.js new file mode 100644 index 0000000000..5dc0326193 --- /dev/null +++ b/ajax_select/static/ajax_select/js/ajax_select.js @@ -0,0 +1,206 @@ +'use strict'; + +(function ($) { + + $.fn.autocompleteselect = function (options) { + return this.each(function () { + var id = this.id, + $this = $(this), + $text = $('#' + id + '_text'), + $deck = $('#' + id + '_on_deck'); + + function receiveResult(event, ui) { + if ($this.val()) { + kill(); + } + $this.val(ui.item.pk); + $text.val(''); + addKiller(ui.item.repr); + $deck.trigger('added'); + + return false; + } + + function addKiller(repr, pk) { + var killer_id = 'kill_' + pk + id, + killButton = 'X '; + if (repr) { + $deck.empty(); + $deck.append('
' + killButton + repr + '
'); + } else { + $('#' + id+'_on_deck > div').prepend(killButton); + } + $('#' + killer_id).click(function () { + kill(); + $deck.trigger('killed'); + }); + } + + function kill() { + $this.val(''); + $deck.children().fadeOut(1.0).remove(); + } + + options.select = receiveResult; + $text.autocomplete(options); + + if (options.initial) { + addKiller(options.initial[0], options.initial[1]); + } + + $this.bind('didAddPopup', function (event, pk, repr) { + receiveResult(null, {item: {pk: pk, repr: repr}}); + }); + }); + }; + + $.fn.autocompleteselectmultiple = function (options) { + return this.each(function () { + var id = this.id, + $this = $(this), + $text = $('#' + id+'_text'), + $deck = $('#' + id+'_on_deck'); + + function receiveResult(event, ui) { + var pk = ui.item.pk, + prev = $this.val(); + + if (prev.indexOf('|'+pk+'|') === -1) { + $this.val((prev ? prev : '|') + pk + '|'); + addKiller(ui.item.repr, pk); + $text.val(''); + $deck.trigger('added'); + } + return false; + } + + function addKiller(repr, pk) { + var killer_id = 'kill_' + pk + id, + killButton = 'X '; + $deck.append('
' + killButton + repr + '
'); + + $('#' + killer_id).click(function () { + kill(pk); + $deck.trigger('killed'); + }); + } + + function kill(pk) { + $this.val($this.val().replace('|' + pk + '|', '|')); + $('#' + id+'_on_deck_'+pk).fadeOut().remove(); + } + + options.select = receiveResult; + $text.autocomplete(options); + + if (options.initial) { + $.each(options.initial, function (i, its) { + addKiller(its[0], its[1]); + }); + } + + $this.bind('didAddPopup', function (event, pk, repr) { + receiveResult(null, {item: {pk: pk, repr: repr }}); + }); + }); + }; + + function addAutoComplete (inp, callback) { + var $inp = $(inp), + html_id = inp.id, + prefix_id = html_id, + opts = JSON.parse($inp.attr('data-plugin-options')), + prefix = 0; + + /* detects inline forms and converts the html_id if needed */ + if (html_id.indexOf('__prefix__') !== -1) { + // Some dirty loop to find the appropriate element to apply the callback to + while ($('#' + html_id).length) { + html_id = prefix_id.replace(/__prefix__/, prefix++); + } + html_id = prefix_id.replace(/__prefix__/, prefix - 2); + // Ignore the first call to this function, the one that is triggered when + // page is loaded just because the 'empty' form is there. + if ($('#' + html_id + ', #' + html_id + '_text').hasClass('ui-autocomplete-input')) { + return; + } + } + + callback($inp, opts); + } + + // allow html in the results menu + // https://github.com/scottgonzalez/jquery-ui-extensions + var proto = $.ui.autocomplete.prototype, + initSource = proto._initSource; + + function filter(array, term) { + var matcher = new RegExp($.ui.autocomplete.escapeRegex(term), 'i'); + return $.grep(array, function(value) { + return matcher.test($('
').html(value.label || value.value || value).text()); + }); + } + + $.extend(proto, { + _initSource: function() { + if (this.options.html && $.isArray(this.options.source)) { + this.source = function(request, response) { + response(filter(this.options.source, request.term)); + }; + } else { + initSource.call(this); + } + }, + _renderItem: function(ul, item) { + var body = this.options.html ? item.repr : item.label; + return $('
  • ') + .data('item.autocomplete', item) + .append($('')[this.options.html ? 'html' : 'text' ](body)) + .appendTo(ul); + } + }); + + /* the popup handler + requires RelatedObjects.js which is part of the django admin js + so if using outside of the admin then you would need to include that manually */ + window.didAddPopup = function (win, newId, newRepr) { + var name = window.windowname_to_id(win.name); + $('#' + name).trigger('didAddPopup', [window.html_unescape(newId), window.html_unescape(newRepr)]); + win.close(); + }; + + // activate any on page + $(window).bind('init-autocomplete', function () { + + $('input[data-ajax-select=autocomplete]').each(function (i, inp) { + addAutoComplete(inp, function ($inp, opts) { + opts.select = + function (event, ui) { + $inp.val(ui.item.value).trigger('added'); + return false; + }; + $inp.autocomplete(opts); + }); + }); + + $('input[data-ajax-select=autocompleteselect]').each(function (i, inp) { + addAutoComplete(inp, function ($inp, opts) { + $inp.autocompleteselect(opts); + }); + }); + + $('input[data-ajax-select=autocompleteselectmultiple]').each(function (i, inp) { + addAutoComplete(inp, function ($inp, opts) { + $inp.autocompleteselectmultiple(opts); + }); + }); + + }); + + $(document).ready(function () { + // if dynamically injecting forms onto a page + // you can trigger them to be ajax-selects-ified: + $(window).trigger('init-autocomplete'); + }); + +})(window.jQuery); diff --git a/ajax_select/static/ajax_select/js/bootstrap.js b/ajax_select/static/ajax_select/js/bootstrap.js new file mode 100644 index 0000000000..343ce89c9a --- /dev/null +++ b/ajax_select/static/ajax_select/js/bootstrap.js @@ -0,0 +1,13 @@ +// load jquery and jquery-ui if needed +// into window.jQuery +if (typeof jQuery === 'undefined') { + try { // use django admins + jQuery=django.jQuery; + } catch(err) { + document.write(' diff --git a/ajax_select/templates/autocomplete.html b/ajax_select/templates/autocomplete.html index fac7aa6d92..1cb153acf0 100644 --- a/ajax_select/templates/autocomplete.html +++ b/ajax_select/templates/autocomplete.html @@ -1,21 +1,3 @@ -{% if bootstrap %}{% include "ajax_select/bootstrap.html" %}{% endif %} - - {% block help %}{% if help_text %}

    {{ help_text }}

    {% endif %}{% endblock %} -{{ inline }} diff --git a/ajax_select/templates/autocompleteselect.html b/ajax_select/templates/autocompleteselect.html index 83c99dabc0..77b67b01bc 100644 --- a/ajax_select/templates/autocompleteselect.html +++ b/ajax_select/templates/autocompleteselect.html @@ -1,19 +1,10 @@ -{% if bootstrap %}{% include "ajax_select/bootstrap.html" %}{% endif %} - + {% if add_link %} add {% endif %} - +
    {{current_repr|safe}}
    - {% block help %}{% if help_text %}

    {{help_text}}

    {% endif %}{% endblock %}
    -{{ inline }} diff --git a/ajax_select/templates/autocompleteselectmultiple.html b/ajax_select/templates/autocompleteselectmultiple.html index 6f0aaa7f35..19eeca2888 100644 --- a/ajax_select/templates/autocompleteselectmultiple.html +++ b/ajax_select/templates/autocompleteselectmultiple.html @@ -1,19 +1,9 @@ -{% if bootstrap %}{% include "ajax_select/bootstrap.html" %}{% endif %} - + {% if add_link %} add {% endif %} - +
    - {# django admin adds the help text. this is for use outside of the admin #} {% block help %}{% if help_text %}

    {{help_text}}

    {% endif %}{% endblock %} -{{ inline }} diff --git a/ajax_select/urls.py b/ajax_select/urls.py index 42e9ca8830..35ba1e8e2f 100644 --- a/ajax_select/urls.py +++ b/ajax_select/urls.py @@ -1,15 +1,16 @@ - -from django.conf.urls.defaults import * +try: + from django.conf.urls import * +except: + from django.conf.urls.defaults import * urlpatterns = patterns('', url(r'^ajax_lookup/(?P[-\w]+)$', 'ajax_select.views.ajax_lookup', - name = 'ajax_lookup' + name='ajax_lookup' ), url(r'^add_popup/(?P\w+)/(?P\w+)$', 'ajax_select.views.add_popup', - name = 'add_popup' + name='add_popup' ) ) - diff --git a/ajax_select/views.py b/ajax_select/views.py index e886e15ec2..d5ec4d1528 100644 --- a/ajax_select/views.py +++ b/ajax_select/views.py @@ -6,7 +6,7 @@ from django.utils import simplejson -def ajax_lookup(request,channel): +def ajax_lookup(request, channel): """ this view supplies results for foreign keys and many to many fields """ @@ -19,23 +19,23 @@ def ajax_lookup(request,channel): query = request.GET['term'] else: if 'term' not in request.POST: - return HttpResponse('') # suspicious + return HttpResponse('') # suspicious query = request.POST['term'] lookup = get_lookup(channel) - if hasattr(lookup,'check_auth'): + if hasattr(lookup, 'check_auth'): lookup.check_auth(request) if len(query) >= getattr(lookup, 'min_length', 1): - instances = lookup.get_query(query,request) + instances = lookup.get_query(query, request) else: instances = [] results = simplejson.dumps([ { - 'pk': unicode(getattr(item,'pk',None)), + 'pk': unicode(getattr(item, 'pk', None)), 'value': lookup.get_result(item), - 'match' : lookup.format_match(item), + 'match': lookup.format_match(item), 'repr': lookup.format_item_display(item) } for item in instances ]) @@ -43,7 +43,7 @@ def ajax_lookup(request,channel): return HttpResponse(results, mimetype='application/javascript') -def add_popup(request,app_label,model): +def add_popup(request, app_label, model): """ this presents the admin site popup add view (when you click the green +) make sure that you have added ajax_select.urls to your urls.py: @@ -60,9 +60,17 @@ def add_popup(request,app_label,model): # TODO : should detect where we really are admin.admin_site.root_path = "/ajax_select/" - response = admin.add_view(request,request.path) + response = admin.add_view(request, request.path) if request.method == 'POST': - if 'opener.dismissAddAnotherPopup' in response.content: - return HttpResponse( response.content.replace('dismissAddAnotherPopup','didAddPopup' ) ) + try: + # this detects TemplateResponse which are not yet rendered + # and are returned for form validation errors + if not response.is_rendered: + out = response.rendered_content + else: + out = response.content + except AttributeError: # django < 1.5 + out = response.content + if 'opener.dismissAddAnotherPopup' in out: + return HttpResponse(out.replace('dismissAddAnotherPopup', 'didAddPopup')) return response - diff --git a/example/Makefile b/example/Makefile new file mode 100644 index 0000000000..0d2eb15541 --- /dev/null +++ b/example/Makefile @@ -0,0 +1,25 @@ + + +install: + $(shell ./install.sh $(DJANGO) 1>&2) + @echo + @echo NOW DO: + @echo source AJAXSELECTS/bin/active + @echo ./manage.py runserver + @echo browse: http://localhost:8080/admin/ + +clean: + @echo Deleting virtualenv + rm -rf AJAXSELECTS + +cleandb: + @echo Removing sqllite db + rm ajax_selects_example + +help: + @echo make install + @echo or: + @echo make clean install DJANGO=1.4.2 + + +.PHONY: install clean cleandb help diff --git a/example/admin.py b/example/admin.py deleted file mode 100644 index 01ea3ea652..0000000000 --- a/example/admin.py +++ /dev/null @@ -1,86 +0,0 @@ - -from django.contrib import admin -from ajax_select import make_ajax_form -from ajax_select.admin import AjaxSelectAdmin -from example.forms import ReleaseForm -from example.models import * - - - -class PersonAdmin(admin.ModelAdmin): - - pass - -admin.site.register(Person,PersonAdmin) - - - -class LabelAdmin(AjaxSelectAdmin): - """ to get + popup buttons, subclass AjaxSelectAdmin - - multi-inheritance is also possible if you have an Admin class you want to inherit from: - - class PersonAdmin(YourAdminSuperclass,AjaxSelectAdmin): - - this acts as a MixIn to add the relevant methods - """ - # this shows a ForeignKey field - - # create an ajax form class using the factory function - # model,fieldlist, [form superclass] - form = make_ajax_form(Label,{'owner':'person'}) - -admin.site.register(Label,LabelAdmin) - - - -class GroupAdmin(AjaxSelectAdmin): - - # this shows a ManyToMany field - form = make_ajax_form(Group,{'members':'person'}) - -admin.site.register(Group,GroupAdmin) - - - -class SongAdmin(AjaxSelectAdmin): - - form = make_ajax_form(Song,{'group':'group','title':'cliche'}) - -admin.site.register(Song,SongAdmin) - - - -class ReleaseAdmin(AjaxSelectAdmin): - - # specify a form class manually (normal django way) - # see forms.py - form = ReleaseForm - -admin.site.register(Release,ReleaseAdmin) - - - -class BookInline(admin.TabularInline): - - model = Book - form = make_ajax_form(Book,{'about_group':'group','mentions_persons':'person'},show_help_text=True) - extra = 2 - - # + check add still not working - # no + appearing - # def get_formset(self, request, obj=None, **kwargs): - # from ajax_select.fields import autoselect_fields_check_can_add - # fs = super(BookInline,self).get_formset(request,obj,**kwargs) - # autoselect_fields_check_can_add(fs.form,self.model,request.user) - # return fs - -class AuthorAdmin(admin.ModelAdmin): - inlines = [ - BookInline, - ] - -admin.site.register(Author, AuthorAdmin) - - - diff --git a/example/example/__init__.py b/example/example/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/example/example/admin.py b/example/example/admin.py new file mode 100644 index 0000000000..2c8f30da5a --- /dev/null +++ b/example/example/admin.py @@ -0,0 +1,86 @@ + +from django.contrib import admin +from ajax_select import make_ajax_form +from ajax_select.admin import AjaxSelectAdmin, AjaxSelectAdminTabularInline +from example.forms import ReleaseForm +from example.models import * + + +class PersonAdmin(AjaxSelectAdmin): + + pass + +admin.site.register(Person, PersonAdmin) + + +class LabelAdmin(AjaxSelectAdmin): + """ to get + popup buttons, subclass AjaxSelectAdmin + + multi-inheritance is also possible if you have an Admin class you want to inherit from: + + class PersonAdmin(YourAdminSuperclass, AjaxSelectAdmin): + + this acts as a MixIn to add the relevant methods + """ + # this shows a ForeignKey field + + # create an ajax form class using the factory function + # model, fieldlist, [form superclass] + form = make_ajax_form(Label, {'owner': 'person'}) + +admin.site.register(Label, LabelAdmin) + + +class GroupAdmin(AjaxSelectAdmin): + + # this shows a ManyToMany field + form = make_ajax_form(Group, {'members': 'person'}) + +admin.site.register(Group, GroupAdmin) + + +class SongAdmin(AjaxSelectAdmin): + + form = make_ajax_form(Song, {'group': 'group', 'title': 'cliche'}) + +admin.site.register(Song, SongAdmin) + + +class ReleaseAdmin(AjaxSelectAdmin): + + # specify a form class manually (normal django way) + # see forms.py + form = ReleaseForm + +admin.site.register(Release, ReleaseAdmin) + + +class BookInline(AjaxSelectAdminTabularInline): + + # AjaxSelectAdminTabularInline enables the + add option + + model = Book + form = make_ajax_form(Book, { + 'about_group': 'group', + 'mentions_persons': 'person' + }, + show_help_text=True) + extra = 2 + + # to enable the + add option + # instead of your inline inheriting from AjaxSelectAdminTabularInline + # you could implement this + # def get_formset(self, request, obj=None, **kwargs): + # from ajax_select.fields import autoselect_fields_check_can_add + # fs = super(BookInline, self).get_formset(request, obj,**kwargs) + # autoselect_fields_check_can_add(fs.form, self.model, request.user) + # return fs + + +class AuthorAdmin(AjaxSelectAdmin): + + inlines = [ + BookInline, + ] + +admin.site.register(Author, AuthorAdmin) diff --git a/example/forms.py b/example/example/forms.py similarity index 50% rename from example/forms.py rename to example/example/forms.py index 04217032e9..8a4db7e945 100644 --- a/example/forms.py +++ b/example/example/forms.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- -from django import forms from django.forms.models import ModelForm from ajax_select import make_ajax_field from example.models import Release @@ -12,16 +11,15 @@ class Meta: model = Release # args: this model, fieldname on this model, lookup_channel_name - group = make_ajax_field(Release,'group','group',show_help_text=True) + group = make_ajax_field(Release, 'group', 'group', show_help_text=True) - label = make_ajax_field(Release,'label','label',help_text="Search for label by name") + label = make_ajax_field(Release, 'label', 'label', help_text="Search for label by name") # any extra kwargs are passed onto the field, so you may pass a custom help_text here - #songs = make_ajax_field(Release,'songs','song',help_text=u"Search for song by title") + #songs = make_ajax_field(Release,'songs','song', help_text=u"Search for song by title") # testing bug with no help text supplied - songs = make_ajax_field(Release,'songs','song',help_text="",show_help_text=True) + songs = make_ajax_field(Release, 'songs', 'song', help_text="", show_help_text=True) # these are from a fixed array defined in lookups.py - title = make_ajax_field(Release,'title','cliche',help_text=u"Autocomplete will suggest clichés about cats.") - + title = make_ajax_field(Release, 'title', 'cliche', help_text=u"Autocomplete will suggest clichés about cats.") diff --git a/example/lookups.py b/example/example/lookups.py similarity index 67% rename from example/lookups.py rename to example/example/lookups.py index ce1a2e3125..31ddcc2742 100644 --- a/example/lookups.py +++ b/example/example/lookups.py @@ -9,40 +9,40 @@ class PersonLookup(LookupChannel): model = Person - def get_query(self,q,request): + def get_query(self, q, request): return Person.objects.filter(Q(name__icontains=q) | Q(email__istartswith=q)).order_by('name') - def get_result(self,obj): + def get_result(self, obj): u""" result is the simple text that is the completion of what the person typed """ return obj.name - def format_match(self,obj): + def format_match(self, obj): """ (HTML) formatted item for display in the dropdown """ - return self.format_item_display(obj) + return u"%s
    %s
    " % (escape(obj.name), escape(obj.email)) + # return self.format_item_display(obj) - def format_item_display(self,obj): + def format_item_display(self, obj): """ (HTML) formatted item for displaying item in the selected deck area """ - return u"%s
    %s
    " % (escape(obj.name),escape(obj.email)) - + return u"%s
    %s
    " % (escape(obj.name), escape(obj.email)) class GroupLookup(LookupChannel): model = Group - def get_query(self,q,request): + def get_query(self, q, request): return Group.objects.filter(name__icontains=q).order_by('name') - def get_result(self,obj): + def get_result(self, obj): return unicode(obj) - def format_match(self,obj): + def format_match(self, obj): return self.format_item_display(obj) - def format_item_display(self,obj): - return u"%s
    %s
    " % (escape(obj.name),escape(obj.url)) + def format_item_display(self, obj): + return u"%s
    %s
    " % (escape(obj.name), escape(obj.url)) - def can_add(self,user,model): + def can_add(self, user, model): """ customize can_add by allowing anybody to add a Group. the superclass implementation uses django's permissions system to check. only those allowed to add will be offered a [+ add] popup link @@ -54,18 +54,17 @@ class SongLookup(LookupChannel): model = Song - def get_query(self,q,request): + def get_query(self, q, request): return Song.objects.filter(title__icontains=q).select_related('group').order_by('title') - def get_result(self,obj): + def get_result(self, obj): return unicode(obj.title) - def format_match(self,obj): + def format_match(self, obj): return self.format_item_display(obj) - def format_item_display(self,obj): - return "%s
    by %s
    " % (escape(obj.title),escape(obj.group.name)) - + def format_item_display(self, obj): + return "%s
    by %s
    " % (escape(obj.title), escape(obj.group.name)) class ClicheLookup(LookupChannel): @@ -82,7 +81,7 @@ class ClicheLookup(LookupChannel): u"let the cat out of the bag", u"fat cat", u"the early bird catches the worm", - u"catch as catch can", + u"catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as catch as catch can as can", u"you can catch more flies with honey than with vinegar", u"catbird seat", u"cat's paw", @@ -130,15 +129,14 @@ class ClicheLookup(LookupChannel): u"cat's pajamas", u"cat got your tongue?"] - def get_query(self,q,request): + def get_query(self, q, request): return sorted([w for w in self.words if q in w]) - def get_result(self,obj): + def get_result(self, obj): return obj - def format_match(self,obj): + def format_match(self, obj): return escape(obj) - def format_item_display(self,obj): + def format_item_display(self, obj): return escape(obj) - diff --git a/example/example/models.py b/example/example/models.py new file mode 100644 index 0000000000..c635752420 --- /dev/null +++ b/example/example/models.py @@ -0,0 +1,88 @@ +# -*- coding: utf8 -*- + +from django.db import models + + +class Person(models.Model): + + """ an actual singular human being """ + name = models.CharField(blank=True, max_length=100) + email = models.EmailField() + + def __unicode__(self): + return self.name + + +class Group(models.Model): + + """ a music group """ + + name = models.CharField(max_length=200, unique=True, help_text="Name of the group") + members = models.ManyToManyField(Person, blank=True, help_text="Enter text to search for and add each member of the group.") + url = models.URLField(blank=True) + + def __unicode__(self): + return self.name + + +class Label(models.Model): + + """ a record label """ + + name = models.CharField(max_length=200, unique=True) + owner = models.ForeignKey(Person, blank=True, null=True) + url = models.URLField(blank=True) + + def __unicode__(self): + return self.name + + +class Song(models.Model): + + """ a song """ + + title = models.CharField(blank=False, max_length=200) + group = models.ForeignKey(Group) + + def __unicode__(self): + return self.title + + +class Release(models.Model): + + """ a music release/product """ + + title = models.CharField(max_length=100) + catalog = models.CharField(blank=True, max_length=100) + + group = models.ForeignKey(Group, blank=True, null=True, verbose_name=u"Русский текст (group)") + label = models.ForeignKey(Label, blank=False, null=False) + songs = models.ManyToManyField(Song, blank=True) + + def __unicode__(self): + return self.title + + +class Author(models.Model): + + """ Author has multiple books, + via foreign keys + """ + + name = models.CharField(max_length=100) + + def __unicode__(self): + return self.name + + +class Book(models.Model): + + """ Book has no admin, its an inline in the Author admin""" + + author = models.ForeignKey(Author) + title = models.CharField(max_length=100) + about_group = models.ForeignKey(Group) + mentions_persons = models.ManyToManyField(Person, help_text="Person lookup renders html in menu") + + def __unicode__(self): + return self.title diff --git a/example/settings.py b/example/example/settings.py similarity index 57% rename from example/settings.py rename to example/example/settings.py index 8b2de5c6f6..b824a7586e 100644 --- a/example/settings.py +++ b/example/example/settings.py @@ -8,10 +8,11 @@ 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.admin', + 'django.contrib.staticfiles', 'example', #################################### - 'ajax_select', # <- add the app + 'ajax_select', # <- add the app #################################### ) @@ -21,44 +22,26 @@ # DEFINE THE SEARCH CHANNELS: AJAX_LOOKUP_CHANNELS = { - # simplest way, automatically construct a search channel by passing a dictionary - 'label' : {'model':'example.label', 'search_field':'name'}, + # simplest way, automatically construct a search channel by passing a dict + 'label': {'model': 'example.label', 'search_field': 'name'}, # Custom channels are specified with a tuple # channel: ( module.where_lookup_is, ClassNameOfLookup ) - 'person' : ('example.lookups', 'PersonLookup'), - 'group' : ('example.lookups', 'GroupLookup'), - 'song' : ('example.lookups', 'SongLookup'), - 'cliche' : ('example.lookups','ClicheLookup') + 'person': ('example.lookups', 'PersonLookup'), + 'group': ('example.lookups', 'GroupLookup'), + 'song': ('example.lookups', 'SongLookup'), + 'cliche': ('example.lookups', 'ClicheLookup') } -AJAX_SELECT_BOOTSTRAP = True -# True: [easiest] -# use the admin's jQuery if present else load from jquery's CDN -# use jqueryUI if present else load from jquery's CDN -# use jqueryUI theme if present else load one from jquery's CDN -# False/None/Not set: [default] -# you should include jQuery, jqueryUI + theme in your template - - -AJAX_SELECT_INLINES = 'inline' -# 'inline': [easiest] -# includes the js and css inline -# this gets you up and running easily -# but on large admin pages or with higher traffic it will be a bit wasteful. -# 'staticfiles': -# @import the css/js from {{STATIC_URL}}/ajax_selects using django's staticfiles app -# requires staticfiles to be installed and to run its management command to collect files -# this still includes the css/js multiple times and is thus inefficient -# but otherwise harmless -# False/None: [default] -# does not inline anything. include the css/js files in your compressor stack -# or include them in the head of the admin/base_site.html template -# this is the most efficient but takes the longest to configure - -# when using staticfiles you may implement your own ajax_select.css and customize to taste +# By default will use window.jQuery +# or Django Admin's jQuery +# or load one from google ajax apis +# then load jquery-ui and a default css +# Set this to False if for some reason you want to supply your own +# window.jQuery and jQuery UI +# AJAX_SELECT_BOOTSTRAP = False ########################################################################### @@ -75,12 +58,12 @@ MANAGERS = ADMINS -DATABASE_ENGINE = 'sqlite3' # 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'. -DATABASE_NAME = 'ajax_selects_example' # Or path to database file if using sqlite3. +DATABASE_ENGINE = 'sqlite3' +DATABASE_NAME = 'ajax_selects_example' DATABASE_USER = '' # Not used with sqlite3. DATABASE_PASSWORD = '' # Not used with sqlite3. -DATABASE_HOST = '' # Set to empty string for localhost. Not used with sqlite3. -DATABASE_PORT = '' # Set to empty string for default. Not used with sqlite3. +DATABASE_HOST = '' # Not used with sqlite3. +DATABASE_PORT = '' # Not used with sqlite3. DATABASES = { 'default': { @@ -122,7 +105,7 @@ # URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a # trailing slash. # Examples: "http://foo.com/media/", "/media/". -STATIC_URL = '/media/' +STATIC_URL = '/static/' # Make this unique, and don't share it with nobody. SECRET_KEY = '=9fhrrwrazha6r_m)r#+in*@n@i322ubzy4r+zz%wz$+y(=qpb' @@ -131,7 +114,7 @@ ROOT_URLCONF = 'example.urls' TEMPLATE_DIRS = ( - # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". + # Put strings here, like "/home/html/django_templates" # Always use forward slashes, even on Windows. # Don't forget to use absolute paths, not relative paths. ) diff --git a/example/example/urls.py b/example/example/urls.py new file mode 100644 index 0000000000..62796851c8 --- /dev/null +++ b/example/example/urls.py @@ -0,0 +1,17 @@ +try: + from django.conf.urls import * +except: + from django.conf.urls.defaults import * +from django.conf.urls.static import static +from django.contrib import admin +from django.conf import settings +from ajax_select import urls as ajax_select_urls + + +admin.autodiscover() + +urlpatterns = patterns('', + url(r'^search_form', view='example.views.search_form', name='search_form'), + (r'^admin/lookups/', include(ajax_select_urls)), + (r'^admin/', include(admin.site.urls)), +) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/example/views.py b/example/example/views.py similarity index 80% rename from example/views.py rename to example/example/views.py index bd1d497706..84a832ba9b 100644 --- a/example/views.py +++ b/example/example/views.py @@ -16,12 +16,13 @@ class SearchForm(forms.Form): attrs={'size': 100} ) + def search_form(request): dd = {} if 'q' in request.GET: dd['entered'] = request.GET.get('q') - initial = {'q':"\"This is an initial value,\" said O'Leary."} + initial = {'q': "\"This is an initial value,\" said O'Leary."} form = SearchForm(initial=initial) dd['form'] = form - return render_to_response('search_form.html',dd,context_instance=RequestContext(request)) + return render_to_response('search_form.html', dd, context_instance=RequestContext(request)) diff --git a/example/install.sh b/example/install.sh index 3ed42d344a..7fce4d2d9f 100755 --- a/example/install.sh +++ b/example/install.sh @@ -1,17 +1,36 @@ +#!/bin/sh -# creates a virtualenv and installs a django here +# break on any error +set -e + +# creates a virtualenv virtualenv AJAXSELECTS source AJAXSELECTS/bin/activate -pip install django -# put ajax selects in the path -ln -s ../ajax_select/ ./ajax_select +DJANGO=$1 +if [ "$DJANGO" != "" ]; then + echo "Installing Django $DJANGO:" + pip install Django==$DJANGO +else + echo "Installing latest django:" + pip install django +fi -# create sqllite database +echo "Creating a sqllite database:" ./manage.py syncdb -echo "type 'source AJAXSELECTS/bin/activate' to activate the virtualenv" -echo "then run: ./manage.py runserver" -echo "and visit http://127.0.0.1:8000/admin/" -echo "type 'deactivate' to close the virtualenv or just close the shell" +if [ ! -d ./ajax_select ]; then + echo "\nSymlinking ajax_select into this app directory:" + ln -s ../ajax_select/ ./ajax_select +fi + +echo "\nto activate the virtualenv:\nsource AJAXSELECTS/bin/activate" + +echo '\nto create an admin account:' +echo './manage.py createsuperuser' + +echo "\nto run the testserver:\n./manage.py runserver" +echo "\nthen open this url:\nhttp://127.0.0.1:8000/admin/" +echo "\nto close the virtualenv or just close the shell:\ndeactivate" +exit 0 diff --git a/example/manage-1.3.x.py b/example/manage-1.3.x.py new file mode 100644 index 0000000000..5e78ea979e --- /dev/null +++ b/example/manage-1.3.x.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python +from django.core.management import execute_manager +try: + import settings # Assumed to be in the same directory. +except ImportError: + import sys + sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) + sys.exit(1) + +if __name__ == "__main__": + execute_manager(settings) diff --git a/example/manage.py b/example/manage.py index 5e78ea979e..2605e3768e 100755 --- a/example/manage.py +++ b/example/manage.py @@ -1,11 +1,10 @@ #!/usr/bin/env python -from django.core.management import execute_manager -try: - import settings # Assumed to be in the same directory. -except ImportError: - import sys - sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) - sys.exit(1) +import os +import sys if __name__ == "__main__": - execute_manager(settings) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/example/models.py b/example/models.py deleted file mode 100644 index 4bc0714eb4..0000000000 --- a/example/models.py +++ /dev/null @@ -1,75 +0,0 @@ -# -*- coding: utf8 -*- - -from django.db import models - - -class Person(models.Model): - - """ an actual singular human being """ - name = models.CharField(blank=True, max_length=100) - email = models.EmailField() - - def __unicode__(self): - return self.name - - -class Group(models.Model): - - """ a music group """ - - name = models.CharField(max_length=200,unique=True) - members = models.ManyToManyField(Person,blank=True,help_text="Enter text to search for and add each member of the group.") - url = models.URLField(blank=True, verify_exists=False) - - def __unicode__(self): - return self.name - - -class Label(models.Model): - - """ a record label """ - - name = models.CharField(max_length=200,unique=True) - owner = models.ForeignKey(Person,blank=True,null=True) - url = models.URLField(blank=True, verify_exists=False) - - def __unicode__(self): - return self.name - - -class Song(models.Model): - - """ a song """ - - title = models.CharField(blank=False, max_length=200) - group = models.ForeignKey(Group) - - def __unicode__(self): - return self.title - - -class Release(models.Model): - - """ a music release/product """ - - title = models.CharField(max_length=100) - catalog = models.CharField(blank=True, max_length=100) - - group = models.ForeignKey(Group,blank=True,null=True,verbose_name=u"Русский текст") - label = models.ForeignKey(Label,blank=False,null=False) - songs = models.ManyToManyField(Song,blank=True) - - def __unicode__(self): - return self.title - - - -class Author(models.Model): - name = models.CharField(max_length=100) - -class Book(models.Model): - author = models.ForeignKey(Author) - title = models.CharField(max_length=100) - about_group = models.ForeignKey(Group) - mentions_persons = models.ManyToManyField(Person) - diff --git a/example/urls.py b/example/urls.py deleted file mode 100644 index 765def8b4c..0000000000 --- a/example/urls.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.conf.urls.defaults import * - -from django.contrib import admin -from ajax_select import urls as ajax_select_urls - -admin.autodiscover() - -urlpatterns = patterns('', - url(r'^search_form', view='views.search_form',name='search_form'), - (r'^admin/lookups/', include(ajax_select_urls)), - (r'^admin/', include(admin.site.urls)), -) diff --git a/setup.py b/setup.py index 45c1cdddcb..14a4eb9229 100644 --- a/setup.py +++ b/setup.py @@ -7,15 +7,28 @@ use_setuptools() from setuptools import setup -setup(name='django-ajax-selects', - version='1.2.5', +setup( + name='django-ajax-selects', + version='1.3.0', description='jQuery-powered auto-complete fields for editing ForeignKey, ManyToManyField and CharField', author='crucialfelix', author_email='crucialfelix@gmail.com', url='https://github.com/crucialfelix/django-ajax-selects/', - packages=['ajax_select', ], - package_data={'ajax_select': ['*.py','*.txt','static/css/*','static/images/*','static/js/*','templates/*.html', 'templates/ajax_select/*.html']}, - classifiers = [ + packages=['ajax_select'], + package_data={'ajax_select': + [ + '*.py', + '*.txt', + 'static/ajax_select/css/*', + 'static/ajax_select/images/*', + 'static/ajax_select/js/*', + 'templates/*.html', + 'templates/ajax_select/*.html' + ] + }, + include_package_data=True, + zip_safe=False, + classifiers=[ "Programming Language :: Python", "Programming Language :: Python :: 2", "Development Status :: 5 - Production/Stable", @@ -27,7 +40,7 @@ "Topic :: Software Development :: User Interfaces", "Framework :: Django", ], - long_description = """\ + long_description="""\ Enables editing of `ForeignKey`, `ManyToManyField` and `CharField` using jQuery UI AutoComplete. 1. The user types a search term into the text field @@ -37,7 +50,7 @@ 5. Selected result displays in the "deck" area directly below the input field. 6. User can click trashcan icon to remove a selected item -+ Django 1.2+ ++ Django 1.4+ + Optional boostrap mode allows easy installation by automatic inclusion of jQueryUI from the googleapis CDN + Compatible with staticfiles, appmedia, django-compressor etc + Popup to add a new item is supported