├── __init__.py ├── .gitignore ├── ajax_select ├── models.py ├── static │ └── ajax_select │ │ ├── loading-indicator.gif │ │ ├── js │ │ └── ajax_select.js │ │ └── iconic.css ├── LICENSE.txt ├── urls.py ├── admin.py ├── templates │ ├── autocomplete.html │ ├── autocompleteselect.html │ └── autocompleteselectmultiple.html ├── views.py ├── __init__.py ├── fields.py └── docs.txt ├── MANIFEST.in ├── README.txt └── setup.py /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info/ -------------------------------------------------------------------------------- /ajax_select/models.py: -------------------------------------------------------------------------------- 1 | # blank file so django recognizes the app -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include ajax_select *.css *.py *.gif *.html *.txt *.js -------------------------------------------------------------------------------- /ajax_select/static/ajax_select/loading-indicator.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buriy/django-ajax-selects/HEAD/ajax_select/static/ajax_select/loading-indicator.gif -------------------------------------------------------------------------------- /ajax_select/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Chris Sattinger 2 | 3 | Dual licensed under the MIT and GPL licenses: 4 | http://www.opensource.org/licenses/mit-license.php 5 | http://www.gnu.org/licenses/gpl.html 6 | -------------------------------------------------------------------------------- /README.txt: -------------------------------------------------------------------------------- 1 | 2 | Enables editing of `ForeignKey`, `ManyToMany` and simple text fields using the Autocomplete - `jQuery` plugin. 3 | 4 | django-ajax-selects will work in any normal form as well as in the admin. 5 | 6 | See docs.txt 7 | 8 | -------------------------------------------------------------------------------- /ajax_select/static/ajax_select/js/ajax_select.js: -------------------------------------------------------------------------------- 1 | 2 | /* requires RelatedObjects.js */ 3 | 4 | function didAddPopup(win,newId,newRepr) { 5 | var name = windowname_to_id(win.name); 6 | $("#"+name).trigger('didAddPopup',[html_unescape(newId),html_unescape(newRepr)]); 7 | win.close(); 8 | } 9 | 10 | -------------------------------------------------------------------------------- /ajax_select/urls.py: -------------------------------------------------------------------------------- 1 | 2 | from django.conf.urls.defaults import * 3 | 4 | 5 | urlpatterns = patterns('', 6 | url(r'^ajax_lookup/(?P[-\w]+)$', 7 | 'ajax_select.views.ajax_lookup', 8 | name = 'ajax_lookup' 9 | ), 10 | url(r'^add_popup/(?P\w+)/(?P\w+)$', 11 | 'ajax_select.views.add_popup', 12 | name = 'add_popup' 13 | ) 14 | ) 15 | 16 | -------------------------------------------------------------------------------- /ajax_select/admin.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from ajax_select.fields import autoselect_fields_check_can_add 4 | from django.contrib import admin 5 | 6 | class AjaxSelectAdmin(admin.ModelAdmin): 7 | 8 | """ in order to get + popup functions subclass this or do the same hook inside of your get_form """ 9 | 10 | def get_form(self, request, obj=None, **kwargs): 11 | form = super(AjaxSelectAdmin,self).get_form(request,obj,**kwargs) 12 | 13 | autoselect_fields_check_can_add(form,self.model,request.user) 14 | return form 15 | 16 | -------------------------------------------------------------------------------- /ajax_select/templates/autocomplete.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% block help %}{# {% if help_text %}

{{ help_text }}

{% endif %} #}{% endblock %} 4 | 19 | -------------------------------------------------------------------------------- /ajax_select/static/ajax_select/iconic.css: -------------------------------------------------------------------------------- 1 | /* 2 | most of the css that makes it look good is in the jquery.autocomplete.css that comes with the autocomplete plugin. 3 | that formats the dropdown of search results. 4 | 5 | one class is used on the html interface itself and that's the X that allows you to remove an item. 6 | here is the styling I use. add this to your main css file and season to taste. 7 | */ 8 | .iconic { 9 | background: #fff; 10 | color: #000; 11 | border: 1px solid #ddd; 12 | padding: 2px 4px; 13 | font-weight: bold; 14 | font-family: Courier; 15 | text-decoration: none; 16 | } 17 | .iconic:hover { 18 | text-decoration: none; 19 | color: #fff; 20 | background: #000; 21 | cursor: pointer; 22 | } 23 | 24 | input.ac_loading { 25 | background: #FFF url('../images/loading-indicator.gif') no-repeat; 26 | background-position: right; 27 | } 28 | 29 | 30 | /* change the X to an image */ 31 | .results_on_deck .iconic, .results_on_deck .iconic:hover { 32 | float: left; 33 | background: url(../shared/images/Trashcan.gif) no-repeat; 34 | color: transparent; 35 | border: 0; 36 | } 37 | 38 | /* specific to a site I worked on. the formatted results were tables. I sized them and floated them left, next to the icon */ 39 | .results_on_deck div table { 40 | float: left; 41 | width: 300px; 42 | border: 0; 43 | } 44 | /* and each div in the result clears to start a new row */ 45 | .results_on_deck > div { 46 | clear: both; 47 | } 48 | 49 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup 4 | 5 | setup(name='django-ajax-selects', 6 | version='1.1.4', 7 | description='jQuery-powered auto-complete fields for ForeignKey and ManyToMany fields', 8 | author='crucialfelix', 9 | author_email='crucialfelix@gmail.com', 10 | url='http://code.google.com/p/django-ajax-selects/', 11 | packages=['ajax_select', ], 12 | include_package_data = True, # include everything in source control 13 | package_data={'ajax_select': ['*.py','*.txt','*.css','*.gif','js/*.js','templates/*.html']}, 14 | classifiers = [ 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 2", 17 | "Development Status :: 4 - Beta", 18 | 'Environment :: Web Environment', 19 | "Intended Audience :: Developers", 20 | "License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)", 21 | "Operating System :: OS Independent", 22 | "Topic :: Software Development :: Libraries :: Python Modules", 23 | "Topic :: Software Development :: User Interfaces", 24 | "Framework :: Django", 25 | ], 26 | long_description = """\ 27 | Enables editing of `ForeignKey`, `ManyToMany` and simple text fields using the Autocomplete - `jQuery` plugin. 28 | 29 | django-ajax-selects will work in any normal form as well as in the admin. 30 | 31 | The user is presented with a text field. They type a search term or a few letters of a name they are looking for, an ajax request is sent to the server, a search channel returns possible results. Results are displayed as a drop down menu. When an item is selected it is added to a display area just below the text field. 32 | 33 | """ 34 | ) 35 | -------------------------------------------------------------------------------- /ajax_select/views.py: -------------------------------------------------------------------------------- 1 | 2 | from ajax_select import get_lookup 3 | from django.contrib.admin import site 4 | from django.db import models 5 | from django.http import HttpResponse 6 | 7 | 8 | def ajax_lookup(request,channel): 9 | """ this view supplies results for both foreign keys and many to many fields """ 10 | 11 | # it should come in as GET unless global $.ajaxSetup({type:"POST"}) has been set 12 | # in which case we'll support POST 13 | if request.method == "GET": 14 | # we could also insist on an ajax request 15 | if 'q' not in request.GET: 16 | return HttpResponse('') 17 | query = request.GET['q'] 18 | else: 19 | if 'q' not in request.POST: 20 | return HttpResponse('') # suspicious 21 | query = request.POST['q'] 22 | 23 | lookup_channel = get_lookup(channel) 24 | 25 | if query: 26 | instances = lookup_channel.get_query(query,request) 27 | else: 28 | instances = [] 29 | 30 | results = [] 31 | for item in instances: 32 | itemf = lookup_channel.format_item(item) 33 | itemf = itemf.replace("\n","").replace("|","¦") 34 | resultf = lookup_channel.format_result(item) 35 | resultf = resultf.replace("\n","").replace("|","¦") 36 | results.append( "|".join((unicode(item.pk),itemf,resultf)) ) 37 | return HttpResponse("\n".join(results)) 38 | 39 | 40 | def add_popup(request,app_label,model): 41 | """ present an admin site add view, hijacking the result if its the dismissAddAnotherPopup js and returning didAddPopup """ 42 | themodel = models.get_model(app_label, model) 43 | admin = site._registry[themodel] 44 | 45 | admin.admin_site.root_path = "/ajax_select/" # warning: your URL should be configured here. 46 | # as in your root urls.py includes : 47 | # (r'^ajax_select/', include('ajax_select.urls')), 48 | # I should be able to auto-figure this out but ... 49 | 50 | response = admin.add_view(request,request.path) 51 | if request.method == 'POST': 52 | if response.content.startswith(' 57 | 58 | -------------------------------------------------------------------------------- /ajax_select/templates/autocompleteselectmultiple.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% if add_link %} 4 | Add Another 5 | {% endif %} 6 | {% block help %}{% if help_text %}

{{help_text}}

{% endif %}{% endblock %} 7 |

8 | 9 | -------------------------------------------------------------------------------- /ajax_select/__init__.py: -------------------------------------------------------------------------------- 1 | """JQuery-Ajax Autocomplete fields for Django Forms""" 2 | __version__ = "1.1.4.1" 3 | __author__ = "crucialfelix" 4 | __contact__ = "crucialfelix@gmail.com" 5 | __homepage__ = "http://code.google.com/p/django-ajax-selects/" 6 | 7 | from django.conf import settings 8 | from django.core.exceptions import ImproperlyConfigured 9 | from django.db.models.fields.related import ForeignKey, ManyToManyField 10 | from django.forms.models import ModelForm 11 | from django.utils.text import capfirst 12 | from django.utils.translation import ugettext_lazy as _, ugettext 13 | 14 | 15 | def make_ajax_form(model,fieldlist,superclass=ModelForm): 16 | """ this will create a ModelForm subclass inserting 17 | AutoCompleteSelectMultipleField (many to many), 18 | AutoCompleteSelectField (foreign key) 19 | 20 | where specified in the fieldlist: 21 | 22 | dict(fieldname='channel',...) 23 | 24 | usage: 25 | class YourModelAdmin(Admin): 26 | ... 27 | form = make_ajax_form(YourModel,dict(contacts='contact',author='contact')) 28 | 29 | where 'contacts' is a many to many field, specifying to use the lookup channel 'contact' 30 | and 31 | where 'author' is a foreign key field, specifying here to also use the lookup channel 'contact' 32 | 33 | """ 34 | 35 | class TheForm(superclass): 36 | class Meta: 37 | pass 38 | setattr(Meta, 'model', model) 39 | 40 | for model_fieldname,channel in fieldlist.iteritems(): 41 | f = make_ajax_field(model,model_fieldname,channel) 42 | 43 | TheForm.declared_fields[model_fieldname] = f 44 | TheForm.base_fields[model_fieldname] = f 45 | setattr(TheForm,model_fieldname,f) 46 | 47 | return TheForm 48 | 49 | 50 | def make_ajax_field(model,model_fieldname,channel,**kwargs): 51 | """ makes an ajax select / multiple select / autocomplete field 52 | copying the label and help text from the model's db field 53 | 54 | optional args: 55 | help_text - note that django's ManyToMany db field will append 56 | 'Hold down "Control", or "Command" on a Mac, to select more than one.' 57 | to your db field's help text. 58 | Therefore you are better off passing it in here 59 | label - default is db field's verbose name 60 | required - default's to db field's (not) blank 61 | """ 62 | 63 | from ajax_select.fields import AutoCompleteField, \ 64 | AutoCompleteSelectMultipleField, \ 65 | AutoCompleteSelectField 66 | 67 | field = model._meta.get_field(model_fieldname) 68 | if kwargs.has_key('label'): 69 | label = kwargs.pop('label') 70 | else: 71 | label = _(capfirst(unicode(field.verbose_name))) 72 | if kwargs.has_key('help_text'): 73 | help_text = kwargs.pop('help_text') 74 | else: 75 | if isinstance(field.help_text,basestring): 76 | help_text = _(field.help_text) 77 | else: 78 | help_text = field.help_text 79 | if kwargs.has_key('required'): 80 | required = kwargs.pop('required') 81 | else: 82 | required = not field.blank 83 | 84 | if isinstance(field,ManyToManyField): 85 | f = AutoCompleteSelectMultipleField( 86 | channel, 87 | required=required, 88 | help_text=help_text, 89 | label=label, 90 | **kwargs 91 | ) 92 | elif isinstance(field,ForeignKey): 93 | f = AutoCompleteSelectField( 94 | channel, 95 | required=required, 96 | help_text=help_text, 97 | label=label, 98 | **kwargs 99 | ) 100 | else: 101 | f = AutoCompleteField( 102 | channel, 103 | required=required, 104 | help_text=help_text, 105 | label=label, 106 | **kwargs 107 | ) 108 | return f 109 | 110 | def get_lookup(channel): 111 | """ find the lookup class for the named channel. this is used internally """ 112 | try: 113 | lookup_label = settings.AJAX_LOOKUP_CHANNELS[channel] 114 | except (KeyError, AttributeError): 115 | raise ImproperlyConfigured("settings.AJAX_LOOKUP_CHANNELS not configured correctly for %r" % channel) 116 | 117 | if isinstance(lookup_label,dict): 118 | # 'channel' : dict(model='app.model', search_field='title' ) 119 | # generate a simple channel dynamically 120 | return make_channel( lookup_label['model'], lookup_label['search_field'] ) 121 | else: 122 | # 'channel' : ('app.module','LookupClass') 123 | # from app.module load LookupClass and instantiate 124 | lookup_module = __import__( lookup_label[0],{},{},['']) 125 | lookup_class = getattr(lookup_module,lookup_label[1] ) 126 | return lookup_class() 127 | 128 | 129 | def make_channel(app_model,search_field): 130 | """ used in get_lookup 131 | app_model : app_name.model_name 132 | search_field : the field to search against and to display in search results """ 133 | from django.db import models 134 | app_label, model_name = app_model.split(".") 135 | model = models.get_model(app_label, model_name) 136 | 137 | class AjaxChannel(object): 138 | 139 | def get_query(self,q,request): 140 | """ return a query set searching for the query string q """ 141 | kwargs = { "%s__icontains" % search_field : q } 142 | return model.objects.filter(**kwargs).order_by(search_field) 143 | 144 | def format_item(self,obj): 145 | """ format item for simple list of currently selected items """ 146 | return unicode(obj) 147 | 148 | def format_result(self,obj): 149 | """ format search result for the drop down of search results. may include html """ 150 | return unicode(obj) 151 | 152 | def get_objects(self,ids): 153 | """ get the currently selected objects """ 154 | return model.objects.filter(pk__in=ids).order_by(search_field) 155 | 156 | return AjaxChannel() 157 | 158 | 159 | -------------------------------------------------------------------------------- /ajax_select/fields.py: -------------------------------------------------------------------------------- 1 | 2 | from ajax_select import get_lookup 3 | from django import forms 4 | from django.conf import settings 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.core.urlresolvers import reverse 7 | from django.forms.util import flatatt 8 | from django.template.defaultfilters import escapejs 9 | from django.template.loader import render_to_string 10 | from django.utils.safestring import mark_safe 11 | from django.utils.translation import ugettext as _ 12 | 13 | class AutoCompleteSelectWidget(forms.widgets.TextInput): 14 | 15 | """ widget to select a model """ 16 | 17 | add_link = None 18 | 19 | def __init__(self, 20 | channel, 21 | help_text='', 22 | *args, **kw): 23 | super(forms.widgets.TextInput, self).__init__(*args, **kw) 24 | self.channel = channel 25 | self.help_text = help_text 26 | 27 | def render(self, name, value, attrs=None): 28 | 29 | value = value or '' 30 | final_attrs = self.build_attrs(attrs) 31 | self.html_id = final_attrs.pop('id', name) 32 | 33 | lookup = get_lookup(self.channel) 34 | if value: 35 | objs = lookup.get_objects([value]) 36 | try: 37 | obj = objs[0] 38 | except IndexError: 39 | raise Exception("%s cannot find object:%s" % (lookup, value)) 40 | current_result = mark_safe(lookup.format_item( obj ) ) 41 | else: 42 | current_result = '' 43 | 44 | context = { 45 | 'name': name, 46 | 'html_id' : self.html_id, 47 | 'lookup_url': reverse('ajax_lookup',kwargs={'channel':self.channel}), 48 | 'current_id': value, 49 | 'current_result': current_result, 50 | 'help_text': self.help_text, 51 | 'extra_attrs': mark_safe(flatatt(final_attrs)), 52 | 'func_slug': self.html_id.replace("-",""), 53 | 'add_link' : self.add_link, 54 | } 55 | 56 | return mark_safe(render_to_string(('autocompleteselect_%s.html' % self.channel, 'autocompleteselect.html'),context)) 57 | 58 | def value_from_datadict(self, data, files, name): 59 | 60 | got = data.get(name, None) 61 | if got: 62 | return long(got) 63 | else: 64 | return None 65 | 66 | 67 | 68 | class AutoCompleteSelectField(forms.fields.CharField): 69 | 70 | """ form field to select a model for a ForeignKey db field """ 71 | 72 | channel = None 73 | 74 | def __init__(self, channel, *args, **kwargs): 75 | self.channel = channel 76 | widget = kwargs.get("widget", False) 77 | if not widget or not isinstance(widget, AutoCompleteSelectWidget): 78 | kwargs["widget"] = AutoCompleteSelectWidget(channel=channel,help_text=kwargs.get('help_text',_('Enter text to search.'))) 79 | super(AutoCompleteSelectField, self).__init__(max_length=255,*args, **kwargs) 80 | 81 | def clean(self, value): 82 | if value: 83 | lookup = get_lookup(self.channel) 84 | objs = lookup.get_objects( [ value] ) 85 | if len(objs) != 1: 86 | # someone else might have deleted it while you were editing 87 | # or your channel is faulty 88 | # out of the scope of this field to do anything more than tell you it doesn't exist 89 | raise forms.ValidationError(u"%s cannot find object: %s" % (lookup,value)) 90 | return objs[0] 91 | else: 92 | if self.required: 93 | raise forms.ValidationError(self.error_messages['required']) 94 | return None 95 | 96 | def check_can_add(self,user,model): 97 | _check_can_add(self,user,model) 98 | 99 | 100 | 101 | class AutoCompleteSelectMultipleWidget(forms.widgets.SelectMultiple): 102 | 103 | """ widget to select multiple models """ 104 | 105 | add_link = None 106 | 107 | def __init__(self, 108 | channel, 109 | help_text='', 110 | show_help_text=False,#admin will also show help. set True if used outside of admin 111 | *args, **kwargs): 112 | super(AutoCompleteSelectMultipleWidget, self).__init__(*args, **kwargs) 113 | self.channel = channel 114 | self.help_text = help_text 115 | self.show_help_text = show_help_text 116 | 117 | def render(self, name, value, attrs=None): 118 | 119 | if value is None: 120 | value = [] 121 | 122 | final_attrs = self.build_attrs(attrs) 123 | self.html_id = final_attrs.pop('id', name) 124 | 125 | lookup = get_lookup(self.channel) 126 | 127 | current_name = "" # the text field starts empty 128 | # eg. value = [3002L, 1194L] 129 | if value: 130 | current_ids = "|" + "|".join( str(pk) for pk in value ) + "|" # |pk|pk| of current 131 | else: 132 | current_ids = "|" 133 | 134 | objects = lookup.get_objects(value) 135 | 136 | # text repr of currently selected items 137 | current_repr_json = [] 138 | for obj in objects: 139 | repr = lookup.format_item(obj) 140 | current_repr_json.append( """new Array("%s",%s)""" % (escapejs(repr),obj.pk) ) 141 | 142 | current_reprs = mark_safe("new Array(%s)" % ",".join(current_repr_json)) 143 | if self.show_help_text: 144 | help_text = self.help_text 145 | else: 146 | help_text = '' 147 | 148 | context = { 149 | 'name':name, 150 | 'html_id':self.html_id, 151 | 'lookup_url':reverse('ajax_lookup',kwargs={'channel':self.channel}), 152 | 'current':value, 153 | 'current_name':current_name, 154 | 'current_ids':current_ids, 155 | 'current_reprs':current_reprs, 156 | 'help_text':help_text, 157 | 'extra_attrs': mark_safe(flatatt(final_attrs)), 158 | 'func_slug': self.html_id.replace("-",""), 159 | 'add_link' : self.add_link, 160 | } 161 | return mark_safe(render_to_string(('autocompleteselectmultiple_%s.html' % self.channel, 'autocompleteselectmultiple.html'),context)) 162 | 163 | def value_from_datadict(self, data, files, name): 164 | # eg. u'members': [u'|229|4688|190|'] 165 | return [long(val) for val in data.get(name,'').split('|') if val] 166 | 167 | 168 | 169 | 170 | class AutoCompleteSelectMultipleField(forms.fields.CharField): 171 | 172 | """ form field to select multiple models for a ManyToMany db field """ 173 | 174 | channel = None 175 | 176 | def __init__(self, channel, *args, **kwargs): 177 | self.channel = channel 178 | help_text = kwargs.get('help_text',_('Enter text to search.')) 179 | # admin will also show help text, so by default do not show it in widget 180 | # if using in a normal form then set to True so the widget shows help 181 | show_help_text = kwargs.get('show_help_text',False) 182 | kwargs['widget'] = AutoCompleteSelectMultipleWidget(channel=channel,help_text=help_text,show_help_text=show_help_text) 183 | super(AutoCompleteSelectMultipleField, self).__init__(*args, **kwargs) 184 | 185 | def clean(self, value): 186 | if not value and self.required: 187 | raise forms.ValidationError(self.error_messages['required']) 188 | return value # a list of IDs from widget value_from_datadict 189 | 190 | def check_can_add(self,user,model): 191 | _check_can_add(self,user,model) 192 | 193 | 194 | class AutoCompleteWidget(forms.TextInput): 195 | """ 196 | Widget to select a search result and enter the result as raw text in the text input field. 197 | the user may also simply enter text and ignore any auto complete suggestions. 198 | """ 199 | channel = None 200 | help_text = '' 201 | html_id = '' 202 | 203 | def __init__(self, channel, *args, **kwargs): 204 | self.channel = channel 205 | self.help_text = kwargs.pop('help_text', '') 206 | 207 | super(AutoCompleteWidget, self).__init__(*args, **kwargs) 208 | 209 | def render(self, name, value, attrs=None): 210 | 211 | value = value or '' 212 | final_attrs = self.build_attrs(attrs) 213 | self.html_id = final_attrs.pop('id', name) 214 | 215 | context = { 216 | 'current_name': value, 217 | 'current_id': value, 218 | 'help_text': self.help_text, 219 | 'html_id': self.html_id, 220 | 'lookup_url': reverse('ajax_lookup', args=[self.channel]), 221 | 'name': name, 222 | 'extra_attrs':mark_safe(flatatt(final_attrs)), 223 | 'func_slug': self.html_id.replace("-","") 224 | } 225 | 226 | templates = ('autocomplete_%s.html' % self.channel, 227 | 'autocomplete.html') 228 | return mark_safe(render_to_string(templates, context)) 229 | 230 | 231 | class AutoCompleteField(forms.CharField): 232 | """ 233 | Field uses an AutoCompleteWidget to lookup possible completions using a channel and stores raw text (not a foreign key) 234 | """ 235 | channel = None 236 | 237 | def __init__(self, channel, *args, **kwargs): 238 | self.channel = channel 239 | 240 | widget = AutoCompleteWidget(channel,help_text=kwargs.get('help_text', _('Enter text to search.'))) 241 | 242 | defaults = {'max_length': 255,'widget': widget} 243 | defaults.update(kwargs) 244 | 245 | super(AutoCompleteField, self).__init__(*args, **defaults) 246 | 247 | 248 | 249 | 250 | 251 | def _check_can_add(self,user,model): 252 | """ check if the user can add the model, deferring first to the channel if it implements can_add() \ 253 | else using django's default perm check. \ 254 | if it can add, then enable the widget to show the + link """ 255 | lookup = get_lookup(self.channel) 256 | try: 257 | can_add = lookup.can_add(user,model) 258 | except AttributeError: 259 | ctype = ContentType.objects.get_for_model(model) 260 | can_add = user.has_perm("%s.add_%s" % (ctype.app_label,ctype.model)) 261 | if can_add: 262 | self.widget.add_link = reverse('add_popup',kwargs={'app_label':model._meta.app_label,'model':model._meta.object_name.lower()}) 263 | 264 | def autoselect_fields_check_can_add(form,model,user): 265 | """ check the form's fields for any autoselect fields and enable their widgets with + sign add links if permissions allow""" 266 | for name,form_field in form.declared_fields.iteritems(): 267 | if isinstance(form_field,(AutoCompleteSelectMultipleField,AutoCompleteSelectField)): 268 | db_field = model._meta.get_field_by_name(name)[0] 269 | form_field.check_can_add(user,db_field.rel.to) 270 | 271 | -------------------------------------------------------------------------------- /ajax_select/docs.txt: -------------------------------------------------------------------------------- 1 | 2 | Enables editing of `ForeignKey`, `ManyToMany` and simple text fields using the Autocomplete - `jQuery` plugin. 3 | 4 | django-ajax-selects will work in any normal form as well as in the admin. 5 | 6 | 7 | ==User experience== 8 | 9 | selecting... 10 | 11 | http://crucial-systems.com/crucialwww/uploads/posts/selecting.png 12 | 13 | selected. 14 | 15 | http://crucial-systems.com/crucialwww/uploads/posts/selected.png 16 | 17 | The user is presented with a text field. They type a search term or a few letters of a name they are looking for, an ajax request is sent to the server, a search channel returns possible results. Results are displayed as a drop down menu. When an item is selected it is added to a display area just below the text field. 18 | 19 | A single view services all of the ajax search requests, delegating the searches to named 'channels'. 20 | 21 | A channel is a simple class that handles the actual searching, defines how you want to treat the query input (split first name and last name, which fields to search etc.) and returns id and formatted results back to the view which sends it to the browser. 22 | 23 | For instance the search channel 'contacts' would search for Contact models. The class would be named ContactLookup. This channel can be used for both AutoCompleteSelect ( foreign key, single item ) and AutoCompleteSelectMultiple (many to many) fields. 24 | 25 | Simple search channels can also be automatically generated, you merely specify the model and the field to search against (see examples below). 26 | 27 | Custom search channels can be written when you need to do a more complex search, check the user's permissions, format the results differently or customize the sort order of the results. 28 | 29 | 30 | ==Requirements== 31 | 32 | * Django 1.0 + 33 | * jquery 1.26 + 34 | * Autocomplete - jQuery plugin 1.1 [http://bassistance.de/jquery-plugins/jquery-plugin-autocomplete/] 35 | * jquery.autocomplete.css (included with Autocomplete) 36 | 37 | The Autocomplete jQuery plugin has now been merged into jQuery UI 1.8 and been improved (2010-06-23). I will migrate this package to use the jQuery UI version and release that as django_ajax_select 1.2, but as of this reading (you, reading this right now) you should download and use his 1.1 version. 38 | 39 | 40 | ==Installation== 41 | 42 | `pip install django-ajax-selects` 43 | or 44 | `easy_install django-ajax-selects` 45 | or 46 | download or checkout the distribution 47 | or install using buildout by adding `django-ajax-selects` to your `eggs` 48 | 49 | in settings.py : 50 | 51 | {{{ 52 | INSTALLED_APPS = ( 53 | ..., 54 | 'ajax_select' 55 | ) 56 | }}} 57 | 58 | 59 | Make sure that these js/css files appear on your page: 60 | 61 | * jquery-1.2.6.js or greater 62 | * jquery.autocomplete.js 63 | * jquery.autocomplete.css 64 | * ajax_select.js (for pop up admin support) 65 | * iconic.css (optional, or use this as a starting point) 66 | 67 | I like to use django-compress: 68 | 69 | {{{ 70 | COMPRESS_CSS = { 71 | 'all': { 72 | 'source_filenames': ( 73 | ... 74 | 'shared/js/jqplugins/jquery.autocomplete.css', 75 | ), 76 | 'output_filename': 'css/all_compressed.css', 77 | 'extra_context': { 78 | 'media': 'screen,projection', 79 | }, 80 | }, 81 | } 82 | 83 | COMPRESS_JS = { 84 | 'all': { 85 | 'source_filenames': ( 86 | 'shared/jquery_ui/jquery-1.2.6.js', 87 | 'shared/js/jqplugins/jquery.autocomplete.js', 88 | ), 89 | 'output_filename': 'js/all_compressed.js', 90 | }, 91 | } 92 | }}} 93 | 94 | But it would be nice if js and css files could be included from any path, not just those in the MEDIA_ROOT. You will have to copy or symlink the included files to place them somewhere where they can be served. 95 | 96 | 97 | 98 | in your `settings.py` define the channels in use on the site: 99 | 100 | {{{ 101 | AJAX_LOOKUP_CHANNELS = { 102 | # the simplest case, pass a DICT with the model and field to search against : 103 | 'track' : dict(model='music.track',search_field='title'), 104 | # this generates a simple channel 105 | # specifying the model Track in the music app, and searching against the 'title' field 106 | 107 | # or write a custom search channel and specify that using a TUPLE 108 | 'contact' : ('peoplez.lookups', 'ContactLookup'), 109 | # this specifies to look for the class `ContactLookup` in the `peoplez.lookups` module 110 | } 111 | }}} 112 | 113 | Custom search channels can be written when you need to do a more complex search, check the user's permissions (if the lookup URL should even be accessible to them, and then to perhaps filter which items they are allowed to see), format the results differently or customize the sort order of the results. Search channel objects should implement the 4 methods shown in the following example. 114 | 115 | `peoplez/lookups.py` 116 | {{{ 117 | from peoplez.models import Contact 118 | from django.db.models import Q 119 | 120 | class ContactLookup(object): 121 | 122 | def get_query(self,q,request): 123 | """ return a query set. you also have access to request.user if needed """ 124 | return Contact.objects.filter(Q(name__istartswith=q) | Q(fname__istartswith=q) | Q(lname__istartswith=q) | Q(email__icontains=q)) 125 | 126 | def format_result(self,contact): 127 | """ the search results display in the dropdown menu. may contain html and multiple-lines. will remove any | """ 128 | return u"%s %s %s (%s)" % (contact.fname, contact.lname,contact.name,contact.email) 129 | 130 | def format_item(self,contact): 131 | """ the display of a currently selected object in the area below the search box. html is OK """ 132 | return unicode(contact) 133 | 134 | def get_objects(self,ids): 135 | """ given a list of ids, return the objects ordered as you would like them on the admin page. 136 | this is for displaying the currently selected items (in the case of a ManyToMany field) 137 | """ 138 | return Contact.objects.filter(pk__in=ids).order_by('name','lname') 139 | }}} 140 | 141 | HTML is fine in the result or item format. Newlines and pipe chars will be removed and everything will be escaped properly. 142 | 143 | Example showing security: 144 | {{{ 145 | from django.http import HttpResponseForbidden 146 | 147 | class ContactLookup(object): 148 | 149 | def get_query(self,q,request): 150 | """ return a query set. you also have access to request.user if needed """ 151 | if not request.user.is_authenticated(): 152 | raise HttpResponseForbidden() # raising an exception, django will catch this and return an Http 403 153 | # filtering only this user's contacts 154 | return Contact.objects.filter(name__istartswith=q,created_by=request.user) 155 | 156 | ... 157 | 158 | }}} 159 | 160 | 161 | include the urls in your site's `urls.py`. This adds the lookup view and the pop up admin view. 162 | 163 | {{{ 164 | (r'^ajax_select/', include('ajax_select.urls')), 165 | }}} 166 | 167 | 168 | ==Example== 169 | 170 | for an example model: 171 | 172 | {{{ 173 | class ContactMailing(models.Model): 174 | """ can mail to multiple contacts, has one author """ 175 | contacts = models.ManyToManyField(Contact,blank=True) 176 | author = models.ForeignKey(Contact,blank=False) 177 | ... 178 | }}} 179 | 180 | 181 | in the `admin.py` for this app: 182 | 183 | {{{ 184 | from ajax_select import make_ajax_form 185 | 186 | class ContactMailingAdmin(Admin): 187 | form = make_ajax_form(ContactMailing,dict(author='contact',contacts='contact')) 188 | }}} 189 | 190 | `make_ajax_form( model, fieldlist )` is a factory function which will insert the ajax powered form field inputs 191 | so in this example the `author` field (`ForeignKey`) uses the 'contact' channel 192 | and the `contacts` field (`ManyToMany`) also uses the 'contact' channel 193 | 194 | 195 | If you need to write your own form class then specify that form for the admin as usual: 196 | 197 | {{{ 198 | from forms import ContactMailingForm 199 | 200 | class ContactMailingAdmin(admin.ModelAdmin): 201 | form = ContactMailingForm 202 | 203 | admin.site.register(ContactMailing,ContactMailingAdmin) 204 | }}} 205 | 206 | in `forms.py` for that app: 207 | 208 | {{{ 209 | from ajax_select.fields import AutoCompleteSelectMultipleField, AutoCompleteSelectField 210 | 211 | class ContactMailingForm(models.ModelForm): 212 | 213 | # declare a field and specify the named channel that it uses 214 | contacts = AutoCompleteSelectMultipleField('contact', required=False) 215 | author = AutoCompleteSelectField('contact', required=False) 216 | 217 | }}} 218 | 219 | 220 | ==Add another via popup== 221 | 222 | 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 your form does not. 223 | 224 | 1. subclass `AjaxSelectAdmin` or include the `autoselect_fields_check_can_add` hook in your admin's `get_form()` [see AjaxSelectAdmin] 225 | 226 | {{{ 227 | def get_form(self, request, obj=None, **kwargs): 228 | form = super(AjaxSelectAdmin,self).get_form(request,obj,**kwargs) 229 | autoselect_fields_check_can_add(form,self.model,request.user) 230 | return form 231 | }}} 232 | 233 | 2. Make sure that `js/ajax_select.js` is included in your admin's media or in your site's admin js stack. 234 | 235 | 236 | `autoselect_fields_check_can_add(form,model,user)` 237 | 238 | This checks if the user has permission to add the model, 239 | delegating first to the channel if that implements `can_add(user,model)` 240 | otherwise using django's standard user.has_perm check. 241 | 242 | The pop up is served by a custom view that uses the model's registered admin 243 | 244 | 3. For this to work you must include ajax_select/urls.py in your root urls.py under this directory: 245 | 246 | `(r'^ajax_select/', include('ajax_select.urls')),` 247 | 248 | 249 | Once the related object is successfully added, the mischevious custom view hijacks the little javascript response and substitutes `didAddPopup(win,newId,newRepr)` which is in `ajax_select.js` 250 | 251 | Integrating with Django's normal popup admin system is tricky for a number of reasons. 252 | 253 | `ModelAdmin` creates default fields for each field on the model. Then for `ForeignKey` and `ManyToMany` fields it wraps the (default) form field's widget with a `RelatedFieldWidgetWrapper` that adds the magical green +. (Incidentally it adds this regardless of whether you have permission to add the model or not. This is a bug I need to file) 254 | 255 | It then overwrites all of those with any explicitly declared fields. `AutoComplete` fields are declared fields on your form, so if there was a Select field with a wrapper, it gets overwritten by the `AutoCompleteSelect`. That doesn't matter anyway because `RelatedFieldWidgetWrapper` operates only with the default `SelectField` that it is expecting. 256 | 257 | The green + pops open a window with a GET param: `_popup=1`. The `ModelAdmin` recognizes this, the template uses if statements to reduce the page's html a bit, and when the ModelAdmin saves, it returns a simple response with just some javascript that calls `dismissAddAnotherPopup(win, newId, newRepr)` which is a function in `RelatedObjects.js`. That looks for the form field, and if it is a `SelectField` as expected then it alters that accordingly. Then it shuts the pop up window. 258 | 259 | 260 | 261 | 262 | ==Using ajax selects in a `FormSet`== 263 | 264 | There might be a better way to do this. 265 | 266 | `forms.py` 267 | {{{ 268 | from django.forms.models import modelformset_factory 269 | from django.forms.models import BaseModelFormSet 270 | from ajax_select.fields import AutoCompleteSelectMultipleField, AutoCompleteSelectField 271 | 272 | from models import * 273 | 274 | # create a superclass 275 | class BaseTaskFormSet(BaseModelFormSet): 276 | 277 | # that adds the field in, overwriting the previous default field 278 | def add_fields(self, form, index): 279 | super(BaseTaskFormSet, self).add_fields(form, index) 280 | form.fields["project"] = AutoCompleteSelectField('project', required=False) 281 | 282 | # pass in the base formset class to the factory 283 | TaskFormSet = modelformset_factory(Task,fields=('name','project','area'),extra=0,formset=BaseTaskFormSet) 284 | }}} 285 | 286 | 287 | ==customizing the html or javascript== 288 | 289 | django's `select_template` is used to choose the template to render the widget's interface: 290 | 291 | `autocompleteselect_{channel}.html` or `autocompleteselect.html` 292 | 293 | So by writing a template `autocompleteselect_{channel}.html` you can customize the interface just for that channel. 294 | 295 | 296 | ==Handlers: On item added or removed== 297 | 298 | Triggers are a great way to keep code clean and untangled. Two triggers/signals are sent: 'added' and 'killed'. These are sent to the p 'on deck' element. That is the area that surrounds the currently selected items. Its quite easy to bind functions to respond to these triggers. 299 | 300 | Extend the template, implement the extra_script block and bind functions that will respond to the trigger: 301 | 302 | multi select: 303 | {{{ 304 | {% block extra_script %} 305 | $("#{{html_id}}_on_deck").bind('added',function() { 306 | id = $("#{{html_id}}").val(); 307 | alert('added id:' + id ); 308 | }); 309 | $("#{{html_id}}_on_deck").bind('killed',function() { 310 | current = $("#{{html_id}}").val() 311 | alert('removed, current is:' + current); 312 | }); 313 | {% endblock %} 314 | }}} 315 | 316 | select: 317 | {{{ 318 | {% block extra_script %} 319 | $("#{{html_id}}_on_deck").bind('added',function() { 320 | id = $("#{{html_id}}").val(); 321 | alert('added id:' + id ); 322 | }); 323 | $("#{{html_id}}_on_deck").bind('killed',function() { 324 | alert('removed'); 325 | }); 326 | {% endblock %} 327 | }}} 328 | 329 | auto-complete text select 330 | {{{ 331 | {% block extra_script %} 332 | $('#{{ html_id }}').bind('added',function() { 333 | entered = $('#{{ html_id }}').val(); 334 | alert( entered ); 335 | }); 336 | {% endblock %} 337 | }}} 338 | 339 | There is no remove as there is no kill/delete button. The user may clear the text themselves but there is no javascript involved. Its just a text field. 340 | 341 | 342 | see: 343 | http://docs.jquery.com/Events/trigger 344 | 345 | 346 | ==Help text== 347 | 348 | If you are using AutoCompleteSelectMultiple outside of the admin then pass in `show_help_text=True`. 349 | 350 | This is because the admin displays the widget's help text and the widget would also. But when used outside of the admin you need the help text. This is not the case for `AutoCompleteSelect`. 351 | 352 | When defining a db ManyToMany field django will append 'Hold down "Control", or "Command" on a Mac, to select more than one.' regardless of what widget is actually used. http://code.djangoproject.com/ticket/12359 353 | 354 | Thus you should always define the help text in your form field, and its usually nicer to tell people what fields will be searched against. Its not inherently obvious that the text field is "ajax powered" or what a brand of window cleaner has to do with filling out this dang form anyway. 355 | 356 | 357 | ==CSS== 358 | 359 | See iconic.css for some example styling. autocomplete.js adds the .ac_loading class to the text field while the search is being served. You can style this with fashionable ajax spinning discs etc. A fashionable ajax spinning disc is included. 360 | 361 | 362 | ==Planned Improvements== 363 | 364 | * Migrating to use the jQuery 1.8 version of autocomplete. This will integrate it with ThemeRoller and would slightly reduce the js codebase. 365 | 366 | * including of media will be improved to use field/admin's Media but it would be preferable if that can be integrated with django-compress 367 | 368 | * make it work within inline many to many fields (when the inlines themselves have lookups) 369 | 370 | 371 | ==License== 372 | 373 | Copyright (c) 2009 Chris Sattinger 374 | 375 | Dual licensed under the MIT and GPL licenses: 376 | http://www.opensource.org/licenses/mit-license.php 377 | http://www.gnu.org/licenses/gpl.html 378 | 379 | 380 | ==Changelog== 381 | 382 | 1.1.0 383 | Changed AutoCompleteSelect to work like AutoCompleteSelectMultiple: 384 | after the result is selected it is displayed below the text input and the text input is cleared. 385 | a clickable span is added to remove the item 386 | Simplified functions a bit, cleaned up code 387 | Added blocks: script and extra_script 388 | Added 'killed' and 'added' triggers/signals 389 | Support for adding an item via a pop up (ie. the django admin green + sign) 390 | 391 | 1.1.4 392 | Fixed python 2.4 compatiblity 393 | Added LICENSE 394 | 395 | 396 | --------------------------------------------------------------------------------