From 56cb30928f3c46c8631cce1bb0e008c50aaed42c Mon Sep 17 00:00:00 2001 From: Villiers Strauss Date: Thu, 4 Sep 2014 16:18:07 +0200 Subject: [PATCH 01/85] Add Ajax StackedInline to have the add pluses in stacked inlines as well --- ajax_select/admin.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/ajax_select/admin.py b/ajax_select/admin.py index 0dae1872f4..8a9c6d8e10 100644 --- a/ajax_select/admin.py +++ b/ajax_select/admin.py @@ -1,5 +1,3 @@ - - from ajax_select.fields import autoselect_fields_check_can_add from django.contrib import admin @@ -15,9 +13,16 @@ def get_form(self, request, obj=None, **kwargs): return form -class AjaxSelectAdminTabularInline(admin.TabularInline): - +class AjaxSelectAdminInlineFormsetMixin(object): def get_formset(self, request, obj=None, **kwargs): - fs = super(AjaxSelectAdminTabularInline, self).get_formset(request, obj, **kwargs) + fs = super(AjaxSelectAdminFormsetMixin, self).get_formset(request, obj, **kwargs) autoselect_fields_check_can_add(fs.form, self.model, request.user) return fs + + +class AjaxSelectAdminTabularInline(AjaxSelectAdminFormsetMixin, admin.TabularInline): + pass + + +class AjaxSelectAdminStackedInline(AjaxSelectAdminFormsetMixin, admin.StackedInline): + pass From 8a3d41aae51033f97d331de61b83a1957db45da2 Mon Sep 17 00:00:00 2001 From: Villiers Strauss Date: Fri, 5 Sep 2014 10:38:49 +0200 Subject: [PATCH 02/85] Doh! messed up the name! --- ajax_select/admin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ajax_select/admin.py b/ajax_select/admin.py index 8a9c6d8e10..ed4f075c18 100644 --- a/ajax_select/admin.py +++ b/ajax_select/admin.py @@ -15,14 +15,14 @@ def get_form(self, request, obj=None, **kwargs): class AjaxSelectAdminInlineFormsetMixin(object): def get_formset(self, request, obj=None, **kwargs): - fs = super(AjaxSelectAdminFormsetMixin, self).get_formset(request, obj, **kwargs) + fs = super(AjaxSelectAdminInlineFormsetMixin, self).get_formset(request, obj, **kwargs) autoselect_fields_check_can_add(fs.form, self.model, request.user) return fs -class AjaxSelectAdminTabularInline(AjaxSelectAdminFormsetMixin, admin.TabularInline): +class AjaxSelectAdminTabularInline(AjaxSelectAdminInlineFormsetMixin, admin.TabularInline): pass -class AjaxSelectAdminStackedInline(AjaxSelectAdminFormsetMixin, admin.StackedInline): +class AjaxSelectAdminStackedInline(AjaxSelectAdminInlineFormsetMixin, admin.StackedInline): pass From 02b5e360aa571fed11735c389c067142ed1c7e7d Mon Sep 17 00:00:00 2001 From: Villiers Strauss Date: Thu, 2 Apr 2015 09:25:52 +0200 Subject: [PATCH 03/85] Hacks! Workaround for Django 1.8 complaining about the form not having exclude defined. --- ajax_select/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index 511feaf963..41555ebd1b 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -92,7 +92,7 @@ class YourModelAdmin(Admin): class TheForm(superclass): class Meta: - pass + exclude = () setattr(Meta, 'model', model) if hasattr(superclass, 'Meta'): if hasattr(superclass.Meta, 'fields'): @@ -212,4 +212,4 @@ class MadeLookupChannel(LookupChannel): model = themodel search_field = arg_search_field - return MadeLookupChannel() + return MadeLookupChannel() \ No newline at end of file From 9b115edda1ca0fe529a0dff30a13b9c36a0f15e8 Mon Sep 17 00:00:00 2001 From: Villiers Strauss Date: Thu, 2 Apr 2015 11:00:04 +0200 Subject: [PATCH 04/85] Stop warning about `django.forms.util` being renamed to `django.forms.utils` --- ajax_select/fields.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ajax_select/fields.py b/ajax_select/fields.py index bbc4fd0a21..e98a159fb2 100644 --- a/ajax_select/fields.py +++ b/ajax_select/fields.py @@ -5,7 +5,7 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.urlresolvers import reverse -from django.forms.util import flatatt +from django.forms.utils import flatatt from django.template.loader import render_to_string from django.template.defaultfilters import force_escape from django.utils.encoding import force_text @@ -426,4 +426,4 @@ def plugin_options(channel, channel_name, widget_plugin_options, initial): # continue to support any custom templates that still expect these 'lookup_url': po['source'], 'min_length': po['min_length'] - } + } \ No newline at end of file From dbc69258f5c95c14a8caf679442d3c27c2c21c4e Mon Sep 17 00:00:00 2001 From: Villiers Strauss Date: Thu, 2 Apr 2015 11:32:36 +0200 Subject: [PATCH 05/85] Workaround to have Django 1.8 not break. --- ajax_select/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index 511feaf963..c979d70675 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -92,7 +92,7 @@ class YourModelAdmin(Admin): class TheForm(superclass): class Meta: - pass + exclude = () setattr(Meta, 'model', model) if hasattr(superclass, 'Meta'): if hasattr(superclass.Meta, 'fields'): From e871951ef0c17c8e22adecccd18b650be6cfa6a2 Mon Sep 17 00:00:00 2001 From: Villiers Strauss Date: Thu, 2 Apr 2015 11:40:37 +0200 Subject: [PATCH 06/85] Use django.forms.utils instead of django.forms.util if available. --- ajax_select/fields.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ajax_select/fields.py b/ajax_select/fields.py index bbc4fd0a21..73c74792de 100644 --- a/ajax_select/fields.py +++ b/ajax_select/fields.py @@ -5,7 +5,6 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.urlresolvers import reverse -from django.forms.util import flatatt from django.template.loader import render_to_string from django.template.defaultfilters import force_escape from django.utils.encoding import force_text @@ -16,6 +15,10 @@ except ImportError: from django.utils import simplejson as json +try: + from django.forms.utils import flatatt +except ImportError: + from django.forms.util import flatatt as_default_help = 'Enter text to search.' IS_PYTHON2 = sys.version_info[0] == 2 From 92884fcaf95f8b23261c27188297a6890d9101de Mon Sep 17 00:00:00 2001 From: Villiers Strauss Date: Wed, 8 Apr 2015 09:06:59 +0200 Subject: [PATCH 07/85] Added example of AjaxSelectAdminStackedInline --- example/example/admin.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/example/example/admin.py b/example/example/admin.py index 91d35fa3cb..872a1d01eb 100644 --- a/example/example/admin.py +++ b/example/example/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from ajax_select import make_ajax_form -from ajax_select.admin import AjaxSelectAdmin, AjaxSelectAdminTabularInline +from ajax_select.admin import AjaxSelectAdmin, AjaxSelectAdminTabularInline, AjaxSelectAdminStackedInline from example.forms import ReleaseForm from example.models import Person, Label, Group, Song, Release, Book, Author class PersonAdmin(AjaxSelectAdmin): - + pass admin.site.register(Person, PersonAdmin) @@ -31,10 +31,26 @@ class PersonAdmin(YourAdminSuperclass, AjaxSelectAdmin): admin.site.register(Label, LabelAdmin) +class ReleaseInline(AjaxSelectAdminStackedInline): + + # Example of the stacked inline + + model = Release + form = make_ajax_form(Release, { + 'group': 'group', + 'label': 'label', + 'songs': 'song', + }) + extra = 1 + + class GroupAdmin(AjaxSelectAdmin): # this shows a ManyToMany field form = make_ajax_form(Group, {'members': 'person'}) + inlines = [ + ReleaseInline + ] admin.site.register(Group, GroupAdmin) From 80a640a5e319d58467d992fe7a464386eb619e29 Mon Sep 17 00:00:00 2001 From: Chris Sattinger Date: Fri, 10 Apr 2015 10:39:07 +0200 Subject: [PATCH 08/85] fix #117 casting unicode to numeric --- ajax_select/fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ajax_select/fields.py b/ajax_select/fields.py index 5e18e50bc5..905a3d0a5a 100644 --- a/ajax_select/fields.py +++ b/ajax_select/fields.py @@ -26,7 +26,7 @@ def _as_pk(got): # a unicode method that checks for integers - if got.isnumeric(): + if (type(got) is unicode) and got.isnumeric(): if IS_PYTHON2: return long(got) return int(got) From b4d2aa26e0f33f1735ea8e3da932f38705418470 Mon Sep 17 00:00:00 2001 From: Chris Sattinger Date: Fri, 10 Apr 2015 13:05:47 +0200 Subject: [PATCH 09/85] add testing with tox and travis for django 1.5 - 1.8, python 2.7 3.3 matrix still to be added --- .gitignore | 7 +++++-- .travis.yml | 13 +++++++++++++ requirements-test.txt | 4 ++++ test_settings.py | 25 ++++++++++++++++++++++++ tests/__init__.py | 0 tox.ini | 44 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 .travis.yml create mode 100644 requirements-test.txt create mode 100644 test_settings.py create mode 100644 tests/__init__.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore index c9e94440cc..86c5e6fb82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ -*.pyc +*.pyc *.egg-info/ -example/AJAXSELECTS/* \ No newline at end of file +example/AJAXSELECTS/* +.tox +htmlcov +.coverage diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..9e6383c6bc --- /dev/null +++ b/.travis.yml @@ -0,0 +1,13 @@ +language: python +python: + - "2.7" +env: + - TOX_ENV=flake8 + - TOX_ENV=py27-django15 + - TOX_ENV=py27-django16 + - TOX_ENV=py27-django17 + - TOX_ENV=py27-django18 +install: + - pip install -r requirements-test.txt +script: + - tox -e $TOX_ENV diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000000..69369868b7 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,4 @@ +coverage +coveralls +flake8>=2.1.0 +tox>=1.7.0 diff --git a/test_settings.py b/test_settings.py new file mode 100644 index 0000000000..7600f45f32 --- /dev/null +++ b/test_settings.py @@ -0,0 +1,25 @@ +DEBUG = True +USE_TZ = True +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + } +} +ROOT_URLCONF = "ajax_select.urls" +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sites", + 'django.contrib.messages', + 'django.contrib.sessions', + 'django.contrib.admin', + 'django.contrib.staticfiles', + "ajax_select", +] +SITE_ID = 1 +MIDDLEWARE_CLASSES = ( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware' +) +SECRET_KEY = 'inyd5fc5pymlsv@hwoc5+3_6*cm0erlxzv6i-wl0jm_kt-6rp9' diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000000..7c9d5e1200 --- /dev/null +++ b/tox.ini @@ -0,0 +1,44 @@ +[tox] +envlist = flake8, django15, django16, django17, django18 + +# py33 not supported until django-nose supports it + +[base] +deps = + -r{toxinidir}/requirements-test.txt + +[testenv] +setenv = + DJANGO_SETTINGS_MODULE=test_settings + PYTHONPATH = {toxinidir}:{toxinidir}/ajax_select +commands = django-admin.py test ajax_select +deps = + {[base]deps} + + +[testenv:flake8] +basepython=python +deps=flake8 +commands= + flake8 . + + +[testenv:django15] +deps = + django>=1.5, < 1.6 + {[base]deps} + +[testenv:django16] +deps = + django>=1.6, < 1.7 + {[base]deps} + +[testenv:django17] +deps = + django>=1.7, < 1.8 + {[base]deps} + +[testenv:django18] +deps = + django>=1.8, < 1.9 + {[base]deps} From 90c18ab89c8e4e866ab3d22f08a6086f6b2643ae Mon Sep 17 00:00:00 2001 From: Chris Sattinger Date: Fri, 10 Apr 2015 13:06:14 +0200 Subject: [PATCH 10/85] add first test --- tests/test_fields.py | 22 +++++++++++++++++++++ tests/test_models.py | 47 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 tests/test_fields.py create mode 100644 tests/test_models.py diff --git a/tests/test_fields.py b/tests/test_fields.py new file mode 100644 index 0000000000..36496bac26 --- /dev/null +++ b/tests/test_fields.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +import unittest + +from ajax_select import fields +# from test_models import Book, Person, Author + + +class TestAjaxSelectAutoCompleteSelectWidget(unittest.TestCase): + + def setUp(self): + pass + + def test_render(self): + channel = None + widget = fields.AutoCompleteSelectWidget(channel) + widget + + def tearDown(self): + pass diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000000..39c1ca6e38 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +test_django-ajax-selects +------------ + +Tests for `django-ajax-selects` models module. +""" + +# import unittest +from django.db import models + + +# --------------------------- models ---------------------------------- # + +class Person(models.Model): + + name = models.CharField() + + +class Author(models.Model): + + name = models.CharField() + + +class Book(models.Model): + + """ Book has no admin, its an inline in the Author admin""" + + author = models.ForeignKey(Author) + name = models.CharField() + mentions_persons = models.ManyToManyField(Person, help_text="MENTIONS PERSONS HELP TEXT") + + +# --------------------------- tests ---------------------------------- # + +# class TestAjax_select(unittest.TestCase): + +# def setUp(self): +# pass + +# def test_something(self): +# pass + +# def tearDown(self): +# pass From 3d4f2a074f7461face6585f49bd026cd3f89a338 Mon Sep 17 00:00:00 2001 From: Chris Sattinger Date: Fri, 10 Apr 2015 13:07:56 +0200 Subject: [PATCH 11/85] add Makefile with lint, text, test-all, coverage --- Makefile | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..434e86cb40 --- /dev/null +++ b/Makefile @@ -0,0 +1,56 @@ +.PHONY: clean-pyc clean-build +# docs + +help: + @echo "clean-build - remove build artifacts" + @echo "clean-pyc - remove Python file artifacts" + @echo "lint - check style with flake8" + @echo "test - run tests quickly with the default Python" + @echo "testall - run tests on every Python version with tox" + @echo "coverage - check code coverage quickly with the default Python" + @echo "docs - generate Sphinx HTML documentation, including API docs" + @echo "release - package and upload a release" + @echo "sdist - package" + +clean: clean-build clean-pyc + +clean-build: + rm -fr build/ + rm -fr dist/ + rm -fr *.egg-info + +clean-pyc: + find . -name '*.pyc' -exec rm -f {} + + find . -name '*.pyo' -exec rm -f {} + + find . -name '*~' -exec rm -f {} + + +lint: + flake8 . + +test: + django-admin.py test --settings=test_settings ajax_select + +test-all: + tox + +coverage: + coverage run --source ajax_select setup.py test + coverage report -m + coverage html + open htmlcov/index.html + +# docs: +# rm -f docs/django-ajax-selects.rst +# rm -f docs/modules.rst +# sphinx-apidoc -o docs/ django-ajax-selects +# $(MAKE) -C docs clean +# $(MAKE) -C docs html +# open docs/_build/html/index.html + +release: clean + python setup.py sdist upload + python setup.py bdist_wheel upload + +sdist: clean + python setup.py sdist + ls -l dist From d98c21c65057c758caac95ed762d50e0cd44552f Mon Sep 17 00:00:00 2001 From: Chris Sattinger Date: Fri, 10 Apr 2015 13:09:04 +0200 Subject: [PATCH 12/85] add requirements.txt note: if you aredeveloping then create a virtual env and install requirements-test.txt --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000..7530d6e951 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +django>=1.5.1, <1.9 +wheel==0.24.0 From f1f3d98c7d14ce3077145b2e6246ddb6ecb51692 Mon Sep 17 00:00:00 2001 From: Chris Sattinger Date: Fri, 10 Apr 2015 13:09:18 +0200 Subject: [PATCH 13/85] add top level LICENSE file --- LICENSE | 705 ++++++++++++++++++++++++++++++++++++++++ ajax_select/LICENSE.txt | 702 ++++++++++++++++++++++++++++++++++++++- setup.py | 1 + 3 files changed, 1407 insertions(+), 1 deletion(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..979d19cb77 --- /dev/null +++ b/LICENSE @@ -0,0 +1,705 @@ +Copyright (c) 2009-2015 Chris Sattinger + +Dual licensed under the MIT and GPL licenses: + http://www.opensource.org/licenses/mit-license.php + http://www.gnu.org/licenses/gpl.html + +-------------------------------------------------------------------------- + +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +----------------------------------------------------------------------- + +GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {one line to give the program's name and a brief idea of what it does.} + Copyright (C) {year} {name of author} + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + {project} Copyright (C) {year} {fullname} + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/ajax_select/LICENSE.txt b/ajax_select/LICENSE.txt index d2aa320016..979d19cb77 100644 --- a/ajax_select/LICENSE.txt +++ b/ajax_select/LICENSE.txt @@ -1,5 +1,705 @@ -Copyright (c) 2009-2013 Chris Sattinger +Copyright (c) 2009-2015 Chris Sattinger Dual licensed under the MIT and GPL licenses: http://www.opensource.org/licenses/mit-license.php http://www.gnu.org/licenses/gpl.html + +-------------------------------------------------------------------------- + +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +----------------------------------------------------------------------- + +GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {one line to give the program's name and a brief idea of what it does.} + Copyright (C) {year} {name of author} + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + {project} Copyright (C) {year} {fullname} + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/setup.py b/setup.py index db063c2ac7..9d62d066b4 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,7 @@ }, include_package_data=True, zip_safe=False, + license="MIT", classifiers=[ "Programming Language :: Python", "Programming Language :: Python :: 2", From c15d87e8bd3aba5d2e4353dc1896c83e70564859 Mon Sep 17 00:00:00 2001 From: Chris Sattinger Date: Fri, 10 Apr 2015 13:11:37 +0200 Subject: [PATCH 14/85] fix flake8 whitespace errors ./example/example/admin.py:10:1: W293 blank line contains whitespace ./example/example/admin.py:35:1: W293 blank line contains whitespace --- example/example/admin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example/example/admin.py b/example/example/admin.py index 872a1d01eb..ef3e2de7d7 100644 --- a/example/example/admin.py +++ b/example/example/admin.py @@ -7,7 +7,7 @@ class PersonAdmin(AjaxSelectAdmin): - + pass admin.site.register(Person, PersonAdmin) @@ -32,7 +32,7 @@ class PersonAdmin(YourAdminSuperclass, AjaxSelectAdmin): class ReleaseInline(AjaxSelectAdminStackedInline): - + # Example of the stacked inline model = Release From 37ab3e29019321d02d2789a4fb24cfdcd2597775 Mon Sep 17 00:00:00 2001 From: Villiers Strauss Date: Fri, 17 Apr 2015 16:40:45 +0200 Subject: [PATCH 15/85] ajax_lookup should respond with content type `application/json`, not `application/javascript` --- ajax_select/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ajax_select/views.py b/ajax_select/views.py index c23c3880f9..bd2ed35684 100644 --- a/ajax_select/views.py +++ b/ajax_select/views.py @@ -44,7 +44,7 @@ def ajax_lookup(request, channel): } for item in instances ]) - return HttpResponse(results, content_type='application/javascript') + return HttpResponse(results, content_type='application/json') def add_popup(request, app_label, model): From 107dfb51696fd2073f0ce6c76570546fcd4edd5b Mon Sep 17 00:00:00 2001 From: Chris Sattinger Date: Fri, 17 Apr 2015 18:53:02 +0200 Subject: [PATCH 16/85] testing: fix tox file - was not installing django correctly --- tox.ini | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/tox.ini b/tox.ini index 7c9d5e1200..8da23a1edb 100644 --- a/tox.ini +++ b/tox.ini @@ -1,44 +1,33 @@ [tox] envlist = flake8, django15, django16, django17, django18 -# py33 not supported until django-nose supports it - -[base] -deps = - -r{toxinidir}/requirements-test.txt [testenv] setenv = DJANGO_SETTINGS_MODULE=test_settings PYTHONPATH = {toxinidir}:{toxinidir}/ajax_select commands = django-admin.py test ajax_select -deps = - {[base]deps} [testenv:flake8] basepython=python -deps=flake8 -commands= - flake8 . +deps = + flake8 +commands = flake8 . [testenv:django15] deps = - django>=1.5, < 1.6 - {[base]deps} + django>=1.5, <1.6 [testenv:django16] deps = - django>=1.6, < 1.7 - {[base]deps} + django>=1.6, <1.7 [testenv:django17] deps = - django>=1.7, < 1.8 - {[base]deps} + django>=1.7, <1.8 [testenv:django18] deps = - django>=1.8, < 1.9 - {[base]deps} + django>=1.8, <1.9 From 04025272284b8489a8c95d0ac62e08b37f70332f Mon Sep 17 00:00:00 2001 From: Chris Sattinger Date: Fri, 17 Apr 2015 19:03:27 +0200 Subject: [PATCH 17/85] fix travis: TOX_ENV were incorrect --- .travis.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 9e6383c6bc..fe19842ce7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,10 +3,10 @@ python: - "2.7" env: - TOX_ENV=flake8 - - TOX_ENV=py27-django15 - - TOX_ENV=py27-django16 - - TOX_ENV=py27-django17 - - TOX_ENV=py27-django18 + - TOX_ENV=django15 + - TOX_ENV=django16 + - TOX_ENV=django17 + - TOX_ENV=django18 install: - pip install -r requirements-test.txt script: From ee88093aacd7f020a83741397b26776c9947157a Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Wed, 3 Jun 2015 11:29:34 +0200 Subject: [PATCH 18/85] add a simple test runner, fix: make coverage and make test --- Makefile | 8 +++---- runtests.py | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 runtests.py diff --git a/Makefile b/Makefile index 434e86cb40..06a06f8350 100644 --- a/Makefile +++ b/Makefile @@ -5,8 +5,8 @@ help: @echo "clean-build - remove build artifacts" @echo "clean-pyc - remove Python file artifacts" @echo "lint - check style with flake8" - @echo "test - run tests quickly with the default Python" - @echo "testall - run tests on every Python version with tox" + @echo "test - run tests quickly with the currently installed Django" + @echo "testall - run tests on every Django version with tox" @echo "coverage - check code coverage quickly with the default Python" @echo "docs - generate Sphinx HTML documentation, including API docs" @echo "release - package and upload a release" @@ -28,13 +28,13 @@ lint: flake8 . test: - django-admin.py test --settings=test_settings ajax_select + python runtests.py test-all: tox coverage: - coverage run --source ajax_select setup.py test + coverage run --source ajax_select runtests.py coverage report -m coverage html open htmlcov/index.html diff --git a/runtests.py b/runtests.py new file mode 100644 index 0000000000..1ee8486e49 --- /dev/null +++ b/runtests.py @@ -0,0 +1,67 @@ +import sys + +try: + from django.conf import settings + + settings.configure( + DEBUG=True, + USE_TZ=True, + DATABASES={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + } + }, + ROOT_URLCONF="ajax_select.urls", + INSTALLED_APPS=[ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sites", + 'django.contrib.messages', + 'django.contrib.sessions', + 'django.contrib.admin', + 'django.contrib.staticfiles', + "ajax_select", + ], + SITE_ID=1, + MIDDLEWARE_CLASSES=( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware' + ) + ) + + try: + import django + setup = django.setup + except AttributeError: + pass + else: + setup() + + try: + from django.test.runner import DiscoverRunner as TestRunner + except ImportError: + # < 1.8 + from django.test.simple import DjangoTestSuiteRunner as TestRunner + +except ImportError: + import traceback + traceback.print_exc() + raise ImportError( + "Make sure that you have installed test requirements: " + "pip install -r requirements-test.txt") + + +def run_tests(*test_args): + if not test_args: + test_args = ['ajax_select'] + + runner = TestRunner() + failures = runner.run_tests(test_args, verbosity=1) + if failures: + pass + # sys.exit(failures) + + +if __name__ == '__main__': + run_tests(*sys.argv[1:]) From 049743b8389ad0b99d38f47748351a1b4ef5e6bb Mon Sep 17 00:00:00 2001 From: Tony Morrow Date: Sun, 14 Jun 2015 23:59:45 -0500 Subject: [PATCH 19/85] Core autodiscover code --- ajax_select/__init__.py | 17 ++++++++++++---- ajax_select/apps.py | 24 ++++++++++++++++++++++ ajax_select/decorators.py | 28 ++++++++++++++++++++++++++ ajax_select/sites.py | 42 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 ajax_select/apps.py create mode 100644 ajax_select/decorators.py create mode 100644 ajax_select/sites.py diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index 0a3026fe9d..efccd7ab98 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -13,6 +13,9 @@ from django.utils.encoding import force_text from django.utils.html import escape from django.utils.translation import ugettext_lazy as _ +from django.utils.module_loading import autodiscover_modules +from .sites import site +from .decorators import register class LookupChannel(object): @@ -166,11 +169,10 @@ def make_ajax_field(model, model_fieldname, channel, show_help_text=False, **kwa def get_lookup(channel): """ find the lookup class for the named channel. this is used internally """ try: - lookup_label = settings.AJAX_LOOKUP_CHANNELS[channel] - except AttributeError: - raise ImproperlyConfigured("settings.AJAX_LOOKUP_CHANNELS is not configured") + lookup_label = site._registry[channel] except KeyError: - raise ImproperlyConfigured("settings.AJAX_LOOKUP_CHANNELS not configured correctly for %r" % channel) + raise ImproperlyConfigured("settings.AJAX_LOOKUP_CHANNELS not configured correctly for %(channel)r " + "or autodiscovery could not locate %(channel)r" % {'channel': channel}) if isinstance(lookup_label, dict): # 'channel' : dict(model='app.model', search_field='title' ) @@ -214,3 +216,10 @@ class MadeLookupChannel(LookupChannel): search_field = arg_search_field return MadeLookupChannel() + + + +def autodiscover(): + autodiscover_modules('lookups', register_to=site) + +default_app_config = 'ajax_select.apps.AjaxSelectConfig' diff --git a/ajax_select/apps.py b/ajax_select/apps.py new file mode 100644 index 0000000000..237582b61f --- /dev/null +++ b/ajax_select/apps.py @@ -0,0 +1,24 @@ +from django.apps import AppConfig +from django.core.exceptions import ImproperlyConfigured +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ + +from .sites import site + + +class SimpleAjaxSelectConfig(AppConfig): + + name = 'ajax_select' + verbose_name = _('AjaxSelects') + + def ready(self): + try: + site.register(settings.AJAX_LOOKUP_CHANNELS) + except AttributeError: + raise ImproperlyConfigured("settings.AJAX_LOOKUP_CHANNELS is not configured") + +class AjaxSelectConfig(SimpleAjaxSelectConfig): + + def ready(self): + super(AjaxSelectConfig, self).ready() + self.module.autodiscover() diff --git a/ajax_select/decorators.py b/ajax_select/decorators.py new file mode 100644 index 0000000000..bbd7556405 --- /dev/null +++ b/ajax_select/decorators.py @@ -0,0 +1,28 @@ +def register(label): + """ + Registers the given model(s) classes and wrapped ModelAdmin class with + admin site: + @register(Author) + class AuthorAdmin(admin.ModelAdmin): + pass + A kwarg of `site` can be passed as the admin site, otherwise the default + admin site will be used. + """ + + from ajax_select import LookupChannel + from ajax_select.sites import site + + def _ajax_select_wrapper(lookup_class): + + if not label or len(label) == 0: + raise ValueError('Lookup Channel must have a label') + + if not issubclass(lookup_class, LookupChannel): + raise ValueError('Wrapped class must subclass LookupChannel.') + + lookup_module_location = lookup_class.__module__ + + site.register({label: (lookup_module_location, lookup_class.__name__) }) + + return lookup_class + return _ajax_select_wrapper \ No newline at end of file diff --git a/ajax_select/sites.py b/ajax_select/sites.py new file mode 100644 index 0000000000..cb175381ab --- /dev/null +++ b/ajax_select/sites.py @@ -0,0 +1,42 @@ + +class AlreadyRegistered(Exception): + pass + + +class NotRegistered(Exception): + pass + + +class AjaxSelectSite(object): + + def __init__(self): + self._registry = {} + + def register(self, lookup_labels): + + # channel data structure is { 'channel' : ( module.lookup, lookupclass ) } + # or { 'channel' : { 'model': 'xxxxx', 'search_field': 'xxxx' }} + for channel_name in lookup_labels.keys(): + if self.is_registered(channel_name): + raise AlreadyRegistered('The channel "%s" is already registered' % channel_name) + self._registry.update(lookup_labels) + + def unregister(self, lookup_labels): + """ + Unregisters the given model(s). + If a model isn't already registered, this will raise NotRegistered. + """ + # channel data structure is { label : ( module.lookup, lookupclass ) } + for channel_name in lookup_labels.keys(): + if not self.is_registered(channel_name): + raise NotRegistered('The channel "%s" is not registered' % channel_name) + del self._registry[channel_name] + + def is_registered(self, label): + """ + Check if a LookupChannel class is registered with this `AjaxSelectSite`. + """ + return label in self._registry + + +site = AjaxSelectSite() \ No newline at end of file From 7e38b56318425bf360ca25e42f23962c4d2e58e7 Mon Sep 17 00:00:00 2001 From: Rebecca Sutton Koeser Date: Mon, 15 Jun 2015 12:44:31 -0400 Subject: [PATCH 20/85] Example for get_formset on inline admin --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 51267e62d1..e3d1a3b0e0 100644 --- a/README.md +++ b/README.md @@ -369,6 +369,13 @@ You may use AjaxSelectAdmin as a mixin class and multiple inherit if you have an autoselect_fields_check_can_add(form, self.model, request.user) return form +If you are using ajax select fields on an Inline form in Django Admin, you should override get_formset to check permission and include the add button when appropriate: + + def get_formset(self, request, obj=None, **kwargs): + formset = super(YourAdminClass, self).get_form(request, obj, **kwargs) + autoselect_fields_check_can_add(formset.form, self.model, request.user) + return formset + 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. From 0d56d5ba51691612bdbbbb364b7cd09c8cd1e1d9 Mon Sep 17 00:00:00 2001 From: Tony Morrow Date: Sat, 20 Jun 2015 13:30:50 -0500 Subject: [PATCH 21/85] Adding tests; minor fix to registry --- ajax_select/apps.py | 5 ++- ajax_select/sites.py | 2 +- example/ajax_select | 1 + example/ajax_selects_example | Bin 0 -> 72704 bytes example/example/lookups.py | 4 ++- example/example/settings.py | 1 - tests/test_lookups.py | 59 +++++++++++++++++++++++++++++++++++ tests/test_models.py | 8 ++++- tox.ini | 3 +- 9 files changed, 75 insertions(+), 8 deletions(-) create mode 120000 example/ajax_select create mode 100644 example/ajax_selects_example create mode 100644 tests/test_lookups.py diff --git a/ajax_select/apps.py b/ajax_select/apps.py index 237582b61f..950c876bea 100644 --- a/ajax_select/apps.py +++ b/ajax_select/apps.py @@ -12,10 +12,9 @@ class SimpleAjaxSelectConfig(AppConfig): verbose_name = _('AjaxSelects') def ready(self): - try: + if hasattr(settings, 'AJAX_LOOKUP_CHANNELS'): site.register(settings.AJAX_LOOKUP_CHANNELS) - except AttributeError: - raise ImproperlyConfigured("settings.AJAX_LOOKUP_CHANNELS is not configured") + class AjaxSelectConfig(SimpleAjaxSelectConfig): diff --git a/ajax_select/sites.py b/ajax_select/sites.py index cb175381ab..cafb790efe 100644 --- a/ajax_select/sites.py +++ b/ajax_select/sites.py @@ -19,7 +19,7 @@ def register(self, lookup_labels): for channel_name in lookup_labels.keys(): if self.is_registered(channel_name): raise AlreadyRegistered('The channel "%s" is already registered' % channel_name) - self._registry.update(lookup_labels) + self._registry.update(lookup_labels) def unregister(self, lookup_labels): """ diff --git a/example/ajax_select b/example/ajax_select new file mode 120000 index 0000000000..6f5d3e8aa1 --- /dev/null +++ b/example/ajax_select @@ -0,0 +1 @@ +../ajax_select/ \ No newline at end of file diff --git a/example/ajax_selects_example b/example/ajax_selects_example new file mode 100644 index 0000000000000000000000000000000000000000..a0f26cde717ad13ebd574c20c4e53aa54cac97a5 GIT binary patch literal 72704 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 %s" % (escape(obj.title), escape(obj.group.name)) - +# Here using decorator syntax rather than setting.AJAX_LOOKUP_CHANNELS +@ajax_select.register('cliche') class ClicheLookup(LookupChannel): """ an autocomplete lookup does not need to search models diff --git a/example/example/settings.py b/example/example/settings.py index 3a51a14caf..13ec5d1c10 100644 --- a/example/example/settings.py +++ b/example/example/settings.py @@ -35,7 +35,6 @@ 'person': ('example.lookups', 'PersonLookup'), 'group': ('example.lookups', 'GroupLookup'), 'song': ('example.lookups', 'SongLookup'), - 'cliche': ('example.lookups', 'ClicheLookup') } diff --git a/tests/test_lookups.py b/tests/test_lookups.py new file mode 100644 index 0000000000..99baa5217e --- /dev/null +++ b/tests/test_lookups.py @@ -0,0 +1,59 @@ +from django.db.models import Q +from django.utils.html import escape + +import ajax_select +from ajax_select import LookupChannel + +from .test_models import Person + +from django.test import TestCase + +@ajax_select.register('testperson') +class PersonLookup(LookupChannel): + model = Person + + 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): + u""" result is the simple text that is the completion of what the person typed """ + return obj.name + + def format_match(self, obj): + """ (HTML) formatted item for display in the dropdown """ + return u"%s
%s
" % (escape(obj.name), escape(obj.email)) + # return self.format_item_display(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)) + + +class UnregisteredPersonLookup(LookupChannel): + model = Person + + 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): + u""" result is the simple text that is the completion of what the person typed """ + return obj.name + + def format_match(self, obj): + """ (HTML) formatted item for display in the dropdown """ + return u"%s
%s
" % (escape(obj.name), escape(obj.email)) + # return self.format_item_display(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)) + +class TestPersonLookup(TestCase): + def setUp(self): + pass + + def test_person_lookup_is_registered(self): + self.assertIsNotNone(ajax_select.site._registry.get('testperson')) + + def test_person_lookup_is_registered(self): + self.assertIsNone(ajax_select.site._registry.get('testunregisteredperson')) \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py index 39c1ca6e38..6f01b80d2c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -18,11 +18,16 @@ class Person(models.Model): name = models.CharField() + class Meta: + app_label = 'testapp' class Author(models.Model): name = models.CharField() + class Meta: + app_label = 'testapp' + class Book(models.Model): @@ -32,7 +37,8 @@ class Book(models.Model): name = models.CharField() mentions_persons = models.ManyToManyField(Person, help_text="MENTIONS PERSONS HELP TEXT") - + class Meta: + app_label = 'testapp' # --------------------------- tests ---------------------------------- # # class TestAjax_select(unittest.TestCase): diff --git a/tox.ini b/tox.ini index 8da23a1edb..412886ee85 100644 --- a/tox.ini +++ b/tox.ini @@ -5,8 +5,9 @@ envlist = flake8, django15, django16, django17, django18 [testenv] setenv = DJANGO_SETTINGS_MODULE=test_settings - PYTHONPATH = {toxinidir}:{toxinidir}/ajax_select + PYTHONPATH = {toxinidir}:{toxinidir}/ajax_select:{toxinidir}/tests commands = django-admin.py test ajax_select + django-admin.py test tests [testenv:flake8] From ba169b9732fa457ae21cff917a926c642ffb9ea4 Mon Sep 17 00:00:00 2001 From: Tony Morrow Date: Sat, 20 Jun 2015 18:28:36 -0500 Subject: [PATCH 22/85] About as far as I can make the tests work --- tests/test_lookups.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_lookups.py b/tests/test_lookups.py index 99baa5217e..2e76758bc4 100644 --- a/tests/test_lookups.py +++ b/tests/test_lookups.py @@ -55,5 +55,5 @@ def setUp(self): def test_person_lookup_is_registered(self): self.assertIsNotNone(ajax_select.site._registry.get('testperson')) - def test_person_lookup_is_registered(self): + def test_unregistered_person_lookup_is_not_registered(self): self.assertIsNone(ajax_select.site._registry.get('testunregisteredperson')) \ No newline at end of file From 0353beb24b7eac9200c6baf21cacc87480a233d0 Mon Sep 17 00:00:00 2001 From: morr0350 Date: Tue, 23 Jun 2015 14:29:12 -0500 Subject: [PATCH 23/85] imports cleanup --- ajax_select/apps.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ajax_select/apps.py b/ajax_select/apps.py index 950c876bea..24419329ed 100644 --- a/ajax_select/apps.py +++ b/ajax_select/apps.py @@ -1,5 +1,4 @@ from django.apps import AppConfig -from django.core.exceptions import ImproperlyConfigured from django.conf import settings from django.utils.translation import ugettext_lazy as _ From d4a47de1ab309362bc9da8ef6c85e38b079dffdf Mon Sep 17 00:00:00 2001 From: morr0350 Date: Tue, 23 Jun 2015 14:31:05 -0500 Subject: [PATCH 24/85] comment typo cleanup --- example/example/lookups.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/example/lookups.py b/example/example/lookups.py index ebd82ac560..ec1edaedb9 100644 --- a/example/example/lookups.py +++ b/example/example/lookups.py @@ -67,7 +67,7 @@ def format_match(self, obj): def format_item_display(self, obj): return "%s
by %s
" % (escape(obj.title), escape(obj.group.name)) -# Here using decorator syntax rather than setting.AJAX_LOOKUP_CHANNELS +# Here using decorator syntax rather than settings.AJAX_LOOKUP_CHANNELS @ajax_select.register('cliche') class ClicheLookup(LookupChannel): From 487fe0cf8babfb194774ae165cf1a0231ae984cd Mon Sep 17 00:00:00 2001 From: morr0350 Date: Tue, 23 Jun 2015 14:40:33 -0500 Subject: [PATCH 25/85] cleanup - removing symlink --- example/ajax_select | 1 - 1 file changed, 1 deletion(-) delete mode 120000 example/ajax_select diff --git a/example/ajax_select b/example/ajax_select deleted file mode 120000 index 6f5d3e8aa1..0000000000 --- a/example/ajax_select +++ /dev/null @@ -1 +0,0 @@ -../ajax_select/ \ No newline at end of file From b42dff1b3414e66d796a5b5950ac65092ef7b3b1 Mon Sep 17 00:00:00 2001 From: morr0350 Date: Tue, 23 Jun 2015 14:44:48 -0500 Subject: [PATCH 26/85] cleanup - rm database file --- example/ajax_selects_example | Bin 72704 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 example/ajax_selects_example diff --git a/example/ajax_selects_example b/example/ajax_selects_example deleted file mode 100644 index a0f26cde717ad13ebd574c20c4e53aa54cac97a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 72704 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 Date: Thu, 25 Jun 2015 20:33:43 -0500 Subject: [PATCH 27/85] Test support for Django 1.5, version checking to be backward compatible/disable autodiscovery in Django <=1.6 --- ajax_select/__init__.py | 17 ++++++++++++++--- test_settings.py | 1 + tests/models.py | 1 + tests/{test_lookups.py => tests.py} | 0 4 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 tests/models.py rename tests/{test_lookups.py => tests.py} (100%) diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index efccd7ab98..9b3e3aa0fe 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -4,7 +4,7 @@ __contact__ = "crucialfelix@gmail.com" __homepage__ = "https://github.com/crucialfelix/django-ajax-selects/" -from django.conf import settings +import django from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.db.models.fields.related import ForeignKey, ManyToManyField from django.contrib.contenttypes.models import ContentType @@ -13,7 +13,7 @@ from django.utils.encoding import force_text from django.utils.html import escape from django.utils.translation import ugettext_lazy as _ -from django.utils.module_loading import autodiscover_modules + from .sites import site from .decorators import register @@ -220,6 +220,17 @@ class MadeLookupChannel(LookupChannel): def autodiscover(): + try: + from django.utils.module_loading import autodiscover_modules + except ImportError: + raise Exception("Django-Ajax-Selects now uses Django's built-in module autodiscovery by default. " + "AutoDiscovery is only supported in Django 1.7+. Try using the SimpleAjaxSelectConfig " + "in order to register lookup channels in settings.py for Django <=1.6.") autodiscover_modules('lookups', register_to=site) -default_app_config = 'ajax_select.apps.AjaxSelectConfig' + +if django.VERSION[0] == 1 and django.VERSION[1] <= 6: + # Django <= 1.6, use settings.AJAX_LOOKUP_CHANNELS only + default_app_config = 'ajax_select.apps.SimpleAjaxSelectConfig' +else: + default_app_config = 'ajax_select.apps.AjaxSelectConfig' diff --git a/test_settings.py b/test_settings.py index 7600f45f32..97cbc53cd0 100644 --- a/test_settings.py +++ b/test_settings.py @@ -15,6 +15,7 @@ 'django.contrib.admin', 'django.contrib.staticfiles', "ajax_select", + "tests" ] SITE_ID = 1 MIDDLEWARE_CLASSES = ( diff --git a/tests/models.py b/tests/models.py new file mode 100644 index 0000000000..f8126cc7c0 --- /dev/null +++ b/tests/models.py @@ -0,0 +1 @@ +# this file only needed in app for Django 1.5 support diff --git a/tests/test_lookups.py b/tests/tests.py similarity index 100% rename from tests/test_lookups.py rename to tests/tests.py From 773dbc23801b90359d0c40d53fa42f64c1087b19 Mon Sep 17 00:00:00 2001 From: morr0350 Date: Thu, 25 Jun 2015 20:47:08 -0500 Subject: [PATCH 28/85] Refactoring autodiscover importerror message --- ajax_select/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index 9b3e3aa0fe..e50a00ca0a 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -223,9 +223,8 @@ def autodiscover(): try: from django.utils.module_loading import autodiscover_modules except ImportError: - raise Exception("Django-Ajax-Selects now uses Django's built-in module autodiscovery by default. " - "AutoDiscovery is only supported in Django 1.7+. Try using the SimpleAjaxSelectConfig " - "in order to register lookup channels in settings.py for Django <=1.6.") + raise ImproperlyConfigured("AJAX_LOOKUP_CHANNELS is not set and cannot do " + "app autodiscovery unless Django version is >=1.7") autodiscover_modules('lookups', register_to=site) From cc547eb5b0d3d0199ab172d583d4a2285cc94dfa Mon Sep 17 00:00:00 2001 From: morr0350 Date: Thu, 25 Jun 2015 21:03:16 -0500 Subject: [PATCH 29/85] refactor version checking --- ajax_select/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index e50a00ca0a..4236c9d998 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -228,7 +228,7 @@ def autodiscover(): autodiscover_modules('lookups', register_to=site) -if django.VERSION[0] == 1 and django.VERSION[1] <= 6: +if django.VERSION[:2] <= (1, 6): # Django <= 1.6, use settings.AJAX_LOOKUP_CHANNELS only default_app_config = 'ajax_select.apps.SimpleAjaxSelectConfig' else: From 01800c9e297e903fda545beb7e5c88983d2a8c48 Mon Sep 17 00:00:00 2001 From: morr0350 Date: Thu, 25 Jun 2015 21:34:49 -0500 Subject: [PATCH 30/85] flake refactor --- ajax_select/__init__.py | 16 +++++++++------- ajax_select/decorators.py | 4 ++-- ajax_select/sites.py | 2 +- example/example/lookups.py | 1 + tests/test_models.py | 1 + tests/tests.py | 4 +++- 6 files changed, 17 insertions(+), 11 deletions(-) diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index 4236c9d998..bd5feeb9de 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -4,7 +4,8 @@ __contact__ = "crucialfelix@gmail.com" __homepage__ = "https://github.com/crucialfelix/django-ajax-selects/" -import django +from django import VERSION +from django.conf import settings from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.db.models.fields.related import ForeignKey, ManyToManyField from django.contrib.contenttypes.models import ContentType @@ -15,8 +16,7 @@ from django.utils.translation import ugettext_lazy as _ from .sites import site -from .decorators import register - +from .decorators import register # noqa class LookupChannel(object): @@ -218,18 +218,20 @@ class MadeLookupChannel(LookupChannel): return MadeLookupChannel() - def autodiscover(): try: from django.utils.module_loading import autodiscover_modules except ImportError: - raise ImproperlyConfigured("AJAX_LOOKUP_CHANNELS is not set and cannot do " + raise ImproperlyConfigured("AJAX_LOOKUP_CHANNELS is not set in settings.py and we cannot do " "app autodiscovery unless Django version is >=1.7") autodiscover_modules('lookups', register_to=site) -if django.VERSION[:2] <= (1, 6): +if VERSION[:2] <= (1, 6): # Django <= 1.6, use settings.AJAX_LOOKUP_CHANNELS only - default_app_config = 'ajax_select.apps.SimpleAjaxSelectConfig' + try: + site.register(settings.AJAX_LOOKUP_CHANNELS) + except AttributeError: + pass # Allow AJAX_LOOKUP_CHANNELS to be empty and fail at the point of get_lookup() else: default_app_config = 'ajax_select.apps.AjaxSelectConfig' diff --git a/ajax_select/decorators.py b/ajax_select/decorators.py index bbd7556405..5275ce11fe 100644 --- a/ajax_select/decorators.py +++ b/ajax_select/decorators.py @@ -22,7 +22,7 @@ def _ajax_select_wrapper(lookup_class): lookup_module_location = lookup_class.__module__ - site.register({label: (lookup_module_location, lookup_class.__name__) }) + site.register({label: (lookup_module_location, lookup_class.__name__)}) return lookup_class - return _ajax_select_wrapper \ No newline at end of file + return _ajax_select_wrapper diff --git a/ajax_select/sites.py b/ajax_select/sites.py index cafb790efe..d4987a41fd 100644 --- a/ajax_select/sites.py +++ b/ajax_select/sites.py @@ -39,4 +39,4 @@ def is_registered(self, label): return label in self._registry -site = AjaxSelectSite() \ No newline at end of file +site = AjaxSelectSite() diff --git a/example/example/lookups.py b/example/example/lookups.py index ec1edaedb9..1a9eaf66db 100644 --- a/example/example/lookups.py +++ b/example/example/lookups.py @@ -67,6 +67,7 @@ def format_match(self, obj): def format_item_display(self, obj): return "%s
by %s
" % (escape(obj.title), escape(obj.group.name)) + # Here using decorator syntax rather than settings.AJAX_LOOKUP_CHANNELS @ajax_select.register('cliche') class ClicheLookup(LookupChannel): diff --git a/tests/test_models.py b/tests/test_models.py index 6f01b80d2c..aa7638264e 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -21,6 +21,7 @@ class Person(models.Model): class Meta: app_label = 'testapp' + class Author(models.Model): name = models.CharField() diff --git a/tests/tests.py b/tests/tests.py index 2e76758bc4..33f7d2e66e 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -8,6 +8,7 @@ from django.test import TestCase + @ajax_select.register('testperson') class PersonLookup(LookupChannel): model = Person @@ -48,6 +49,7 @@ 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)) + class TestPersonLookup(TestCase): def setUp(self): pass @@ -56,4 +58,4 @@ def test_person_lookup_is_registered(self): self.assertIsNotNone(ajax_select.site._registry.get('testperson')) def test_unregistered_person_lookup_is_not_registered(self): - self.assertIsNone(ajax_select.site._registry.get('testunregisteredperson')) \ No newline at end of file + self.assertIsNone(ajax_select.site._registry.get('testunregisteredperson')) From 49c11c944c294d6407970bd25714f8d053ffa5c0 Mon Sep 17 00:00:00 2001 From: Tony Morrow Date: Fri, 26 Jun 2015 19:05:15 -0500 Subject: [PATCH 31/85] flake style fix --- ajax_select/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index bd5feeb9de..e9f7946110 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -18,6 +18,7 @@ from .sites import site from .decorators import register # noqa + class LookupChannel(object): """Subclass this, setting model and overiding the methods below to taste""" From 374157f47a3e5286794e7234d795ed637f8a68a2 Mon Sep 17 00:00:00 2001 From: Tony Morrow Date: Sun, 5 Jul 2015 00:21:30 -0500 Subject: [PATCH 32/85] Updating README with autodiscovery documentation --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 51267e62d1..176c11353f 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,33 @@ example/lookups.py: return Song.objects.filter(title__icontains=q).order_by('title') +LOOKUP CHANNEL AUTODISCOVERY IN DJANGO 1.7+ +=================================== +# If using Django 1.7+, you can also register custom lookup channels +# with a decorator syntax and have them auto-discovered at initialization time. +# Auto-discovery will look for custom lookup channels defined +# in a "lookups.py" module in your app(s). This can be useful if you +# have a large number of apps with ajax lookups. Auto-discovery can be used +# alongside AJAX_LOOKUP_CHANNELS in settings.py, as long as you do not +# use the same lookup channel label more than once. + +In your example/lookups.py: + +... +from ajax_select import register, LookupChannel + +@register('lookup_label') +def ExampleLookupChannel(LookupChannel): + model = ExampleModel + ... + +-> equivalent to { 'lookup_label' : ( 'example.lookups', 'ExampleLookupChannel') } in settings.py + +# ! Please note that auto-discovery is not enabled in Django <=1.6, and +# attempting to use the register decorator will result in an exception. +# If using Django <=1.6, continue to use settings.AJAX_LOOKUP_CHANNELS to +# register your custom lookups + NOT SO QUICK INSTALLATION ========================= From 29fe56453b61ed0faa0eb5db690ac5cfe0dec3d8 Mon Sep 17 00:00:00 2001 From: Tony Morrow Date: Sun, 5 Jul 2015 00:26:30 -0500 Subject: [PATCH 33/85] small edit to README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 176c11353f..225a40b9e8 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,8 @@ LOOKUP CHANNEL AUTODISCOVERY IN DJANGO 1.7+ # with a decorator syntax and have them auto-discovered at initialization time. # Auto-discovery will look for custom lookup channels defined # in a "lookups.py" module in your app(s). This can be useful if you -# have a large number of apps with ajax lookups. Auto-discovery can be used -# alongside AJAX_LOOKUP_CHANNELS in settings.py, as long as you do not +# have a large number of apps with ajax lookups. Decorator-registered lookups +# can be used # alongside AJAX_LOOKUP_CHANNELS in settings.py, as long as you do not # use the same lookup channel label more than once. In your example/lookups.py: From 926124ef581a4fb02630b32ba17359921ea756e5 Mon Sep 17 00:00:00 2001 From: Yauheni Zablotski Date: Thu, 30 Jul 2015 13:40:31 +0200 Subject: [PATCH 34/85] removed unnecessary backquotes --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e3d1a3b0e0..5cf64bd8ed 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Quick Installation Get it - `pip install django-ajax-selects` + pip install django-ajax-selects or download or checkout the distribution From ee58373c7830048aa86e5aa2e5a9c0fd6a09be6a Mon Sep 17 00:00:00 2001 From: Tony Morrow Date: Thu, 30 Jul 2015 22:34:07 -0500 Subject: [PATCH 35/85] Fixing typos in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 225a40b9e8..f2c950575b 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ LOOKUP CHANNEL AUTODISCOVERY IN DJANGO 1.7+ # Auto-discovery will look for custom lookup channels defined # in a "lookups.py" module in your app(s). This can be useful if you # have a large number of apps with ajax lookups. Decorator-registered lookups -# can be used # alongside AJAX_LOOKUP_CHANNELS in settings.py, as long as you do not +# can be used alongside AJAX_LOOKUP_CHANNELS in settings.py, as long as you do not # use the same lookup channel label more than once. In your example/lookups.py: @@ -124,7 +124,7 @@ In your example/lookups.py: from ajax_select import register, LookupChannel @register('lookup_label') -def ExampleLookupChannel(LookupChannel): +class ExampleLookupChannel(LookupChannel): model = ExampleModel ... From 79df66ced9f3a57e514522f6b8329726d64c9a07 Mon Sep 17 00:00:00 2001 From: Jake Merdich Date: Fri, 31 Jul 2015 01:03:37 -0700 Subject: [PATCH 36/85] Handle reset button Ensure reset buttons/event on containing forms are handled, giving a clean field or initial data. Also does this on page load (which, AFAIK, matches the existing behavior). --- .../static/ajax_select/js/ajax_select.js | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/ajax_select/static/ajax_select/js/ajax_select.js b/ajax_select/static/ajax_select/js/ajax_select.js index 9661949195..8d35a36db7 100644 --- a/ajax_select/static/ajax_select/js/ajax_select.js +++ b/ajax_select/static/ajax_select/js/ajax_select.js @@ -49,6 +49,18 @@ addKiller(options.initial[0], options.initial[1]); } + function reset(){ + if (options.initial) { + addKiller(options.initial[0], options.initial[1]); + $this.val(options.initial[1]); + } else { + kill(); + } + }; + + reset(); + $this.closest('form').on('reset', reset); + $this.bind('didAddPopup', function (event, pk, repr) { receiveResult(null, {item: {pk: pk, repr: repr}}); }); @@ -95,11 +107,20 @@ options.select = receiveResult; $text.autocomplete(options); - if (options.initial) { - $.each(options.initial, function (i, its) { - addKiller(its[0], its[1]); - }); - } + function reset(){ + $deck.empty(); + var query = "|"; + if (options.initial) { + $.each(options.initial, function (i, its) { + addKiller(its[0], its[1]); + query += its[1] + "|"; + }); + } + $this.val(query); + }; + + reset(); + $this.closest('form').on('reset', reset); $this.bind('didAddPopup', function (event, pk, repr) { receiveResult(null, {item: {pk: pk, repr: repr }}); From 4aecac903459abbe3c20268335671dff4604c231 Mon Sep 17 00:00:00 2001 From: Jake Merdich Date: Fri, 31 Jul 2015 01:17:49 -0700 Subject: [PATCH 37/85] Missed some of the init code, replaced by reset --- ajax_select/static/ajax_select/js/ajax_select.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ajax_select/static/ajax_select/js/ajax_select.js b/ajax_select/static/ajax_select/js/ajax_select.js index 8d35a36db7..003a511098 100644 --- a/ajax_select/static/ajax_select/js/ajax_select.js +++ b/ajax_select/static/ajax_select/js/ajax_select.js @@ -45,10 +45,6 @@ options.select = receiveResult; $text.autocomplete(options); - if (options.initial) { - addKiller(options.initial[0], options.initial[1]); - } - function reset(){ if (options.initial) { addKiller(options.initial[0], options.initial[1]); From 86ca0ccffee8aaadb284098d8535471b72f9cc4e Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Sun, 25 Oct 2015 19:07:56 +0100 Subject: [PATCH 38/85] remove empty call to test ajax_select there are no tests there --- tox.ini | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 412886ee85..b18f88c557 100644 --- a/tox.ini +++ b/tox.ini @@ -6,8 +6,7 @@ envlist = flake8, django15, django16, django17, django18 setenv = DJANGO_SETTINGS_MODULE=test_settings PYTHONPATH = {toxinidir}:{toxinidir}/ajax_select:{toxinidir}/tests -commands = django-admin.py test ajax_select - django-admin.py test tests +commands = django-admin.py test tests [testenv:flake8] From 913d20a9bf1e328e2b771e26a58d748da4f87363 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Sun, 25 Oct 2015 19:09:44 +0100 Subject: [PATCH 39/85] add testing for django 1.9 (beta) fix: lazy import ContentType models so that no models are imported during __init__.py --- .travis.yml | 1 + ajax_select/__init__.py | 2 +- tox.ini | 6 +++++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index fe19842ce7..d1478781ed 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,7 @@ env: - TOX_ENV=django16 - TOX_ENV=django17 - TOX_ENV=django18 + - TOX_ENV=django19 install: - pip install -r requirements-test.txt script: diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index e9f7946110..5e15d8ec24 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -8,7 +8,6 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.db.models.fields.related import ForeignKey, ManyToManyField -from django.contrib.contenttypes.models import ContentType from django.forms.models import ModelForm from django.utils.text import capfirst from django.utils.encoding import force_text @@ -63,6 +62,7 @@ def can_add(self, user, argmodel): one of these models. This enables the green popup + Default is the standard django permission check """ + from django.contrib.contenttypes.models import ContentType ctype = ContentType.objects.get_for_model(argmodel) return user.has_perm("%s.add_%s" % (ctype.app_label, ctype.model)) diff --git a/tox.ini b/tox.ini index b18f88c557..fcfabc346d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = flake8, django15, django16, django17, django18 +envlist = flake8, django15, django16, django17, django18, django19 [testenv] @@ -31,3 +31,7 @@ deps = [testenv:django18] deps = django>=1.8, <1.9 + +[testenv:django19] +deps = + django==1.9b1 From d599075af6334d5c6b73577353ab8d6b1d57342b Mon Sep 17 00:00:00 2001 From: hwkns Date: Wed, 28 Oct 2015 01:56:09 -0400 Subject: [PATCH 40/85] got rid of terrible `_as_pk` function (see #117, #120, #135) --- ajax_select/fields.py | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/ajax_select/fields.py b/ajax_select/fields.py index 905a3d0a5a..4a97c18c3c 100644 --- a/ajax_select/fields.py +++ b/ajax_select/fields.py @@ -13,6 +13,7 @@ from django.template.defaultfilters import force_escape from django.utils.encoding import force_text from django.utils.safestring import mark_safe +from django.utils.six import text_type from django.utils.translation import ugettext as _ try: import json @@ -24,15 +25,6 @@ IS_PYTHON2 = sys.version_info[0] == 2 -def _as_pk(got): - # a unicode method that checks for integers - if (type(got) is unicode) and got.isnumeric(): - if IS_PYTHON2: - return long(got) - return int(got) - return got - - def _media(self): # unless AJAX_SELECT_BOOTSTRAP == False # then load jquery and jquery ui + default css @@ -108,7 +100,7 @@ def render(self, name, value, attrs=None): return mark_safe(out) def value_from_datadict(self, data, files, name): - return _as_pk(data.get(name, None)) + return data.get(name, None) def id_for_label(self, id_): return '%s_text' % id_ @@ -227,7 +219,7 @@ def render(self, name, value, attrs=None): def value_from_datadict(self, data, files, name): # eg. 'members': ['|229|4688|190|'] - return [_as_pk(val) for val in data.get(name, '').split('|') if val] + return [val for val in data.get(name, '').split('|') if val] def id_for_label(self, id_): return '%s_text' % id_ @@ -248,14 +240,14 @@ def __init__(self, channel, *args, **kwargs): if not (help_text is None): # '' will cause translation to fail # should be '' - if type(help_text) == str: + if isinstance(help_text, str): help_text = force_text(help_text) # django admin appends "Hold down "Control",..." to the help text # regardless of which widget is used. so even when you specify an explicit # help text it appends this other default text onto the end. # This monkey patches the help text to remove that if help_text != '': - if not self._is_string(help_text): + if not isinstance(help_text, text_type): # ideally this could check request.LANGUAGE_CODE translated = help_text.translate(settings.LANGUAGE_CODE) else: @@ -289,16 +281,10 @@ def __init__(self, channel, *args, **kwargs): super(AutoCompleteSelectMultipleField, self).__init__(*args, **kwargs) - @staticmethod - def _is_string(help_text): - if IS_PYTHON2: - return type(help_text) == unicode - return type(help_text) == str - 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 primary keys from widget value_from_datadict def check_can_add(self, user, model): _check_can_add(self, user, model) @@ -429,7 +415,7 @@ def plugin_options(channel, channel_name, widget_plugin_options, initial): if not po.get('source'): po['source'] = reverse('ajax_lookup', kwargs={'channel': channel_name}) - # allow html unless explictly false + # allow html unless explicitly false if po.get('html') is None: po['html'] = True From 5872858d073604551703f8d87b08cd3c992df635 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Wed, 28 Oct 2015 16:11:13 +0100 Subject: [PATCH 41/85] make tests/ into a normal app layout keep lookups and models in their own files --- tests/{tests.py => lookups.py} | 23 +++--------- tests/models.py | 31 ++++++++++++++- test_settings.py => tests/settings.py | 0 tests/test_lookups.py | 17 +++++++++ tests/test_models.py | 54 --------------------------- tox.ini | 2 +- 6 files changed, 53 insertions(+), 74 deletions(-) rename tests/{tests.py => lookups.py} (71%) rename test_settings.py => tests/settings.py (100%) create mode 100644 tests/test_lookups.py delete mode 100644 tests/test_models.py diff --git a/tests/tests.py b/tests/lookups.py similarity index 71% rename from tests/tests.py rename to tests/lookups.py index 33f7d2e66e..ef62de82df 100644 --- a/tests/tests.py +++ b/tests/lookups.py @@ -1,20 +1,17 @@ from django.db.models import Q from django.utils.html import escape - +from tests.models import Person import ajax_select from ajax_select import LookupChannel -from .test_models import Person - -from django.test import TestCase - @ajax_select.register('testperson') class PersonLookup(LookupChannel): + model = Person def get_query(self, q, request): - return Person.objects.filter(Q(name__icontains=q) | Q(email__istartswith=q)).order_by('name') + return self.model.objects.filter(Q(name__icontains=q) | Q(email__istartswith=q)).order_by('name') def get_result(self, obj): u""" result is the simple text that is the completion of what the person typed """ @@ -31,10 +28,11 @@ def format_item_display(self, obj): class UnregisteredPersonLookup(LookupChannel): + model = Person def get_query(self, q, request): - return Person.objects.filter(Q(name__icontains=q) | Q(email__istartswith=q)).order_by('name') + return self.model.objects.filter(Q(name__icontains=q) | Q(email__istartswith=q)).order_by('name') def get_result(self, obj): u""" result is the simple text that is the completion of what the person typed """ @@ -48,14 +46,3 @@ def format_match(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)) - - -class TestPersonLookup(TestCase): - def setUp(self): - pass - - def test_person_lookup_is_registered(self): - self.assertIsNotNone(ajax_select.site._registry.get('testperson')) - - def test_unregistered_person_lookup_is_not_registered(self): - self.assertIsNone(ajax_select.site._registry.get('testunregisteredperson')) diff --git a/tests/models.py b/tests/models.py index f8126cc7c0..ae3b8f49f1 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1 +1,30 @@ -# this file only needed in app for Django 1.5 support + +from django.db import models + + +class Person(models.Model): + + name = models.CharField(max_length=50) + + class Meta: + app_label = 'tests' + + +class Author(models.Model): + + name = models.CharField(max_length=50) + + class Meta: + app_label = 'tests' + + +class Book(models.Model): + + """ Book has no admin, its an inline in the Author admin""" + + author = models.ForeignKey(Author) + name = models.CharField(max_length=50) + mentions_persons = models.ManyToManyField(Person, help_text="MENTIONS PERSONS HELP TEXT") + + class Meta: + app_label = 'tests' diff --git a/test_settings.py b/tests/settings.py similarity index 100% rename from test_settings.py rename to tests/settings.py diff --git a/tests/test_lookups.py b/tests/test_lookups.py new file mode 100644 index 0000000000..8e1040c30e --- /dev/null +++ b/tests/test_lookups.py @@ -0,0 +1,17 @@ + +# conflicting models +# import sys; print(sys.path) +# from .models import Person + +from django.test import TestCase +import ajax_select +from .lookups import PersonLookup, UnregisteredPersonLookup # noqa + + +class TestPersonLookup(TestCase): + + def test_person_lookup_is_registered(self): + self.assertIsNotNone(ajax_select.site._registry.get('testperson')) + + def test_unregistered_person_lookup_is_not_registered(self): + self.assertIsNone(ajax_select.site._registry.get('testunregisteredperson')) diff --git a/tests/test_models.py b/tests/test_models.py deleted file mode 100644 index aa7638264e..0000000000 --- a/tests/test_models.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -""" -test_django-ajax-selects ------------- - -Tests for `django-ajax-selects` models module. -""" - -# import unittest -from django.db import models - - -# --------------------------- models ---------------------------------- # - -class Person(models.Model): - - name = models.CharField() - - class Meta: - app_label = 'testapp' - - -class Author(models.Model): - - name = models.CharField() - - class Meta: - app_label = 'testapp' - - -class Book(models.Model): - - """ Book has no admin, its an inline in the Author admin""" - - author = models.ForeignKey(Author) - name = models.CharField() - mentions_persons = models.ManyToManyField(Person, help_text="MENTIONS PERSONS HELP TEXT") - - class Meta: - app_label = 'testapp' -# --------------------------- tests ---------------------------------- # - -# class TestAjax_select(unittest.TestCase): - -# def setUp(self): -# pass - -# def test_something(self): -# pass - -# def tearDown(self): -# pass diff --git a/tox.ini b/tox.ini index fcfabc346d..8e5aa817bc 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ envlist = flake8, django15, django16, django17, django18, django19 [testenv] setenv = - DJANGO_SETTINGS_MODULE=test_settings + DJANGO_SETTINGS_MODULE=tests.settings PYTHONPATH = {toxinidir}:{toxinidir}/ajax_select:{toxinidir}/tests commands = django-admin.py test tests From 3b14d38808f227076c842ccf7f70f6dbe5b27868 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Wed, 28 Oct 2015 16:15:30 +0100 Subject: [PATCH 42/85] change(auto discovery): do not throw error if already registered this prevents importing any lookup file more than once eg. tests fail because its already auto discovered --- ajax_select/sites.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ajax_select/sites.py b/ajax_select/sites.py index d4987a41fd..1f67cc7287 100644 --- a/ajax_select/sites.py +++ b/ajax_select/sites.py @@ -13,12 +13,8 @@ def __init__(self): self._registry = {} def register(self, lookup_labels): - # channel data structure is { 'channel' : ( module.lookup, lookupclass ) } # or { 'channel' : { 'model': 'xxxxx', 'search_field': 'xxxx' }} - for channel_name in lookup_labels.keys(): - if self.is_registered(channel_name): - raise AlreadyRegistered('The channel "%s" is already registered' % channel_name) self._registry.update(lookup_labels) def unregister(self, lookup_labels): From 17ba7bd8eae2f76a3edeefd7740e0804beffe3b1 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Wed, 28 Oct 2015 17:58:36 +0100 Subject: [PATCH 43/85] Get tox to run tests across all supported django and python versions --- tox.ini | 47 ++++++++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/tox.ini b/tox.ini index 8e5aa817bc..55c38e9575 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,9 @@ [tox] -envlist = flake8, django15, django16, django17, django18, django19 +envlist = + {py27,py34}-flake8, + {py27,py32,py33}-dj{15,16}, + {py27,py34,py35}-dj{17,18,19} +skip_missing_interpreters = true [testenv] @@ -7,31 +11,36 @@ setenv = DJANGO_SETTINGS_MODULE=tests.settings PYTHONPATH = {toxinidir}:{toxinidir}/ajax_select:{toxinidir}/tests commands = django-admin.py test tests - - -[testenv:flake8] -basepython=python deps = - flake8 -commands = flake8 . - - -[testenv:django15] + dj14: Django>=1.4,<1.5 + dj15: Django>=1.5,<1.6 + dj16: Django>=1.6,<1.7 + dj17: Django>=1.7,<1.8 + dj18: Django>=1.8,<1.9 + dj19: Django==1.9b1 + ; djmaster: https://github.com/django/django/zipball/master + +[testenv:py27-flake8] deps = - django>=1.5, <1.6 + flake8 +commands = flake8 ajax_select tests -[testenv:django16] +[testenv:py32-flake8] deps = - django>=1.6, <1.7 + flake8 +commands = flake8 ajax_select tests -[testenv:django17] +[testenv:py33-flake8] deps = - django>=1.7, <1.8 + flake8 +commands = flake8 ajax_select tests -[testenv:django18] +[testenv:py34-flake8] deps = - django>=1.8, <1.9 + flake8 +commands = flake8 ajax_select tests -[testenv:django19] +[testenv:py35-flake8] deps = - django==1.9b1 + flake8 +commands = flake8 ajax_select tests From 348817cc2b874b943a1f51efbb0db981291566cf Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Wed, 28 Oct 2015 17:59:12 +0100 Subject: [PATCH 44/85] fix #136 : add tests to Manifest.in --- MANIFEST.in | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index f04bd56aaa..73fb66447f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ +recursive-include tests *.py recursive-include example *.py *.sh *.txt prune example/AJAXSELECTS -prune example/ajax_select \ No newline at end of file +prune example/ajax_select From fc308964f9c19ccaf02396b2c054fd6671f690d2 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Wed, 28 Oct 2015 17:59:55 +0100 Subject: [PATCH 45/85] remove old runtests.py and switch `make test` to invoke tox instead --- Makefile | 3 --- runtests.py | 67 ----------------------------------------------------- 2 files changed, 70 deletions(-) delete mode 100644 runtests.py diff --git a/Makefile b/Makefile index 06a06f8350..f9abb449cb 100644 --- a/Makefile +++ b/Makefile @@ -28,9 +28,6 @@ lint: flake8 . test: - python runtests.py - -test-all: tox coverage: diff --git a/runtests.py b/runtests.py deleted file mode 100644 index 1ee8486e49..0000000000 --- a/runtests.py +++ /dev/null @@ -1,67 +0,0 @@ -import sys - -try: - from django.conf import settings - - settings.configure( - DEBUG=True, - USE_TZ=True, - DATABASES={ - "default": { - "ENGINE": "django.db.backends.sqlite3", - } - }, - ROOT_URLCONF="ajax_select.urls", - INSTALLED_APPS=[ - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sites", - 'django.contrib.messages', - 'django.contrib.sessions', - 'django.contrib.admin', - 'django.contrib.staticfiles', - "ajax_select", - ], - SITE_ID=1, - MIDDLEWARE_CLASSES=( - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware' - ) - ) - - try: - import django - setup = django.setup - except AttributeError: - pass - else: - setup() - - try: - from django.test.runner import DiscoverRunner as TestRunner - except ImportError: - # < 1.8 - from django.test.simple import DjangoTestSuiteRunner as TestRunner - -except ImportError: - import traceback - traceback.print_exc() - raise ImportError( - "Make sure that you have installed test requirements: " - "pip install -r requirements-test.txt") - - -def run_tests(*test_args): - if not test_args: - test_args = ['ajax_select'] - - runner = TestRunner() - failures = runner.run_tests(test_args, verbosity=1) - if failures: - pass - # sys.exit(failures) - - -if __name__ == '__main__': - run_tests(*sys.argv[1:]) From 1f3dfaec1d3ab2e46c9f37d229837330547adcc7 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Wed, 28 Oct 2015 18:00:20 +0100 Subject: [PATCH 46/85] update Readme with instructions on how to run tests --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index e332188bd5..e689e998e7 100644 --- a/README.md +++ b/README.md @@ -570,6 +570,29 @@ 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. +Testing +------------ + +For any pull requests you should run the unit tests first. Travis CI will also run all tests across all supported versions against your pull request and github will show you the failures. + +Its much faster to run them yourself locally. + + pip install -r requirements-test.txt + +This will run tox: + + make test + + # or just + tox + +With all supported combinations of Django and Python. + +You will need to have different Python interpreters installed which you can do with: + +https://github.com/yyuu/pyenv + + Contributors ------------ From b760402bce4b044a8cbe0a455b3dae7877140e04 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Wed, 28 Oct 2015 18:00:41 +0100 Subject: [PATCH 47/85] fix 3.x unicode errors --- tests/lookups.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/lookups.py b/tests/lookups.py index ef62de82df..cfbb92d2e8 100644 --- a/tests/lookups.py +++ b/tests/lookups.py @@ -14,17 +14,17 @@ def get_query(self, q, request): return self.model.objects.filter(Q(name__icontains=q) | Q(email__istartswith=q)).order_by('name') def get_result(self, obj): - u""" result is the simple text that is the completion of what the person typed """ + """ result is the simple text that is the completion of what the person typed """ return obj.name def format_match(self, obj): """ (HTML) formatted item for display in the dropdown """ - return u"%s
%s
" % (escape(obj.name), escape(obj.email)) + return "%s
%s
" % (escape(obj.name), escape(obj.email)) # return self.format_item_display(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 "%s
%s
" % (escape(obj.name), escape(obj.email)) class UnregisteredPersonLookup(LookupChannel): @@ -35,14 +35,14 @@ def get_query(self, q, request): return self.model.objects.filter(Q(name__icontains=q) | Q(email__istartswith=q)).order_by('name') def get_result(self, obj): - u""" result is the simple text that is the completion of what the person typed """ + """ result is the simple text that is the completion of what the person typed """ return obj.name def format_match(self, obj): """ (HTML) formatted item for display in the dropdown """ - return u"%s
%s
" % (escape(obj.name), escape(obj.email)) + return "%s
%s
" % (escape(obj.name), escape(obj.email)) # return self.format_item_display(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 "%s
%s
" % (escape(obj.name), escape(obj.email)) From 7e263da2b1442d405f1f9f078ac54e1f7821e23f Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Wed, 28 Oct 2015 18:29:57 +0100 Subject: [PATCH 48/85] remove coverage and coveralls from Makefile --- Makefile | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index f9abb449cb..0c19385584 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,12 @@ .PHONY: clean-pyc clean-build -# docs help: - @echo "clean-build - remove build artifacts" - @echo "clean-pyc - remove Python file artifacts" - @echo "lint - check style with flake8" @echo "test - run tests quickly with the currently installed Django" - @echo "testall - run tests on every Django version with tox" - @echo "coverage - check code coverage quickly with the default Python" - @echo "docs - generate Sphinx HTML documentation, including API docs" + @echo "clean - remove build artifacts" + @echo "lint - check style with flake8" @echo "release - package and upload a release" @echo "sdist - package" + # @echo "docs - generate Sphinx HTML documentation, including API docs" clean: clean-build clean-pyc @@ -30,12 +26,6 @@ lint: test: tox -coverage: - coverage run --source ajax_select runtests.py - coverage report -m - coverage html - open htmlcov/index.html - # docs: # rm -f docs/django-ajax-selects.rst # rm -f docs/modules.rst From 9b2d3032aab967f491cb9005f90c1e71a3ceff41 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Wed, 28 Oct 2015 18:30:13 +0100 Subject: [PATCH 49/85] update travis.yml for new testing matrix --- .travis.yml | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index d1478781ed..f2d23b3c91 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,20 @@ language: python -python: - - "2.7" +sudo: false env: - - TOX_ENV=flake8 - - TOX_ENV=django15 - - TOX_ENV=django16 - - TOX_ENV=django17 - - TOX_ENV=django18 - - TOX_ENV=django19 + - TOX_ENV=py27-flake8 + - TOX_ENV=py34-flake8 + - TOX_ENV=py27-dj15 + - TOX_ENV=py27-dj16 + - TOX_ENV=py32-dj15 + - TOX_ENV=py32-dj16 + - TOX_ENV=py33-dj15 + - TOX_ENV=py33-dj16 + - TOX_ENV=py27-dj17 + - TOX_ENV=py27-dj18 + - TOX_ENV=py27-dj19 + - TOX_ENV=py34-dj17 + - TOX_ENV=py34-dj18 + - TOX_ENV=py34-dj19 install: - pip install -r requirements-test.txt script: From fccac4c29c79d2c0ed96d6a2b8984607631f38c4 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Thu, 29 Oct 2015 11:03:38 +0100 Subject: [PATCH 50/85] move make_ajax_form and make_ajax_field to helpers.py --- ajax_select/__init__.py | 89 +-------------------------------------- ajax_select/helpers.py | 93 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 88 deletions(-) create mode 100644 ajax_select/helpers.py diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index 5e15d8ec24..ff01c93bd6 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -13,6 +13,7 @@ from django.utils.encoding import force_text from django.utils.html import escape from django.utils.translation import ugettext_lazy as _ +from ajax_select.helpers import make_ajax_form, make_ajax_field # noqa from .sites import site from .decorators import register # noqa @@ -76,94 +77,6 @@ def check_auth(self, request): raise PermissionDenied -def make_ajax_form(model, fieldlist, superclass=ModelForm, show_help_text=False, **kwargs): - """ Creates a ModelForm subclass with autocomplete fields - - usage: - class YourModelAdmin(Admin): - ... - form = make_ajax_form(YourModel,{'contacts':'contact','author':'contact'}) - - where - 'contacts' is a ManyToManyField specifying to use the lookup channel 'contact' - and - 'author' is a ForeignKeyField specifying here to also use the lookup channel 'contact' - """ - # will support previous arg name for several versions before deprecating - if 'show_m2m_help' in kwargs: - show_help_text = kwargs.pop('show_m2m_help') - - class TheForm(superclass): - - class Meta: - exclude = [] - - setattr(Meta, 'model', model) - if hasattr(superclass, 'Meta'): - if hasattr(superclass.Meta, 'fields'): - setattr(Meta, 'fields', superclass.Meta.fields) - if hasattr(superclass.Meta, 'exclude'): - setattr(Meta, 'exclude', superclass.Meta.exclude) - if hasattr(superclass.Meta, 'widgets'): - setattr(Meta, 'widgets', superclass.Meta.widgets) - - for model_fieldname, channel in fieldlist.items(): - f = make_ajax_field(model, model_fieldname, channel, show_help_text) - - TheForm.declared_fields[model_fieldname] = f - TheForm.base_fields[model_fieldname] = f - - return TheForm - - -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: - help_text - default is the model db field's help_text. - None will disable all help text - label - default is the model db field's verbose name - required - default is the model db field's (not) blank - - show_help_text - - Django will show help text below the widget, but not for ManyToMany inside of admin inlines - This setting will show the help text inside the widget itself. - """ - # will support previous arg name for several versions before deprecating - if 'show_m2m_help' in kwargs: - show_help_text = kwargs.pop('show_m2m_help') - - from ajax_select.fields import AutoCompleteField, \ - AutoCompleteSelectMultipleField, \ - AutoCompleteSelectField - - field = model._meta.get_field(model_fieldname) - if 'label' not in kwargs: - kwargs['label'] = _(capfirst(force_text(field.verbose_name))) - - if ('help_text' not in kwargs) and field.help_text: - kwargs['help_text'] = field.help_text - if 'required' not in kwargs: - kwargs['required'] = not field.blank - - kwargs['show_help_text'] = show_help_text - if isinstance(field, ManyToManyField): - f = AutoCompleteSelectMultipleField( - channel, - **kwargs - ) - elif isinstance(field, ForeignKey): - f = AutoCompleteSelectField( - channel, - **kwargs - ) - else: - f = AutoCompleteField( - channel, - **kwargs - ) - return f - # ----------------------- private --------------------------------------------- # diff --git a/ajax_select/helpers.py b/ajax_select/helpers.py new file mode 100644 index 0000000000..ac7a79f5bb --- /dev/null +++ b/ajax_select/helpers.py @@ -0,0 +1,93 @@ +from django.db.models.fields.related import ForeignKey, ManyToManyField +from django.forms.models import ModelForm +from django.utils.text import capfirst +from django.utils.encoding import force_text +from django.utils.translation import ugettext_lazy as _ + + +def make_ajax_form(model, fieldlist, superclass=ModelForm, show_help_text=False, **kwargs): + """ Creates a ModelForm subclass with autocomplete fields + + usage: + class YourModelAdmin(Admin): + ... + form = make_ajax_form(YourModel,{'contacts':'contact','author':'contact'}) + + where + 'contacts' is a ManyToManyField specifying to use the lookup channel 'contact' + and + 'author' is a ForeignKeyField specifying here to also use the lookup channel 'contact' + """ + # will support previous arg name for several versions before deprecating + # TODO: time to go + if 'show_m2m_help' in kwargs: + show_help_text = kwargs.pop('show_m2m_help') + + class TheForm(superclass): + + class Meta: + exclude = [] + + setattr(Meta, 'model', model) + if hasattr(superclass, 'Meta'): + if hasattr(superclass.Meta, 'fields'): + setattr(Meta, 'fields', superclass.Meta.fields) + if hasattr(superclass.Meta, 'exclude'): + setattr(Meta, 'exclude', superclass.Meta.exclude) + if hasattr(superclass.Meta, 'widgets'): + setattr(Meta, 'widgets', superclass.Meta.widgets) + + for model_fieldname, channel in fieldlist.items(): + f = make_ajax_field(model, model_fieldname, channel, show_help_text) + + TheForm.declared_fields[model_fieldname] = f + TheForm.base_fields[model_fieldname] = f + + return TheForm + + +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: + help_text - default is the model db field's help_text. + None will disable all help text + label - default is the model db field's verbose name + required - default is the model db field's (not) blank + + show_help_text - + Django will show help text below the widget, but not for ManyToMany inside of admin inlines + This setting will show the help text inside the widget itself. + """ + # will support previous arg name for several versions before deprecating + # TODO remove this now + if 'show_m2m_help' in kwargs: + show_help_text = kwargs.pop('show_m2m_help') + + from ajax_select.fields import AutoCompleteField, \ + AutoCompleteSelectMultipleField, \ + AutoCompleteSelectField + + field = model._meta.get_field(model_fieldname) + if 'label' not in kwargs: + kwargs['label'] = _(capfirst(force_text(field.verbose_name))) + + if ('help_text' not in kwargs) and field.help_text: + kwargs['help_text'] = field.help_text + if 'required' not in kwargs: + kwargs['required'] = not field.blank + + kwargs['show_help_text'] = show_help_text + if isinstance(field, ManyToManyField): + f = AutoCompleteSelectMultipleField( + channel, + **kwargs) + elif isinstance(field, ForeignKey): + f = AutoCompleteSelectField( + channel, + **kwargs) + else: + f = AutoCompleteField( + channel, + **kwargs) + return f From 5167b8cd209a1afc1cdfdcd63e0a12a946d36dbe Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Thu, 29 Oct 2015 11:05:09 +0100 Subject: [PATCH 51/85] move LookupChannel to lookup_channel.py --- ajax_select/__init__.py | 60 +--------------------------------- ajax_select/lookup_channel.py | 61 +++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 59 deletions(-) create mode 100644 ajax_select/lookup_channel.py diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index ff01c93bd6..676a3786b1 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -14,70 +14,12 @@ from django.utils.html import escape from django.utils.translation import ugettext_lazy as _ from ajax_select.helpers import make_ajax_form, make_ajax_field # noqa +from ajax_select.lookup_channel import LookupChannel # noqa from .sites import site from .decorators import register # noqa -class LookupChannel(object): - - """Subclass this, setting model and overiding the methods below to taste""" - - model = None - plugin_options = {} - min_length = 1 - - 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} - return self.model.objects.filter(**kwargs).order_by(self.search_field) - - def get_result(self, obj): - """ The text result of autocompleting the entered query """ - return escape(force_text(obj)) - - def format_match(self, obj): - """ (HTML) formatted item for displaying item in the dropdown """ - return escape(force_text(obj)) - - def format_item_display(self, obj): - """ (HTML) formatted item for displaying item in the selected deck area """ - return escape(force_text(obj)) - - 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 - # which is not guaranteed to be the same order they were in when you last edited - # see OrdredManyToMany.md - pk_type = self.model._meta.pk.to_python - ids = [pk_type(id) for id in ids] - things = self.model.objects.in_bulk(ids) - return [things[aid] for aid in ids if aid in things] - - 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 - """ - from django.contrib.contenttypes.models import ContentType - ctype = ContentType.objects.get_for_model(argmodel) - return user.has_perm("%s.add_%s" % (ctype.app_label, ctype.model)) - - 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?") - instead of raising PermissionDenied (401 response) - """ - if not request.user.is_staff: - raise PermissionDenied - - - # ----------------------- private --------------------------------------------- # def get_lookup(channel): diff --git a/ajax_select/lookup_channel.py b/ajax_select/lookup_channel.py new file mode 100644 index 0000000000..7c886fb0bc --- /dev/null +++ b/ajax_select/lookup_channel.py @@ -0,0 +1,61 @@ +from django.core.exceptions import PermissionDenied +from django.utils.encoding import force_text +from django.utils.html import escape + + +class LookupChannel(object): + + """Subclass this, setting model and overiding the methods below to taste""" + + model = None + plugin_options = {} + min_length = 1 + + 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} + return self.model.objects.filter(**kwargs).order_by(self.search_field) + + def get_result(self, obj): + """ The text result of autocompleting the entered query """ + return escape(force_text(obj)) + + def format_match(self, obj): + """ (HTML) formatted item for displaying item in the dropdown """ + return escape(force_text(obj)) + + def format_item_display(self, obj): + """ (HTML) formatted item for displaying item in the selected deck area """ + return escape(force_text(obj)) + + 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 + # which is not guaranteed to be the same order they were in when you last edited + # see OrdredManyToMany.md + pk_type = self.model._meta.pk.to_python + ids = [pk_type(id) for id in ids] + things = self.model.objects.in_bulk(ids) + return [things[aid] for aid in ids if aid in things] + + 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 + """ + from django.contrib.contenttypes.models import ContentType + ctype = ContentType.objects.get_for_model(argmodel) + return user.has_perm("%s.add_%s" % (ctype.app_label, ctype.model)) + + 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?") + instead of raising PermissionDenied (401 response) + """ + if not request.user.is_staff: + raise PermissionDenied From df36f6303418cb8be3de082d5703c36d15193596 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Thu, 29 Oct 2015 11:10:37 +0100 Subject: [PATCH 52/85] rename sites.py -> registry.py move get_lookup into the LookupChannelRegistry move decorator into registery.py --- ajax_select/__init__.py | 54 ----------------- ajax_select/decorators.py | 28 --------- ajax_select/fields.py | 12 ++-- ajax_select/registry.py | 118 ++++++++++++++++++++++++++++++++++++++ ajax_select/sites.py | 38 ------------ ajax_select/views.py | 4 +- 6 files changed, 126 insertions(+), 128 deletions(-) delete mode 100644 ajax_select/decorators.py create mode 100644 ajax_select/registry.py delete mode 100644 ajax_select/sites.py diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index 676a3786b1..d10fd6d8a6 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -20,60 +20,6 @@ from .decorators import register # noqa -# ----------------------- private --------------------------------------------- # - -def get_lookup(channel): - """ find the lookup class for the named channel. this is used internally """ - try: - lookup_label = site._registry[channel] - except KeyError: - raise ImproperlyConfigured("settings.AJAX_LOOKUP_CHANNELS not configured correctly for %(channel)r " - "or autodiscovery could not locate %(channel)r" % {'channel': channel}) - - 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 - # '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]) - - # monkeypatch older lookup classes till 1.3 - if not hasattr(lookup_class, 'format_match'): - setattr(lookup_class, 'format_match', - getattr(lookup_class, 'format_item', - lambda self, obj: force_text(obj))) - if not hasattr(lookup_class, 'format_item_display'): - setattr(lookup_class, 'format_item_display', - getattr(lookup_class, 'format_item', - lambda self, obj: force_text(obj))) - if not hasattr(lookup_class, 'get_result'): - setattr(lookup_class, 'get_result', - getattr(lookup_class, 'format_result', - lambda self, obj: force_text(obj))) - - return lookup_class() - - -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 - """ - from django.db import models - app_label, model_name = app_model.split(".") - themodel = models.get_model(app_label, model_name) - - class MadeLookupChannel(LookupChannel): - - model = themodel - search_field = arg_search_field - - return MadeLookupChannel() - - def autodiscover(): try: from django.utils.module_loading import autodiscover_modules diff --git a/ajax_select/decorators.py b/ajax_select/decorators.py deleted file mode 100644 index 5275ce11fe..0000000000 --- a/ajax_select/decorators.py +++ /dev/null @@ -1,28 +0,0 @@ -def register(label): - """ - Registers the given model(s) classes and wrapped ModelAdmin class with - admin site: - @register(Author) - class AuthorAdmin(admin.ModelAdmin): - pass - A kwarg of `site` can be passed as the admin site, otherwise the default - admin site will be used. - """ - - from ajax_select import LookupChannel - from ajax_select.sites import site - - def _ajax_select_wrapper(lookup_class): - - if not label or len(label) == 0: - raise ValueError('Lookup Channel must have a label') - - if not issubclass(lookup_class, LookupChannel): - raise ValueError('Wrapped class must subclass LookupChannel.') - - lookup_module_location = lookup_class.__module__ - - site.register({label: (lookup_module_location, lookup_class.__name__)}) - - return lookup_class - return _ajax_select_wrapper diff --git a/ajax_select/fields.py b/ajax_select/fields.py index 4a97c18c3c..5f17d6ef3a 100644 --- a/ajax_select/fields.py +++ b/ajax_select/fields.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals import sys -from ajax_select import get_lookup +from ajax_select.registry import registry from django import forms from django.conf import settings from django.contrib.contenttypes.models import ContentType @@ -70,7 +70,7 @@ def render(self, name, value, attrs=None): current_repr = '' initial = None - lookup = get_lookup(self.channel) + lookup = registry.get(self.channel) if value: objs = lookup.get_objects([value]) try: @@ -128,7 +128,7 @@ def __init__(self, channel, *args, **kwargs): def clean(self, value): if value: - lookup = get_lookup(self.channel) + lookup = registry.get(self.channel) objs = lookup.get_objects([value]) if len(objs) != 1: # someone else might have deleted it while you were editing @@ -178,7 +178,7 @@ def render(self, name, value, attrs=None): final_attrs = self.build_attrs(attrs) self.html_id = final_attrs.pop('id', name) - lookup = get_lookup(self.channel) + lookup = registry.get(self.channel) # eg. value = [3002L, 1194L] if value: @@ -321,7 +321,7 @@ def render(self, name, value, attrs=None): final_attrs = self.build_attrs(attrs) self.html_id = final_attrs.pop('id', name) - lookup = get_lookup(self.channel) + lookup = registry.get(self.channel) if self.show_help_text: help_text = self.help_text else: @@ -377,7 +377,7 @@ def _check_can_add(self, user, model): else using django's default perm check. if it can add, then enable the widget to show the + link """ - lookup = get_lookup(self.channel) + lookup = registry.get(self.channel) if hasattr(lookup, 'can_add'): can_add = lookup.can_add(user, model) else: diff --git a/ajax_select/registry.py b/ajax_select/registry.py new file mode 100644 index 0000000000..893f7506f8 --- /dev/null +++ b/ajax_select/registry.py @@ -0,0 +1,118 @@ +from django.core.exceptions import ImproperlyConfigured + + +class LookupChannelRegistry(object): + + """ + Registry for lookup channels activated for your django project ("site"). + + This includes any installed apps that contain lookup.py modules (django 1.7+) + and any lookups that are explicitly declared in settings.AJAX_LOOKUP_CHANNELS + """ + _registry = {} + + def register(self, lookup_specs): + """ + lookup_specs is a dict with one or more LookupChannel specifications + {'channel': ('module.of.lookups', 'MyLookupClass')} + {'channel': {'model': 'MyModelToBeLookedUp', 'search_field': 'field_to_search'}} + """ + for label, spec in lookup_specs.items(): + if spec is None: # unset + if label in self._registry: + del self._registry[label] + else: + self._registry[label] = spec + + def get(self, channel): + """ + Find the LookupChannel for the named channel. + + @param channel {string} - name that the lookup channel was registered at + """ + from lookup_channel import LookupChannel + + try: + lookup_spec = self._registry[channel] + except KeyError: + raise ImproperlyConfigured( + "No ajax_select LookupChannel named %(channel)r is registered." % {'channel': channel}) + + if (type(lookup_spec) is type) and issubclass(lookup_spec, LookupChannel): + return lookup_spec() + elif isinstance(lookup_spec, dict): + # 'channel' : dict(model='app.model', search_field='title' ) + # generate a simple channel dynamically + return self.make_channel(lookup_spec['model'], lookup_spec['search_field']) + else: + # a tuple + # 'channel' : ('app.module','LookupClass') + # from app.module load LookupClass and instantiate + lookup_module = __import__(lookup_spec[0], {}, {}, ['']) + lookup_class = getattr(lookup_module, lookup_spec[1]) + + return lookup_class() + + def is_registered(self, channel): + return channel in self._registry + + def make_channel(self, app_model, arg_search_field): + """ + app_model: app_name.ModelName + arg_search_field: the field to search against and to display in search results + """ + from lookup_channel import LookupChannel + the_model = self.get_model(app_model) + + class MadeLookupChannel(LookupChannel): + + model = the_model + search_field = arg_search_field + + return MadeLookupChannel() + + def get_model(self, app_model): + """ + Get the model from 'app_label.ModelName' + """ + app_label, model_name = app_model.split(".") + try: + # django >= 1.7 + from django.apps import apps + except ImportError: + # django < 1.7 + from django.db import models + return models.get_model(app_label, model_name) + else: + return apps.get_model(app_label, model_name) + + +registry = LookupChannelRegistry() + + +def register(label): + """ + Decorator to register a LookupClass + + + ``` + from ajax_select import LookupChannel, register + + @register('agent') + class AgentLookup(LookupClass): + + def get_query(self) + def format_item(self) + ... etc + ``` + """ + + def _wrapper(lookup_class): + if not label: + raise ValueError('Lookup Channel must have a channel name') + + registry.register({label: lookup_class}) + + return lookup_class + + return _wrapper diff --git a/ajax_select/sites.py b/ajax_select/sites.py deleted file mode 100644 index 1f67cc7287..0000000000 --- a/ajax_select/sites.py +++ /dev/null @@ -1,38 +0,0 @@ - -class AlreadyRegistered(Exception): - pass - - -class NotRegistered(Exception): - pass - - -class AjaxSelectSite(object): - - def __init__(self): - self._registry = {} - - def register(self, lookup_labels): - # channel data structure is { 'channel' : ( module.lookup, lookupclass ) } - # or { 'channel' : { 'model': 'xxxxx', 'search_field': 'xxxx' }} - self._registry.update(lookup_labels) - - def unregister(self, lookup_labels): - """ - Unregisters the given model(s). - If a model isn't already registered, this will raise NotRegistered. - """ - # channel data structure is { label : ( module.lookup, lookupclass ) } - for channel_name in lookup_labels.keys(): - if not self.is_registered(channel_name): - raise NotRegistered('The channel "%s" is not registered' % channel_name) - del self._registry[channel_name] - - def is_registered(self, label): - """ - Check if a LookupChannel class is registered with this `AjaxSelectSite`. - """ - return label in self._registry - - -site = AjaxSelectSite() diff --git a/ajax_select/views.py b/ajax_select/views.py index bd2ed35684..f71841d997 100644 --- a/ajax_select/views.py +++ b/ajax_select/views.py @@ -1,5 +1,5 @@ -from ajax_select import get_lookup +from ajax_select import registry from django.contrib.admin import site from django.db import models from django.http import HttpResponse @@ -26,7 +26,7 @@ def ajax_lookup(request, channel): return HttpResponse('') # suspicious query = request.POST['term'] - lookup = get_lookup(channel) + lookup = registry.get(channel) if hasattr(lookup, 'check_auth'): lookup.check_auth(request) From 2c1cddb5e0f712964969170501b8a1c01c6ec852 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Thu, 29 Oct 2015 11:13:50 +0100 Subject: [PATCH 53/85] simplify AppConfig and autodiscovery --- ajax_select/__init__.py | 41 +++++++++++++---------------------------- ajax_select/apps.py | 27 +++++++++++++++------------ 2 files changed, 28 insertions(+), 40 deletions(-) diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index d10fd6d8a6..a2cdc4b92c 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -4,36 +4,21 @@ __contact__ = "crucialfelix@gmail.com" __homepage__ = "https://github.com/crucialfelix/django-ajax-selects/" -from django import VERSION -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured, PermissionDenied -from django.db.models.fields.related import ForeignKey, ManyToManyField -from django.forms.models import ModelForm -from django.utils.text import capfirst -from django.utils.encoding import force_text -from django.utils.html import escape -from django.utils.translation import ugettext_lazy as _ +from ajax_select.registry import registry, register # noqa from ajax_select.helpers import make_ajax_form, make_ajax_field # noqa from ajax_select.lookup_channel import LookupChannel # noqa -from .sites import site -from .decorators import register # noqa - -def autodiscover(): - try: - from django.utils.module_loading import autodiscover_modules - except ImportError: - raise ImproperlyConfigured("AJAX_LOOKUP_CHANNELS is not set in settings.py and we cannot do " - "app autodiscovery unless Django version is >=1.7") - autodiscover_modules('lookups', register_to=site) - - -if VERSION[:2] <= (1, 6): - # Django <= 1.6, use settings.AJAX_LOOKUP_CHANNELS only - try: - site.register(settings.AJAX_LOOKUP_CHANNELS) - except AttributeError: - pass # Allow AJAX_LOOKUP_CHANNELS to be empty and fail at the point of get_lookup() -else: +try: + # django 1.7+ will use the new AppConfig api + # It will load all your lookups.py modules + # and any specified in settings.AJAX_LOOKUP_CHANNELS + # It will do this after all apps are imported. + from django.apps import AppConfig # noqa default_app_config = 'ajax_select.apps.AjaxSelectConfig' +except ImportError: + # Previous django versions should load now + # using settings.AJAX_LOOKUP_CHANNELS + from django.conf import settings + if hasattr(settings, 'AJAX_LOOKUP_CHANNELS'): + registry.register(settings.AJAX_LOOKUP_CHANNELS) diff --git a/ajax_select/apps.py b/ajax_select/apps.py index 24419329ed..f3902e5618 100644 --- a/ajax_select/apps.py +++ b/ajax_select/apps.py @@ -1,22 +1,25 @@ from django.apps import AppConfig -from django.conf import settings -from django.utils.translation import ugettext_lazy as _ -from .sites import site +class AjaxSelectConfig(AppConfig): -class SimpleAjaxSelectConfig(AppConfig): + """ + Django 1.7+ enables initializing installed applications + and autodiscovering modules + + On startup, search for and import any modules called `lookups.py` in all installed apps. + Your LookupClass subclass may register itself. + """ name = 'ajax_select' - verbose_name = _('AjaxSelects') + verbose_name = 'Ajax Selects' def ready(self): - if hasattr(settings, 'AJAX_LOOKUP_CHANNELS'): - site.register(settings.AJAX_LOOKUP_CHANNELS) - + from django.conf import settings + from ajax_select.registry import registry + from django.utils.module_loading import autodiscover_modules -class AjaxSelectConfig(SimpleAjaxSelectConfig): + autodiscover_modules('lookups') - def ready(self): - super(AjaxSelectConfig, self).ready() - self.module.autodiscover() + if hasattr(settings, 'AJAX_LOOKUP_CHANNELS'): + registry.register(settings.AJAX_LOOKUP_CHANNELS) From 355005075951982821b7f6c0183242017023f322 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Thu, 29 Oct 2015 11:15:36 +0100 Subject: [PATCH 54/85] rework and add more tests for autodiscovery --- tests/lookups.py | 42 +++++++++++++++++------------------------- tests/other_lookups.py | 9 +++++++++ tests/settings.py | 11 +++++++++++ tests/test_fields.py | 16 ++-------------- tests/test_lookups.py | 37 +++++++++++++++++++++++++++---------- 5 files changed, 66 insertions(+), 49 deletions(-) create mode 100644 tests/other_lookups.py diff --git a/tests/lookups.py b/tests/lookups.py index cfbb92d2e8..2b4b7418d1 100644 --- a/tests/lookups.py +++ b/tests/lookups.py @@ -1,48 +1,40 @@ -from django.db.models import Q from django.utils.html import escape +from django.contrib.auth.models import User from tests.models import Person import ajax_select -from ajax_select import LookupChannel -@ajax_select.register('testperson') -class PersonLookup(LookupChannel): +@ajax_select.register('person') +class PersonLookup(ajax_select.LookupChannel): model = Person def get_query(self, q, request): - return self.model.objects.filter(Q(name__icontains=q) | Q(email__istartswith=q)).order_by('name') + return self.model.objects.filter(name__icontains=q) def get_result(self, obj): - """ result is the simple text that is the completion of what the person typed """ return obj.name def format_match(self, obj): - """ (HTML) formatted item for display in the dropdown """ return "%s
%s
" % (escape(obj.name), escape(obj.email)) - # return self.format_item_display(obj) def format_item_display(self, obj): - """ (HTML) formatted item for displaying item in the selected deck area """ return "%s
%s
" % (escape(obj.name), escape(obj.email)) -class UnregisteredPersonLookup(LookupChannel): +@ajax_select.register('user') +class UserLookup(ajax_select.LookupChannel): - model = Person - - def get_query(self, q, request): - return self.model.objects.filter(Q(name__icontains=q) | Q(email__istartswith=q)).order_by('name') + """ + Test if you can unset a lookup provided by a third-party application. + In this case it exposes User without any auth checking + and somebody could manually check the ajax URL and find out + if a user email exists. + So you might want to turn this channel off + by settings.AJAX_LOOKUP_CHANNELS['user'] = None + """ - def get_result(self, obj): - """ result is the simple text that is the completion of what the person typed """ - return obj.name + model = User - def format_match(self, obj): - """ (HTML) formatted item for display in the dropdown """ - return "%s
%s
" % (escape(obj.name), escape(obj.email)) - # return self.format_item_display(obj) - - def format_item_display(self, obj): - """ (HTML) formatted item for displaying item in the selected deck area """ - return "%s
%s
" % (escape(obj.name), escape(obj.email)) + def get_query(self, q, request): + return self.model.objects.filter(email=q) diff --git a/tests/other_lookups.py b/tests/other_lookups.py new file mode 100644 index 0000000000..5ccd740c50 --- /dev/null +++ b/tests/other_lookups.py @@ -0,0 +1,9 @@ +"""Testing if lookups that are not in a file named lookups.py can be loaded correctly.""" + +import ajax_select +from tests.models import Book + + +class BookLookup(ajax_select.LookupChannel): + + model = Book diff --git a/tests/settings.py b/tests/settings.py index 97cbc53cd0..3159e0774d 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -24,3 +24,14 @@ 'django.contrib.messages.middleware.MessageMiddleware' ) SECRET_KEY = 'inyd5fc5pymlsv@hwoc5+3_6*cm0erlxzv6i-wl0jm_kt-6rp9' + +AJAX_LOOKUP_CHANNELS = { + # tuple points to a module and class to load + 'book': ('tests.other_lookups', 'BookLookup'), + # dict specifies an automatically constructed LookupChannel + 'author': {'model': 'tests.Author', 'search_field': 'name'}, + # unset a channel that a third-party app specified + 'user': None, + 'was-never-a-channel': None + # LookupChannels in lookups.py are auto-loaded +} diff --git a/tests/test_fields.py b/tests/test_fields.py index 36496bac26..1c020e16d0 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,22 +1,10 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - - -import unittest - +from django.test import TestCase from ajax_select import fields -# from test_models import Book, Person, Author -class TestAjaxSelectAutoCompleteSelectWidget(unittest.TestCase): - - def setUp(self): - pass +class TestAjaxSelectAutoCompleteSelectWidget(TestCase): def test_render(self): channel = None widget = fields.AutoCompleteSelectWidget(channel) widget - - def tearDown(self): - pass diff --git a/tests/test_lookups.py b/tests/test_lookups.py index 8e1040c30e..14dcb97b3d 100644 --- a/tests/test_lookups.py +++ b/tests/test_lookups.py @@ -1,17 +1,34 @@ -# conflicting models -# import sys; print(sys.path) -# from .models import Person - from django.test import TestCase import ajax_select -from .lookups import PersonLookup, UnregisteredPersonLookup # noqa +try: + from django.apps import AppConfig # noqa +except ImportError: + can_autodiscover = False +else: + can_autodiscover = True + + +class TestAutoDiscovery(TestCase): + + def test_lookup_py_is_autoloaded(self): + """Django >= 1.7 autoloads tests/lookups.py""" + is_registered = ajax_select.registry.is_registered('person') + if can_autodiscover: + self.assertTrue(is_registered) + else: + self.assertFalse(is_registered) -class TestPersonLookup(TestCase): + def test_back_compatible_loads_by_settings(self): + """a module and class specified in settings""" + self.assertTrue(ajax_select.registry.is_registered('book')) - def test_person_lookup_is_registered(self): - self.assertIsNotNone(ajax_select.site._registry.get('testperson')) + def test_autoconstruct_from_spec(self): + """a dict in settings specifying model and lookup fields""" + self.assertTrue(ajax_select.registry.is_registered('author')) - def test_unregistered_person_lookup_is_not_registered(self): - self.assertIsNone(ajax_select.site._registry.get('testunregisteredperson')) + def test_unsetting_a_channel(self): + """settings can unset a channel that was specified in a lookups.py""" + self.assertFalse(ajax_select.registry.is_registered('user')) + self.assertFalse(ajax_select.registry.is_registered('was-never-a-channel')) From 6fabffd65d7ca6883a1abc2d0abffc884ccc5876 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Thu, 29 Oct 2015 11:38:43 +0100 Subject: [PATCH 55/85] slight simplification of Readme Will do a full rewrite shortly --- README.md | 52 +++++++++++++++++++++++----------------------------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index e689e998e7..1a7ec9519f 100644 --- a/README.md +++ b/README.md @@ -96,10 +96,11 @@ In your admin.py: form = make_ajax_form(Label, {'owner': 'person'}) admin.site.register(Label, LabelAdmin) -example/lookups.py: +`example/lookups.py`: - from ajax_select import LookupChannel + from ajax_select import register, LookupChannel + @register('songs') class SongLookup(LookupChannel): model = Song @@ -108,33 +109,6 @@ example/lookups.py: return Song.objects.filter(title__icontains=q).order_by('title') -LOOKUP CHANNEL AUTODISCOVERY IN DJANGO 1.7+ -=================================== -# If using Django 1.7+, you can also register custom lookup channels -# with a decorator syntax and have them auto-discovered at initialization time. -# Auto-discovery will look for custom lookup channels defined -# in a "lookups.py" module in your app(s). This can be useful if you -# have a large number of apps with ajax lookups. Decorator-registered lookups -# can be used alongside AJAX_LOOKUP_CHANNELS in settings.py, as long as you do not -# use the same lookup channel label more than once. - -In your example/lookups.py: - -... -from ajax_select import register, LookupChannel - -@register('lookup_label') -class ExampleLookupChannel(LookupChannel): - model = ExampleModel - ... - --> equivalent to { 'lookup_label' : ( 'example.lookups', 'ExampleLookupChannel') } in settings.py - -# ! Please note that auto-discovery is not enabled in Django <=1.6, and -# attempting to use the register decorator will result in an exception. -# If using Django <=1.6, continue to use settings.AJAX_LOOKUP_CHANNELS to -# register your custom lookups - NOT SO QUICK INSTALLATION ========================= @@ -247,6 +221,26 @@ lookups.py By convention this is where you would define custom lookup channels +If you are using Django >= 1.7 then all `lookups.py` in all of your apps will be automatically imported on startup. + +Use the @register decorator to register your LookupChannels + +`example/lookups.py`: + +```python +from ajax_select import register, LookupChannel + +@register('things') +class ThingsLookupChannel(LookupChannel): + + model = Things + + def get_query(self, q, request): + return self.model.objects.filter(title__icontains=q).order_by('title') + +``` + + Subclass `LookupChannel` and override any method you wish to customize. 1.1x Upgrade note: previous versions did not have a parent class. The methods format_result and format_item have been renamed to format_match and format_item_display respectively. From bdd3094fc3c3a91cf10be7b9f2b94dfcb2faa5ae Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Thu, 29 Oct 2015 11:40:33 +0100 Subject: [PATCH 56/85] remove import checks for python json: its available in 2.6 + --- ajax_select/fields.py | 5 +---- ajax_select/views.py | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/ajax_select/fields.py b/ajax_select/fields.py index 5f17d6ef3a..ed728410b0 100644 --- a/ajax_select/fields.py +++ b/ajax_select/fields.py @@ -15,10 +15,7 @@ from django.utils.safestring import mark_safe from django.utils.six import text_type from django.utils.translation import ugettext as _ -try: - import json -except ImportError: - from django.utils import simplejson as json +import json as_default_help = 'Enter text to search.' diff --git a/ajax_select/views.py b/ajax_select/views.py index f71841d997..614bc5a1c0 100644 --- a/ajax_select/views.py +++ b/ajax_select/views.py @@ -3,11 +3,8 @@ from django.contrib.admin import site from django.db import models from django.http import HttpResponse -try: - import json -except ImportError: - from django.utils import simplejson as json from django.utils.encoding import force_text +import json def ajax_lookup(request, channel): From 413e9e8ebcd480c0b33ac42fadc61aa4cfa9cc2d Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Thu, 29 Oct 2015 11:40:59 +0100 Subject: [PATCH 57/85] formatting, remove unused imports --- ajax_select/__init__.py | 2 +- ajax_select/admin.py | 3 ++- ajax_select/fields.py | 3 +-- ajax_select/models.py | 6 +++++- ajax_select/urls.py | 6 ++---- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index a2cdc4b92c..851a7af6b7 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -1,4 +1,4 @@ -"""JQuery-Ajax Autocomplete fields for Django Forms""" +"""JQuery-Ajax Autocomplete fields for Django Forms.""" __version__ = "1.3.6" __author__ = "crucialfelix" __contact__ = "crucialfelix@gmail.com" diff --git a/ajax_select/admin.py b/ajax_select/admin.py index ed4f075c18..31f0468577 100644 --- a/ajax_select/admin.py +++ b/ajax_select/admin.py @@ -1,5 +1,5 @@ -from ajax_select.fields import autoselect_fields_check_can_add from django.contrib import admin +from ajax_select.fields import autoselect_fields_check_can_add class AjaxSelectAdmin(admin.ModelAdmin): @@ -14,6 +14,7 @@ def get_form(self, request, obj=None, **kwargs): class AjaxSelectAdminInlineFormsetMixin(object): + def get_formset(self, request, obj=None, **kwargs): fs = super(AjaxSelectAdminInlineFormsetMixin, self).get_formset(request, obj, **kwargs) autoselect_fields_check_can_add(fs.form, self.model, request.user) diff --git a/ajax_select/fields.py b/ajax_select/fields.py index ed728410b0..41e33bbfbc 100644 --- a/ajax_select/fields.py +++ b/ajax_select/fields.py @@ -1,5 +1,4 @@ from __future__ import unicode_literals -import sys from ajax_select.registry import registry from django import forms from django.conf import settings @@ -8,6 +7,7 @@ try: from django.forms.utils import flatatt except ImportError: + # < django 1.7 from django.forms.util import flatatt from django.template.loader import render_to_string from django.template.defaultfilters import force_escape @@ -19,7 +19,6 @@ as_default_help = 'Enter text to search.' -IS_PYTHON2 = sys.version_info[0] == 2 def _media(self): diff --git a/ajax_select/models.py b/ajax_select/models.py index fd04752153..2e8ea8e709 100644 --- a/ajax_select/models.py +++ b/ajax_select/models.py @@ -1 +1,5 @@ -# blank file so django recognizes the app +""" +Blank file so that Django recognizes the app. + +This is only required for Django < 1.7 +""" diff --git a/ajax_select/urls.py b/ajax_select/urls.py index 445a1354f2..abec725b1d 100644 --- a/ajax_select/urls.py +++ b/ajax_select/urls.py @@ -7,10 +7,8 @@ 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') ) From f1f14b3a194f22128abbe82bdc55b10568f45ac4 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Fri, 30 Oct 2015 11:34:53 +0100 Subject: [PATCH 58/85] drop some support for django < 1.5 --- ajax_select/urls.py | 5 +---- ajax_select/views.py | 13 +++++-------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/ajax_select/urls.py b/ajax_select/urls.py index abec725b1d..f837dd5e23 100644 --- a/ajax_select/urls.py +++ b/ajax_select/urls.py @@ -1,7 +1,4 @@ -try: - from django.conf.urls import patterns, url -except: - from django.conf.urls.defaults import patterns, url +from django.conf.urls import patterns, url urlpatterns = patterns('', diff --git a/ajax_select/views.py b/ajax_select/views.py index 614bc5a1c0..b728a75414 100644 --- a/ajax_select/views.py +++ b/ajax_select/views.py @@ -63,14 +63,11 @@ def add_popup(request, app_label, model): response = admin.add_view(request, request.path) if request.method == 'POST': - 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 + # 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 if 'opener.dismissAddAnotherPopup' in out: return HttpResponse(out.replace('dismissAddAnotherPopup', 'didAddPopup')) From 4175b76f0e2f316431026dd083ff287bbe2e833c Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Mon, 2 Nov 2015 13:43:55 +0100 Subject: [PATCH 59/85] fix #118 - Add popup broken by django 1.8 thanks to @splatEric for help with the fix --- ajax_select/registry.py | 34 ++++++++++++++++----------------- ajax_select/views.py | 42 +++++++++++++++++++++++++++++------------ tests/admin.py | 9 +++++++++ tests/settings.py | 3 ++- tests/test_views.py | 42 +++++++++++++++++++++++++++++++++++++++++ tests/urls.py | 14 ++++++++++++++ 6 files changed, 114 insertions(+), 30 deletions(-) create mode 100644 tests/admin.py create mode 100644 tests/test_views.py create mode 100644 tests/urls.py diff --git a/ajax_select/registry.py b/ajax_select/registry.py index 893f7506f8..d2959b6d22 100644 --- a/ajax_select/registry.py +++ b/ajax_select/registry.py @@ -62,34 +62,34 @@ def make_channel(self, app_model, arg_search_field): arg_search_field: the field to search against and to display in search results """ from lookup_channel import LookupChannel - the_model = self.get_model(app_model) + app_label, model_name = app_model.split(".") class MadeLookupChannel(LookupChannel): - model = the_model + model = get_model(app_label, model_name) search_field = arg_search_field return MadeLookupChannel() - def get_model(self, app_model): - """ - Get the model from 'app_label.ModelName' - """ - app_label, model_name = app_model.split(".") - try: - # django >= 1.7 - from django.apps import apps - except ImportError: - # django < 1.7 - from django.db import models - return models.get_model(app_label, model_name) - else: - return apps.get_model(app_label, model_name) - registry = LookupChannelRegistry() +def get_model(app_label, model_name): + """ + Get the model from 'app_label' 'ModelName' + """ + try: + # django >= 1.7 + from django.apps import apps + except ImportError: + # django < 1.7 + from django.db import models + return models.get_model(app_label, model_name) + else: + return apps.get_model(app_label, model_name) + + def register(label): """ Decorator to register a LookupClass diff --git a/ajax_select/views.py b/ajax_select/views.py index b728a75414..5bfe90a3ba 100644 --- a/ajax_select/views.py +++ b/ajax_select/views.py @@ -1,7 +1,8 @@ from ajax_select import registry +from ajax_select.registry import get_model from django.contrib.admin import site -from django.db import models +from django.contrib.admin.options import IS_POPUP_VAR from django.http import HttpResponse from django.utils.encoding import force_text import json @@ -55,20 +56,37 @@ def add_popup(request, app_label, model): and instead of calling django's dismissAddAnontherPopup(win,newId,newRepr) it calls didAddPopup(win,newId,newRepr) which was added inline with bootstrap.html """ - themodel = models.get_model(app_label, model) + + themodel = get_model(app_label, model) admin = site._registry[themodel] # TODO : should detect where we really are - admin.admin_site.root_path = "/ajax_select/" + # admin.admin_site.root_path = "/ajax_select/" + + # Force the add_view to always recognise that it is being + # rendered in a pop up context + if request.method == 'GET': + get = request.GET.copy() + get[IS_POPUP_VAR] = 1 + request.GET = get + elif request.method == 'POST': + post = request.POST.copy() + post[IS_POPUP_VAR] = 1 + request.POST = post response = admin.add_view(request, request.path) - if request.method == 'POST': - # 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 - if 'opener.dismissAddAnotherPopup' in out: - return HttpResponse(out.replace('dismissAddAnotherPopup', 'didAddPopup')) + + if request.method == 'POST' and (response.status_code == 200): + + def fiddle(response): + content = response.content.decode('UTF-8') + # django >= 1.8 + fiddled = content.replace('dismissAddRelatedObjectPopup', 'didAddPopup') + # django < 1.8 + fiddled = fiddled.replace('dismissAddAnotherPopup', 'didAddPopup') + response.content = fiddled.encode('UTF-8') + return response + + response.add_post_render_callback(fiddle) + return response diff --git a/tests/admin.py b/tests/admin.py new file mode 100644 index 0000000000..5b5b1ae0b7 --- /dev/null +++ b/tests/admin.py @@ -0,0 +1,9 @@ + +from django.contrib import admin +from tests.models import Author + + +class AuthorAdmin(admin.ModelAdmin): + pass + +admin.site.register(Author, AuthorAdmin) diff --git a/tests/settings.py b/tests/settings.py index 3159e0774d..21411c40a8 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -5,7 +5,7 @@ "ENGINE": "django.db.backends.sqlite3", } } -ROOT_URLCONF = "ajax_select.urls" +ROOT_URLCONF = "tests.urls" INSTALLED_APPS = [ "django.contrib.auth", "django.contrib.contenttypes", @@ -23,6 +23,7 @@ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware' ) +STATIC_URL = '/static/' SECRET_KEY = 'inyd5fc5pymlsv@hwoc5+3_6*cm0erlxzv6i-wl0jm_kt-6rp9' AJAX_LOOKUP_CHANNELS = { diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 0000000000..4ac6799940 --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,42 @@ + +from django.test import TestCase +from django.contrib.auth.models import User +from django.test import Client +from django.core import urlresolvers + + +class TestViews(TestCase): + + def setUp(self): + self.user = User.objects.create_superuser(username='admin', + email='email@example.com', + password='password') + self.client = Client() + self.client.login(username='admin', password='password') + + def test_add_popup_get(self): + app_label = 'tests' + model = 'author' + url = urlresolvers.reverse('add_popup', kwargs={ + 'app_label': app_label, + 'model': model + }) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_add_popup_post(self): + app_label = 'tests' + model = 'author' + url = urlresolvers.reverse('add_popup', kwargs={ + 'app_label': app_label, + 'model': model + }) + data = dict(name='Name') + response = self.client.post(url, data) + + self.assertEqual(response.status_code, 200) + content = response.content.decode('UTF-8') + + self.assertFalse('dismissAddRelatedObjectPopup' in content) + self.assertFalse('dismissAddAnotherPopup' in content) + self.assertTrue('didAddPopup' in content) diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000000..9b70099602 --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,14 @@ + +from django.conf.urls import url, include +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 = [ + url(r'^ajax_lookups/', include(ajax_select_urls)), + url(r'^admin/', include(admin.site.urls)), +] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) From f142fdd47214bcb0ffa91fbc6ea195b06df599b8 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Mon, 2 Nov 2015 13:46:29 +0100 Subject: [PATCH 60/85] update for some RemovedInDjango110Warning --- ajax_select/urls.py | 12 ++++++------ example/example/urls.py | 16 +++++++--------- tests/settings.py | 23 +++++++++++++++++++++++ 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/ajax_select/urls.py b/ajax_select/urls.py index f837dd5e23..048ca46ab7 100644 --- a/ajax_select/urls.py +++ b/ajax_select/urls.py @@ -1,11 +1,11 @@ -from django.conf.urls import patterns, url +from django.conf.urls import url +from ajax_select import views - -urlpatterns = patterns('', +urlpatterns = [ url(r'^ajax_lookup/(?P[-\w]+)$', - 'ajax_select.views.ajax_lookup', + views.ajax_lookup, name='ajax_lookup'), url(r'^add_popup/(?P\w+)/(?P\w+)$', - 'ajax_select.views.add_popup', + views.add_popup, name='add_popup') -) +] diff --git a/example/example/urls.py b/example/example/urls.py index 980fbfad9d..c20d58f2b4 100644 --- a/example/example/urls.py +++ b/example/example/urls.py @@ -1,19 +1,17 @@ -try: - from django.conf.urls import patterns, url, include -except: - from django.conf.urls.defaults import patterns, url, include +from django.conf.urls import url, include 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 +from example import views admin.autodiscover() -urlpatterns = patterns('', +urlpatterns = [ url(r'^search_form', - view='example.views.search_form', + view=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) + url(r'^admin/lookups/', include(ajax_select_urls)), + url(r'^admin/', include(admin.site.urls)), +] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/tests/settings.py b/tests/settings.py index 21411c40a8..96d4a4654a 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -36,3 +36,26 @@ 'was-never-a-channel': None # LookupChannels in lookups.py are auto-loaded } + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + # insert your TEMPLATE_DIRS here + ], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + # Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this + # list if you haven't customized them: + 'django.contrib.auth.context_processors.auth', + 'django.template.context_processors.debug', + 'django.template.context_processors.i18n', + 'django.template.context_processors.media', + 'django.template.context_processors.static', + 'django.template.context_processors.tz', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] From 3bcc0c1ab6afadd5f4aa4ee17e26cce6bd165537 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Tue, 3 Nov 2015 13:39:38 +0100 Subject: [PATCH 61/85] consolidate all loading and feature detection in registry.py --- ajax_select/__init__.py | 4 +--- ajax_select/apps.py | 8 +------- ajax_select/registry.py | 23 ++++++++++++++++++++++- tests/test_lookups.py | 21 +++++++++++++-------- 4 files changed, 37 insertions(+), 19 deletions(-) diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index 851a7af6b7..b358650bbc 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -19,6 +19,4 @@ except ImportError: # Previous django versions should load now # using settings.AJAX_LOOKUP_CHANNELS - from django.conf import settings - if hasattr(settings, 'AJAX_LOOKUP_CHANNELS'): - registry.register(settings.AJAX_LOOKUP_CHANNELS) + registry.load_channels() diff --git a/ajax_select/apps.py b/ajax_select/apps.py index f3902e5618..acaf6267d3 100644 --- a/ajax_select/apps.py +++ b/ajax_select/apps.py @@ -15,11 +15,5 @@ class AjaxSelectConfig(AppConfig): verbose_name = 'Ajax Selects' def ready(self): - from django.conf import settings from ajax_select.registry import registry - from django.utils.module_loading import autodiscover_modules - - autodiscover_modules('lookups') - - if hasattr(settings, 'AJAX_LOOKUP_CHANNELS'): - registry.register(settings.AJAX_LOOKUP_CHANNELS) + registry.load_channels() diff --git a/ajax_select/registry.py b/ajax_select/registry.py index d2959b6d22..9d63165957 100644 --- a/ajax_select/registry.py +++ b/ajax_select/registry.py @@ -1,4 +1,5 @@ from django.core.exceptions import ImproperlyConfigured +from django.conf import settings class LookupChannelRegistry(object): @@ -11,6 +12,18 @@ class LookupChannelRegistry(object): """ _registry = {} + def load_channels(self): + self._registry = {} + try: + from django.utils.module_loading import autodiscover_modules + except ImportError: + pass + else: + autodiscover_modules('lookups') + + if hasattr(settings, 'AJAX_LOOKUP_CHANNELS'): + self.register(settings.AJAX_LOOKUP_CHANNELS) + def register(self, lookup_specs): """ lookup_specs is a dict with one or more LookupChannel specifications @@ -90,7 +103,15 @@ def get_model(app_label, model_name): return apps.get_model(app_label, model_name) -def register(label): +def can_autodiscover(): + try: + from django.apps import AppConfig # noqa + except ImportError: + return False + return True + + +def register(channel): """ Decorator to register a LookupClass diff --git a/tests/test_lookups.py b/tests/test_lookups.py index 14dcb97b3d..c4c1021de9 100644 --- a/tests/test_lookups.py +++ b/tests/test_lookups.py @@ -1,13 +1,7 @@ from django.test import TestCase import ajax_select - -try: - from django.apps import AppConfig # noqa -except ImportError: - can_autodiscover = False -else: - can_autodiscover = True +from ajax_select.registry import can_autodiscover class TestAutoDiscovery(TestCase): @@ -15,9 +9,10 @@ class TestAutoDiscovery(TestCase): def test_lookup_py_is_autoloaded(self): """Django >= 1.7 autoloads tests/lookups.py""" is_registered = ajax_select.registry.is_registered('person') - if can_autodiscover: + if can_autodiscover(): self.assertTrue(is_registered) else: + # person is not in settings and this django will not autoload lookups.py self.assertFalse(is_registered) def test_back_compatible_loads_by_settings(self): @@ -32,3 +27,13 @@ def test_unsetting_a_channel(self): """settings can unset a channel that was specified in a lookups.py""" self.assertFalse(ajax_select.registry.is_registered('user')) self.assertFalse(ajax_select.registry.is_registered('was-never-a-channel')) + + # def test_reimporting_lookup(self): + # """ + # Importing a lookup should not re-register it after app launch is completed. + # """ + # # importing this file will cause the @register to be called + # from tests import lookups # noqa + # # should not have over-ridden what is in settings + # # registry is already frozen + # self.assertFalse(ajax_select.registry.is_registered('user')) From 15d93a7d5ea3dc5dfbf835d306db4857ecf331ad Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Tue, 3 Nov 2015 13:40:47 +0100 Subject: [PATCH 62/85] use the arg name 'channel' consistently --- ajax_select/fields.py | 6 +++--- ajax_select/registry.py | 19 ++++++++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/ajax_select/fields.py b/ajax_select/fields.py index 41e33bbfbc..dd85a01c05 100644 --- a/ajax_select/fields.py +++ b/ajax_select/fields.py @@ -397,17 +397,17 @@ def autoselect_fields_check_can_add(form, model, user): form_field.check_can_add(user, db_field.rel.to) -def plugin_options(channel, channel_name, widget_plugin_options, initial): +def plugin_options(lookup, 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(lookup, '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) + po['min_length'] = getattr(lookup, 'min_length', 1) if not po.get('source'): po['source'] = reverse('ajax_lookup', kwargs={'channel': channel_name}) diff --git a/ajax_select/registry.py b/ajax_select/registry.py index 9d63165957..bf5d814fbd 100644 --- a/ajax_select/registry.py +++ b/ajax_select/registry.py @@ -30,12 +30,12 @@ def register(self, lookup_specs): {'channel': ('module.of.lookups', 'MyLookupClass')} {'channel': {'model': 'MyModelToBeLookedUp', 'search_field': 'field_to_search'}} """ - for label, spec in lookup_specs.items(): + for channel, spec in lookup_specs.items(): if spec is None: # unset - if label in self._registry: - del self._registry[label] + if channel in self._registry: + del self._registry[channel] else: - self._registry[label] = spec + self._registry[channel] = spec def get(self, channel): """ @@ -122,17 +122,18 @@ def register(channel): @register('agent') class AgentLookup(LookupClass): - def get_query(self) - def format_item(self) - ... etc + def get_query(self): + ... + def format_item(self): + ... ``` """ def _wrapper(lookup_class): - if not label: + if not channel: raise ValueError('Lookup Channel must have a channel name') - registry.register({label: lookup_class}) + registry.register({channel: lookup_class}) return lookup_class From e5f869cd9c8ab050cee8d1702d6386c05353ae37 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Tue, 3 Nov 2015 13:41:31 +0100 Subject: [PATCH 63/85] always import LookupChannel from ajax_selects otherwise python names the class differently and issubclass fails --- ajax_select/registry.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/ajax_select/registry.py b/ajax_select/registry.py index bf5d814fbd..c15e56f749 100644 --- a/ajax_select/registry.py +++ b/ajax_select/registry.py @@ -43,7 +43,7 @@ def get(self, channel): @param channel {string} - name that the lookup channel was registered at """ - from lookup_channel import LookupChannel + from ajax_select import LookupChannel try: lookup_spec = self._registry[channel] @@ -53,18 +53,24 @@ def get(self, channel): if (type(lookup_spec) is type) and issubclass(lookup_spec, LookupChannel): return lookup_spec() + # damnit python. + # ideally this would match regardless of how you imported the parent class + # but these are different classes: + # from ajax_select.lookup_channel import LookupChannel + # from ajax_select import LookupChannel elif isinstance(lookup_spec, dict): # 'channel' : dict(model='app.model', search_field='title' ) # generate a simple channel dynamically return self.make_channel(lookup_spec['model'], lookup_spec['search_field']) - else: + elif isinstance(lookup_spec, tuple): # a tuple # 'channel' : ('app.module','LookupClass') # from app.module load LookupClass and instantiate lookup_module = __import__(lookup_spec[0], {}, {}, ['']) lookup_class = getattr(lookup_module, lookup_spec[1]) - return lookup_class() + else: + raise Exception("Invalid lookup spec: %s" % lookup_spec) def is_registered(self, channel): return channel in self._registry @@ -74,7 +80,7 @@ def make_channel(self, app_model, arg_search_field): app_model: app_name.ModelName arg_search_field: the field to search against and to display in search results """ - from lookup_channel import LookupChannel + from ajax_select import LookupChannel app_label, model_name = app_model.split(".") class MadeLookupChannel(LookupChannel): From e42b9cebddc6649dde0d5f3f4b4be21de992f5e4 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Tue, 3 Nov 2015 13:42:04 +0100 Subject: [PATCH 64/85] tests: render each widget --- tests/test_fields.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/tests/test_fields.py b/tests/test_fields.py index 1c020e16d0..8a52e93343 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -2,9 +2,32 @@ from ajax_select import fields -class TestAjaxSelectAutoCompleteSelectWidget(TestCase): +class WidgetTester(TestCase): + pass + + +class TestAutoCompleteSelectWidget(WidgetTester): def test_render(self): - channel = None + channel = 'book' widget = fields.AutoCompleteSelectWidget(channel) - widget + out = widget.render('book', None) + self.assertTrue('autocompleteselect' in out) + + +class TestAutoCompleteSelectMultipleWidget(WidgetTester): + + def test_render(self): + channel = 'book' + widget = fields.AutoCompleteSelectMultipleWidget(channel) + out = widget.render('book', None) + self.assertTrue('autocompleteselectmultiple' in out) + + +class TestAutoCompleteWidget(WidgetTester): + + def test_render(self): + channel = 'book' + widget = fields.AutoCompleteWidget(channel) + out = widget.render('book', None) + self.assertTrue('autocomplete' in out) From a1d2be21c86ebd5d61629e2325f195fc01a25182 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Tue, 3 Nov 2015 13:43:35 +0100 Subject: [PATCH 65/85] fix #126 - add widget_options for each field --- ajax_select/fields.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/ajax_select/fields.py b/ajax_select/fields.py index dd85a01c05..3dfb43ebaf 100644 --- a/ajax_select/fields.py +++ b/ajax_select/fields.py @@ -110,16 +110,15 @@ class AutoCompleteSelectField(forms.fields.CharField): def __init__(self, channel, *args, **kwargs): self.channel = channel - widget = kwargs.get("widget", False) - - 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', {}) - ) - kwargs["widget"] = AutoCompleteSelectWidget(**widget_kwargs) + + 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', {}) + ) + widget_kwargs.update(kwargs.pop('widget_options', {})) + kwargs["widget"] = AutoCompleteSelectWidget(**widget_kwargs) super(AutoCompleteSelectField, self).__init__(max_length=255, *args, **kwargs) def clean(self, value): @@ -272,6 +271,7 @@ def __init__(self, channel, *args, **kwargs): 'show_help_text': show_help_text, 'plugin_options': kwargs.pop('plugin_options', {}) } + widget_kwargs.update(kwargs.pop('widget_options', {})) kwargs['widget'] = AutoCompleteSelectMultipleWidget(**widget_kwargs) kwargs['help_text'] = help_text @@ -354,6 +354,7 @@ def __init__(self, channel, *args, **kwargs): show_help_text=kwargs.pop('show_help_text', True), plugin_options=kwargs.pop('plugin_options', {}) ) + widget_kwargs.update(kwargs.pop('widget_options', {})) if 'attrs' in kwargs: widget_kwargs['attrs'] = kwargs.pop('attrs') widget = AutoCompleteWidget(channel, **widget_kwargs) From 8710dd237e3cbe83562a3f020c20accbf8ffe131 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Tue, 3 Nov 2015 13:53:13 +0100 Subject: [PATCH 66/85] rename test_lookups -> test_registry --- tests/{test_lookups.py => test_registry.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/{test_lookups.py => test_registry.py} (97%) diff --git a/tests/test_lookups.py b/tests/test_registry.py similarity index 97% rename from tests/test_lookups.py rename to tests/test_registry.py index c4c1021de9..a1f2d908eb 100644 --- a/tests/test_lookups.py +++ b/tests/test_registry.py @@ -4,7 +4,7 @@ from ajax_select.registry import can_autodiscover -class TestAutoDiscovery(TestCase): +class TestRegistry(TestCase): def test_lookup_py_is_autoloaded(self): """Django >= 1.7 autoloads tests/lookups.py""" From a37766905911b4d529b13dbcf384eb6e2f0d8a71 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Tue, 3 Nov 2015 14:25:51 +0100 Subject: [PATCH 67/85] fix #123 - changed_data always includes AutoComplete fields it was because the fields are CharFields so they return pk of u'1' but the initial value is 1 --- ajax_select/fields.py | 9 +++++++++ tests/test_fields.py | 28 +++++++++++++++++++++------- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/ajax_select/fields.py b/ajax_select/fields.py index 3dfb43ebaf..1520fe256b 100644 --- a/ajax_select/fields.py +++ b/ajax_select/fields.py @@ -139,6 +139,10 @@ def clean(self, value): def check_can_add(self, user, model): _check_can_add(self, user, model) + def has_changed(self, initial_value, data_value): + # 1 vs u'1' + return text_type(initial_value) != text_type(data_value) + #################################################################################### @@ -285,6 +289,11 @@ def clean(self, value): def check_can_add(self, user, model): _check_can_add(self, user, model) + def has_changed(self, initial_value, data_value): + # [1, 2] vs [u'1', u'2'] + ivs = [text_type(v) for v in initial_value] + dvs = [text_type(v) for v in data_value] + return ivs != dvs #################################################################################### diff --git a/tests/test_fields.py b/tests/test_fields.py index 8a52e93343..f6ad676e76 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -2,11 +2,7 @@ from ajax_select import fields -class WidgetTester(TestCase): - pass - - -class TestAutoCompleteSelectWidget(WidgetTester): +class TestAutoCompleteSelectWidget(TestCase): def test_render(self): channel = 'book' @@ -15,7 +11,7 @@ def test_render(self): self.assertTrue('autocompleteselect' in out) -class TestAutoCompleteSelectMultipleWidget(WidgetTester): +class TestAutoCompleteSelectMultipleWidget(TestCase): def test_render(self): channel = 'book' @@ -24,10 +20,28 @@ def test_render(self): self.assertTrue('autocompleteselectmultiple' in out) -class TestAutoCompleteWidget(WidgetTester): +class TestAutoCompleteWidget(TestCase): def test_render(self): channel = 'book' widget = fields.AutoCompleteWidget(channel) out = widget.render('book', None) self.assertTrue('autocomplete' in out) + + +class TestAutoCompleteSelectField(TestCase): + + def test_has_changed(self): + field = fields.AutoCompleteSelectField('book') + self.assertFalse(field.has_changed(1, '1')) + self.assertFalse(field.has_changed('abc', 'abc')) + self.assertTrue(field.has_changed(1, '2')) + + +class TestAutoCompleteSelectMultipleField(TestCase): + + def test_has_changed(self): + field = fields.AutoCompleteSelectMultipleField('book') + self.assertFalse(field.has_changed([1], ['1'])) + self.assertFalse(field.has_changed(['abc'], ['abc'])) + self.assertTrue(field.has_changed([1], ['2'])) From 548f18d1b5b042c19c07746cdde1facee8b9e8ff Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Tue, 3 Nov 2015 15:10:46 +0100 Subject: [PATCH 68/85] remove deprecated 'min_length' template var - use plugin_options['min_length'] remove deprecated 'lookup_url' template var - use plugin_options['source'] custom templates should now use plugin_options --- ajax_select/fields.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/ajax_select/fields.py b/ajax_select/fields.py index 1520fe256b..9a97284e56 100644 --- a/ajax_select/fields.py +++ b/ajax_select/fields.py @@ -414,21 +414,14 @@ def plugin_options(lookup, channel_name, widget_plugin_options, initial): po['initial'] = initial po.update(getattr(lookup, '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(lookup, 'min_length', 1) if not po.get('source'): po['source'] = reverse('ajax_lookup', kwargs={'channel': channel_name}) - # allow html unless explicitly false + # allow html unless explicitly set if po.get('html') is None: po['html'] = True return { 'plugin_options': mark_safe(json.dumps(po)), - 'data_plugin_options': force_escape(json.dumps(po)), - # continue to support any custom templates that still expect these - 'lookup_url': po['source'], - 'min_length': po['min_length'] + 'data_plugin_options': force_escape(json.dumps(po)) } From ad38f711fe33a72059bc964f9656563a5af1ece8 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Tue, 3 Nov 2015 15:19:28 +0100 Subject: [PATCH 69/85] cleanup example - formatting, py3 fixes add flake test to tox --- example/README.md | 35 +++++++++++ example/README.txt | 35 ----------- example/example/admin.py | 9 ++- example/example/forms.py | 3 +- example/example/lookups.py | 121 +++++++++++++++++++------------------ example/example/models.py | 3 +- example/example/views.py | 13 ++-- tox.ini | 10 +-- 8 files changed, 118 insertions(+), 111 deletions(-) create mode 100644 example/README.md delete mode 100644 example/README.txt diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000000..4919225c38 --- /dev/null +++ b/example/README.md @@ -0,0 +1,35 @@ + +# A test application to play with django-ajax-selects + + +## INSTALL + +Install a local django in a virtualenv: + + ./install.sh + +Or to install a specific django version: + + ./install.sh 1.8.5 + +List of available django versions: + +https://pypi.python.org/pypi/Django + + +This will also activate the virtualenv and create a sqlite db + +## VIRTUALENV + +Active the virtualenv: + + source AJAXSELECTS/bin/activate + + +## Run the server + + ./manage.py runserver + +Go visit the admin site and play around: + +http://127.0.0.1:8000/admin diff --git a/example/README.txt b/example/README.txt deleted file mode 100644 index e5dfc4ac33..0000000000 --- a/example/README.txt +++ /dev/null @@ -1,35 +0,0 @@ - -A test application to play with django-ajax-selects - - -INSTALL ±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±± - -Install a local django in a virtualenv: - -./install.sh - -This will also activate the virtualenv and create a sqlite db - - -Run the server: - -./manage.py runserver - -Go visit the admin site and play around: - -http://127.0.0.1:8000/admin - - -DEACTIVATE ±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±± - -To deactiveate the virtualenv just close the shell or: - -deactivate - - -REACTIVATE ±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±±± - -Reactivate it later: - -source AJAXSELECTS/bin/activate - diff --git a/example/example/admin.py b/example/example/admin.py index ef3e2de7d7..c130e58bfa 100644 --- a/example/example/admin.py +++ b/example/example/admin.py @@ -58,6 +58,11 @@ class GroupAdmin(AjaxSelectAdmin): class SongAdmin(AjaxSelectAdmin): form = make_ajax_form(Song, {'group': 'group', 'title': 'cliche'}) + # django bug: + # readonly_fields = ('group',) + # django displays group twice if its listed as a readonly_field + # and throws a validation error on save + # but doesn't show any error message to the user admin.site.register(Song, SongAdmin) @@ -79,8 +84,8 @@ class BookInline(AjaxSelectAdminTabularInline): form = make_ajax_form(Book, { 'about_group': 'group', 'mentions_persons': 'person' - }, - show_help_text=True) + }, + show_help_text=True) extra = 2 # to enable the + add option diff --git a/example/example/forms.py b/example/example/forms.py index cc2d911e2c..89b11f54f8 100644 --- a/example/example/forms.py +++ b/example/example/forms.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import unicode_literals from django.forms.models import ModelForm from ajax_select import make_ajax_field from example.models import Release @@ -23,4 +24,4 @@ class Meta: 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="Autocomplete will suggest clichés about cats.") diff --git a/example/example/lookups.py b/example/example/lookups.py index 1a9eaf66db..99ae6666b0 100644 --- a/example/example/lookups.py +++ b/example/example/lookups.py @@ -1,4 +1,5 @@ - +from __future__ import text_type_literals +from django.utils.six import text_type from django.db.models import Q from django.utils.html import escape from example.models import Person, Group, Song @@ -14,17 +15,17 @@ 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): - u""" result is the simple text that is the completion of what the person typed """ + """ result is the simple text that is the completion of what the person typed """ return obj.name def format_match(self, obj): """ (HTML) formatted item for display in the dropdown """ - return u"%s
%s
" % (escape(obj.name), escape(obj.email)) + return "%s
%s
" % (escape(obj.name), escape(obj.email)) # return self.format_item_display(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 "%s
%s
" % (escape(obj.name), escape(obj.email)) class GroupLookup(LookupChannel): @@ -35,13 +36,13 @@ def get_query(self, q, request): return Group.objects.filter(name__icontains=q).order_by('name') def get_result(self, obj): - return unicode(obj) + return text_type(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)) + return "%s
%s
" % (escape(obj.name), escape(obj.url)) def can_add(self, user, model): """ customize can_add by allowing anybody to add a Group. @@ -59,7 +60,7 @@ def get_query(self, q, request): return Song.objects.filter(title__icontains=q).select_related('group').order_by('title') def get_result(self, obj): - return unicode(obj.title) + return text_type(obj.title) def format_match(self, obj): return self.format_item_display(obj) @@ -78,13 +79,13 @@ class ClicheLookup(LookupChannel): """ words = [ - u"rain cats and dogs", - u"quick as a cat", - u"there's more than one way to skin a cat", - u"let the cat out of the bag", - u"fat cat", - u"the early bird catches the worm", - u"catch as catch can as catch as catch can as catch as catch can as catch as catch " + "rain cats and dogs", + "quick as a cat", + "there's more than one way to skin a cat", + "let the cat out of the bag", + "fat cat", + "the early bird catches the worm", + "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 " @@ -97,52 +98,52 @@ class ClicheLookup(LookupChannel): "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", - u"cat's meow", - u"has the cat got your tongue?", - u"busy as a cat on a hot tin roof", - u"who'll bell the cat", - u"cat's ass", - u"more nervous than a long tailed cat in a room full of rocking chairs", - u"all cats are grey in the dark", - u"nervous as a cat in a room full of rocking chairs", - u"can't a cat look at a queen?", - u"curiosity killed the cat", - u"cat's pajamas", - u"look what the cat dragged in", - u"while the cat's away the mice will play", - u"Nervous as a cat in a room full of rocking chairs", - u"Slicker than cat shit on a linoleum floor", - u"there's more than one way to kill a cat than choking it with butter.", - u"you can't swing a dead cat without hitting one", - u"The cat's whisker", - u"looking like the cat who swallowed the canary", - u"not enough room to swing a cat", - u"It's raining cats and dogs", - u"He was on that like a pack of dogs on a three-legged cat.", - u"like two tomcats in a gunny sack", - u"I don't know your from adam's house cat!", - u"nervous as a long tailed cat in a living room full of rockers", - u"Busier than a three legged cat in a dry sand box.", - u"Busier than a one-eyed cat watching two mouse holes.", - u"kick the dog and cat", - u"there's more than one way to kill a cat than to drown it in cream", - u"how many ways can you skin a cat?", - u"Looks like a black cat with a red bird in its mouth", - u"Morals of an alley cat and scruples of a snake.", - u"hotter than a six peckered alley cat", - u"when the cats are away the mice will play", - u"you can catch more flies with honey than vinegar", - u"when the cat's away, the mice will play", - u"Who opened the cattleguard?", - u"your past might catch up with you", - u"ain't that just the cats pyjamas", - u"A Cat has nine lives", - u"a cheshire-cat smile", - u"cat's pajamas", - u"cat got your tongue?"] + "you can catch more flies with honey than with vinegar", + "catbird seat", + "cat's paw", + "cat's meow", + "has the cat got your tongue?", + "busy as a cat on a hot tin roof", + "who'll bell the cat", + "cat's ass", + "more nervous than a long tailed cat in a room full of rocking chairs", + "all cats are grey in the dark", + "nervous as a cat in a room full of rocking chairs", + "can't a cat look at a queen?", + "curiosity killed the cat", + "cat's pajamas", + "look what the cat dragged in", + "while the cat's away the mice will play", + "Nervous as a cat in a room full of rocking chairs", + "Slicker than cat shit on a linoleum floor", + "there's more than one way to kill a cat than choking it with butter.", + "you can't swing a dead cat without hitting one", + "The cat's whisker", + "looking like the cat who swallowed the canary", + "not enough room to swing a cat", + "It's raining cats and dogs", + "He was on that like a pack of dogs on a three-legged cat.", + "like two tomcats in a gunny sack", + "I don't know your from adam's house cat!", + "nervous as a long tailed cat in a living room full of rockers", + "Busier than a three legged cat in a dry sand box.", + "Busier than a one-eyed cat watching two mouse holes.", + "kick the dog and cat", + "there's more than one way to kill a cat than to drown it in cream", + "how many ways can you skin a cat?", + "Looks like a black cat with a red bird in its mouth", + "Morals of an alley cat and scruples of a snake.", + "hotter than a six peckered alley cat", + "when the cats are away the mice will play", + "you can catch more flies with honey than vinegar", + "when the cat's away, the mice will play", + "Who opened the cattleguard?", + "your past might catch up with yo", + "ain't that just the cats pyjamas", + "A Cat has nine lives", + "a cheshire-cat smile", + "cat's pajamas", + "cat got your tongue?"] def get_query(self, q, request): return sorted([w for w in self.words if q in w]) diff --git a/example/example/models.py b/example/example/models.py index 6a523c74ac..7bac73bc2c 100644 --- a/example/example/models.py +++ b/example/example/models.py @@ -1,5 +1,6 @@ # -*- coding: utf8 -*- +from __future__ import unicode_literals from django.db import models @@ -57,7 +58,7 @@ class Release(models.Model): 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)") + group = models.ForeignKey(Group, blank=True, null=True, verbose_name="Русский текст (group)") label = models.ForeignKey(Label, blank=False, null=False) songs = models.ManyToManyField(Song, blank=True) diff --git a/example/example/views.py b/example/example/views.py index 84a832ba9b..127eb774cb 100644 --- a/example/example/views.py +++ b/example/example/views.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import unicode_literals from django import forms from django.shortcuts import render_to_response from django.template import RequestContext @@ -8,13 +9,11 @@ class SearchForm(forms.Form): - q = AutoCompleteField( - 'cliche', - required=True, - help_text="Autocomplete will suggest clichés about cats, but you can enter anything you like.", - label="Favorite Cliché", - attrs={'size': 100} - ) + q = AutoCompleteField('cliche', + required=True, + help_text="Autocomplete will suggest clichés about cats, but you can enter anything you like.", + label="Favorite Cliché", + attrs={'size': 100}) def search_form(request): diff --git a/tox.ini b/tox.ini index 55c38e9575..98e0f5cd66 100644 --- a/tox.ini +++ b/tox.ini @@ -23,24 +23,24 @@ deps = [testenv:py27-flake8] deps = flake8 -commands = flake8 ajax_select tests +commands = flake8 ajax_select tests example [testenv:py32-flake8] deps = flake8 -commands = flake8 ajax_select tests +commands = flake8 ajax_select tests example [testenv:py33-flake8] deps = flake8 -commands = flake8 ajax_select tests +commands = flake8 ajax_select tests example [testenv:py34-flake8] deps = flake8 -commands = flake8 ajax_select tests +commands = flake8 ajax_select tests example [testenv:py35-flake8] deps = flake8 -commands = flake8 ajax_select tests +commands = flake8 ajax_select tests example From d99235422aa56557570c38744d78080554dd638a Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Tue, 3 Nov 2015 15:29:29 +0100 Subject: [PATCH 70/85] add sphinx and read the docs theme --- docs/Makefile | 177 ++++++++++++++++++++++++++++ docs/conf.py | 268 ++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 22 ++++ requirements-test.txt | 1 + 4 files changed, 468 insertions(+) create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/index.rst diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000000..8dd6b326c5 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,177 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-ajax-selects.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-ajax-selects.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/django-ajax-selects" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-ajax-selects" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000000..d812087209 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,268 @@ +# -*- coding: utf-8 -*- +# +# django-ajax-selects documentation build configuration file, created by +# sphinx-quickstart on Tue Nov 3 15:23:14 2015. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys +import os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.coverage', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'django-ajax-selects' +copyright = u'2015, Chris Sattinger' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '1.4.0' +# The full version, including alpha/beta/rc tags. +release = '1.4.0' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. + +try: + import sphinx_rtd_theme +except ImportError: + html_theme = 'default' +else: + html_theme = "sphinx_rtd_theme" + html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'django-ajax-selectsdoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ('index', 'django-ajax-selects.tex', u'django-ajax-selects Documentation', + u'Chris Sattinger', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'django-ajax-selects', u'django-ajax-selects Documentation', + [u'Chris Sattinger'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'django-ajax-selects', u'django-ajax-selects Documentation', + u'Chris Sattinger', 'django-ajax-selects', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000000..679f72da54 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,22 @@ +.. django-ajax-selects documentation master file, created by + sphinx-quickstart on Tue Nov 3 15:23:14 2015. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to django-ajax-selects's documentation! +=============================================== + +Contents: + +.. toctree:: + :maxdepth: 2 + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/requirements-test.txt b/requirements-test.txt index 69369868b7..9a61c7bb13 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -2,3 +2,4 @@ coverage coveralls flake8>=2.1.0 tox>=1.7.0 +sphinx_rtd_theme From 8d7ef0be49dd07419a5a42c989f6b5184e81dfb2 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Fri, 6 Nov 2015 18:24:36 +0100 Subject: [PATCH 71/85] tons of docs --- docs/Makefile | 108 +------------------------- docs/index.rst | 22 ------ docs/source/Admin-add-popup.rst | 55 +++++++++++++ docs/source/Admin.rst | 8 ++ docs/source/Contributing.rst | 35 +++++++++ docs/source/Custom-Templates.rst | 36 +++++++++ docs/source/Example-app.rst | 11 +++ docs/source/Forms.rst | 65 ++++++++++++++++ docs/source/Install.rst | 90 +++++++++++++++++++++ docs/source/LookupChannel.rst | 77 ++++++++++++++++++ docs/source/Media-assets.rst | 39 ++++++++++ docs/source/Ordered-ManyToMany.rst | 61 +++++++++++++++ docs/source/Outside-of-Admin.rst | 12 +++ docs/source/Upgrading.rst | 41 ++++++++++ docs/source/_static/add-song.png | Bin 0 -> 10613 bytes docs/source/_static/add.png | Bin 0 -> 7381 bytes docs/source/_static/kiss-all.png | Bin 0 -> 26415 bytes docs/source/_static/kiss.png | Bin 0 -> 28714 bytes docs/source/ajax_select.rst | 86 ++++++++++++++++++++ docs/{ => source}/conf.py | 53 +++++++------ docs/source/index.rst | 94 ++++++++++++++++++++++ docs/source/jQuery-events.rst | 71 +++++++++++++++++ docs/source/jQuery-plugin-options.rst | 31 ++++++++ docs/source/modules.rst | 7 ++ 24 files changed, 849 insertions(+), 153 deletions(-) delete mode 100644 docs/index.rst create mode 100644 docs/source/Admin-add-popup.rst create mode 100644 docs/source/Admin.rst create mode 100644 docs/source/Contributing.rst create mode 100644 docs/source/Custom-Templates.rst create mode 100644 docs/source/Example-app.rst create mode 100644 docs/source/Forms.rst create mode 100644 docs/source/Install.rst create mode 100644 docs/source/LookupChannel.rst create mode 100644 docs/source/Media-assets.rst create mode 100644 docs/source/Ordered-ManyToMany.rst create mode 100644 docs/source/Outside-of-Admin.rst create mode 100644 docs/source/Upgrading.rst create mode 100644 docs/source/_static/add-song.png create mode 100644 docs/source/_static/add.png create mode 100644 docs/source/_static/kiss-all.png create mode 100644 docs/source/_static/kiss.png create mode 100644 docs/source/ajax_select.rst rename docs/{ => source}/conf.py (91%) create mode 100644 docs/source/index.rst create mode 100644 docs/source/jQuery-events.rst create mode 100644 docs/source/jQuery-plugin-options.rst create mode 100644 docs/source/modules.rst diff --git a/docs/Makefile b/docs/Makefile index 8dd6b326c5..26a30dcd08 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -15,36 +15,20 @@ endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext - +.PHONY: help clean html htmlhelp epub latex changes linkcheck help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* @@ -54,50 +38,17 @@ html: @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-ajax-selects.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-ajax-selects.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/django-ajax-selects" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-ajax-selects" - @echo "# devhelp" - epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @@ -110,46 +61,6 @@ latex: @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @@ -160,18 +71,3 @@ linkcheck: @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 679f72da54..0000000000 --- a/docs/index.rst +++ /dev/null @@ -1,22 +0,0 @@ -.. django-ajax-selects documentation master file, created by - sphinx-quickstart on Tue Nov 3 15:23:14 2015. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to django-ajax-selects's documentation! -=============================================== - -Contents: - -.. toctree:: - :maxdepth: 2 - - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - diff --git a/docs/source/Admin-add-popup.rst b/docs/source/Admin-add-popup.rst new file mode 100644 index 0000000000..0fbc9bb99f --- /dev/null +++ b/docs/source/Admin-add-popup.rst @@ -0,0 +1,55 @@ +Admin add popup +=============== + +This enables that green + icon: + +.. image:: _static/add-song.png + +The user can click, a popup window lets them create a new object, they click save, the popup closes and the AjaxSelect field is set. + +Your `Admin` must inherit from `AjaxSelectAdmin`:: + + class YourModelAdmin(AjaxSelectAdmin): + pass + +or be used as a mixin class:: + + class YourModelAdmin(AnotherAdminClass, AjaxSelectAdmin): + pass + +or you must implement `get_form` yourself:: + + from ajax_selects.fields import autoselect_fields_check_can_add + + class YourModelAdmin(admin.ModelAdmin): + + def get_form(self, request, obj=None, **kwargs): + form = super(YourModelAdmin, self).get_form(request, obj, **kwargs) + autoselect_fields_check_can_add(form, self.model, request.user) + return form + + +The `User` must also have permission to create an object of that type, as determined by the standard Django permission system. +Otherwise the resulting pop up will just deny them. + +Custom Permission Check +----------------------- + +You could implement a custom permission check in the `LookupChannel`:: + + from permissionz import permissions + + class YourLookupChannel(LookupChannel): + + def can_add(self, user, model): + return permissions.has_perm('can_add', model, user) + + +Inline forms in the Admin +------------------------- + +If you are using ajax select fields on an Inline you can use these superclasses: + + from ajax_select.admin import AjaxSelectAdminTabularInline, AjaxSelectAdminStackedInline + # or use this mixin if already have a superclass + from ajax_select.admin import AjaxSelectAdminInlineFormsetMixin diff --git a/docs/source/Admin.rst b/docs/source/Admin.rst new file mode 100644 index 0000000000..277f14165b --- /dev/null +++ b/docs/source/Admin.rst @@ -0,0 +1,8 @@ +Admin +===== + +If your application does not otherwise require a custom Form class then you can create the form directly in your admin using `make_ajax_form` + +.. automodule:: ajax_select.helpers + :members: make_ajax_form + :noindex: diff --git a/docs/source/Contributing.rst b/docs/source/Contributing.rst new file mode 100644 index 0000000000..a2de3cf5ca --- /dev/null +++ b/docs/source/Contributing.rst @@ -0,0 +1,35 @@ +Contributing +============ + + +Testing +------- + +For any pull requests you should run the unit tests first. Travis CI will also run all tests across all supported versions against your pull request and github will show you the failures. + +Its much faster to run them yourself locally.:: + + pip install -r requirements-test.txt + +run tox::: + + make test + + # or just + tox + +With all supported combinations of Django and Python. + +You will need to have different Python interpreters installed which you can do with: + +https://github.com/yyuu/pyenv + +It will skip tests for any interpreter you don't have installed. + +Most importantly you should have at least 2.7 and 3.4 + + +Documentation +------------- + +Docstrings use Google style: http://sphinx-doc.org/ext/example_google.html#example-google diff --git a/docs/source/Custom-Templates.rst b/docs/source/Custom-Templates.rst new file mode 100644 index 0000000000..671f41ac5f --- /dev/null +++ b/docs/source/Custom-Templates.rst @@ -0,0 +1,36 @@ +Customizing Templates +===================== + +Each form field widget is rendered using a template: + +- autocomplete.html +- autocompleteselect.html +- autocompleteselectmultiple.html + +You may write a custom template for your channel: + +- yourapp/templates/ajax_select/{channel}_autocomplete.html +- yourapp/templates/ajax_select/{channel}_autocompleteselect.html +- yourapp/templates/ajax_select/{channel}_autocompleteselectmultiple.html + + +And customize these blocks:: + + {% block extra_script %} + + {% endblock %} + + {% block help %} +

+ You could put additional UI or help text here. +

+ {% endblock %} diff --git a/docs/source/Example-app.rst b/docs/source/Example-app.rst new file mode 100644 index 0000000000..f734d4f918 --- /dev/null +++ b/docs/source/Example-app.rst @@ -0,0 +1,11 @@ + +Example App +=========== + +Clone this repository and see the example app for a full working admin site with many variations and comments. It installs quickly using virtualenv and sqllite and comes fully configured. + +install:: + + cd example + ./install.sh 1.8.5 + ./manage.py runserver diff --git a/docs/source/Forms.rst b/docs/source/Forms.rst new file mode 100644 index 0000000000..d474056a31 --- /dev/null +++ b/docs/source/Forms.rst @@ -0,0 +1,65 @@ +Forms +===== + +Forms can be used either for an Admin or in normal Django views. + +Subclass ModelForm as usual and define fields:: + + from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField + + class DocumentForm(ModelForm): + + class Meta: + model = Document + + category = AutoCompleteSelectField('categories', required=False, help_text=None) + tags = AutoCompleteSelectMultipleField('tags', required=False, help_text=None) + + +make_ajax_field +--------------- + +There is also a helper method available here. + +.. automodule:: ajax_select.helpers + :members: make_ajax_field + :noindex: + + +Example:: + + from ajax_select import make_ajax_field + + class DocumentForm(ModelForm): + + class Meta: + model = Document + + category = make_ajax_field(Category, 'categories', 'category', help_text=None) + tags = make_ajax_field(Tag, 'tags', 'tags', help_text=None) + + + +FormSet +------- + +There is possibly a better way to do this, but here is an initial example: + +`forms.py`:: + + from django.forms.models import modelformset_factory + from django.forms.models import BaseModelFormSet + from ajax_select.fields import AutoCompleteSelectMultipleField, AutoCompleteSelectField + + from models import Task + + # create a superclass + class BaseTaskFormSet(BaseModelFormSet): + + # that adds the field in, overwriting the previous default field + def add_fields(self, form, index): + super(BaseTaskFormSet, self).add_fields(form, index) + 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) diff --git a/docs/source/Install.rst b/docs/source/Install.rst new file mode 100644 index 0000000000..d18d9dd627 --- /dev/null +++ b/docs/source/Install.rst @@ -0,0 +1,90 @@ +Install +======= + +Install:: + + pip install django-ajax-selects + +Add the app:: + + # settings.py + INSTALLED_APPS = ( + ... + 'ajax_select', # <- add the app + ... + ) + +Include the urls in your project:: + + # urls.py + from django.conf.urls import url, include + 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 = [ + + # place it at whatever base url you like + url(r'^ajax_select/', include(ajax_select_urls)), + + url(r'^admin/', include(admin.site.urls)), + ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + + +Write a LookupChannel to specify the models, search queries, formatting etc. and register it with a channel name:: + + from ajax_select import register, LookupChannel + from .models import Tag + + @register('tags') + class TagsLookup(LookupChannel): + + model = Tag + + def get_query(self, q, request): + return self.model.objects.filter(name=q) + + def format_item_display(self, item): + return u"%s" % item.name + +If you are using Django >= 1.7 then it will automatically loaded on startup. +For previous Djangos you can import them manually to your urls or views. + +Add ajax lookup fields in your admin.py:: + + from django.contrib import admin + from ajax_select import make_ajax_form + from .models import Document + + @admin.register(Document) + class DocumentAdmin(AjaxSelectAdmin): + + form = make_ajax_form(Document, { + # fieldname: channel_name + 'tags': 'tags' + }) + +Or add the fields to a ModelForm:: + + # forms.py + from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField + + class DocumentForm(ModelForm): + + class Meta: + model = Document + + category = AutoCompleteSelectField('categories', required=False, help_text=None) + tags = AutoCompleteSelectMultipleField('tags', required=False, help_text=None) + + # admin.py + from django.contrib import admin + from .forms import DocumentForm + from .models import Document + + @admin.register(Document) + class DocumentAdmin(AjaxSelectAdmin): + form = DocumentForm diff --git a/docs/source/LookupChannel.rst b/docs/source/LookupChannel.rst new file mode 100644 index 0000000000..97c3f12053 --- /dev/null +++ b/docs/source/LookupChannel.rst @@ -0,0 +1,77 @@ +Lookup Channels +=============== + +A LookupChannel defines how to search and how to format found objects for display in the interface. + +LookupChannels are registered with a "channel name" and fields refer to that name. + +You may have only one LookupChannel for a model, or you might define several for the same Model each with different queries, security policies and formatting styles. + +Custom templates can be created for channels. This enables adding extra javascript or custom UI. See :doc:`/Custom-Templates` + + +lookups.py +---------- + +Write your LookupChannel classes in a file name `yourapp/lookups.py` + +(note: inside your app, not your top level project) + +Use the @register decorator to register your LookupChannels by name + +`example/lookups.py`:: + + from ajax_select import register, LookupChannel + + @register('things') + class ThingsLookup(LookupChannel): + + model = Things + + def get_query(self, q, request): + return self.model.objects.filter(title__icontains=q).order_by('title') + + +If you are using Django >= 1.7 then all `lookups.py` in all of your apps will be automatically imported on startup. + +If Django < 1.7 then you can import each of your lookups in your views or urls. +Or you can register them in settings (see below). + +Customize +--------- + +.. automodule:: ajax_select.lookup_channel + :members: LookupChannel + :noindex: + + +settings.py +----------- + +Versions previous to 1.4 loaded the LookupChannels according to `settings.AJAX_LOOKUP_CHANNELS` + +This will still work. Your LookupChannels will continue to load without having to add them with the new @register decorator. + +Example:: + + # settings.py + + AJAX_LOOKUP_CHANNELS = { + # auto-create a channel named 'person' that searches by name on the model Person + # str: dict + 'person': {'model': 'example.person', 'search_field': 'name'} + + # specify a lookup to be loaded + # str: tuple + 'song': ('example.lookups', 'SongLookup'), + + # delete a lookup channel registered by an app/lookups.py + # str: None + 'users': None + } + + +One situation where it is still useful: if a resuable app defines a LookupChannel and you want to override that or turn it off. +Pass None as in the third example above. + +Anything in `settings.AJAX_LOOKUP_CHANNELS` overwrites anything previously registered by an app. diff --git a/docs/source/Media-assets.rst b/docs/source/Media-assets.rst new file mode 100644 index 0000000000..fdb2b64266 --- /dev/null +++ b/docs/source/Media-assets.rst @@ -0,0 +1,39 @@ +Media Assets +============ + +If `jQuery` or `jQuery.ui` are not already loaded on the page, then these will be loaded from CDN:: + + ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js + code.jquery.com/ui/1.10.3/jquery-ui.js + code.jquery.com/ui/1.10.3/themes/smoothness/jquery-ui.css + +If you want to prevent this and load your own then set:: + + # settings.py + AJAX_SELECT_BOOTSTRAP = False + + +Customizing the style sheet +--------------------------- + +By default `css/ajax_select.css` is included by the Widget's media. This specifies a simple basic style. + +If you would prefer not to have `css/ajax_select.css` loaded at all then you can implement your own `yourapp/static/ajax_select/css/ajax_select.css` and put your app before `ajax_select` in `INSTALLED_APPS`. + +Your version will take precedence and Django will serve your `css/ajax_select.css` + +The markup is simple and you can just add more css to override unwanted styles. + +The trashcan icon comes from the jQueryUI theme by the css classes:: + + "ui-icon ui-icon-trash" + +The following css declaration:: + + .results_on_deck .ui-icon.ui-icon-trash { } + +would be "stronger" than jQuery's style declaration and thus you could make trash look less trashy. + +The loading indicator is in `ajax_select/static/ajax_select/images/loading-indicator.gif` + +`yourapp/static/ajax_select/images/loading-indicator.gif` would override that. diff --git a/docs/source/Ordered-ManyToMany.rst b/docs/source/Ordered-ManyToMany.rst new file mode 100644 index 0000000000..e43677b4fa --- /dev/null +++ b/docs/source/Ordered-ManyToMany.rst @@ -0,0 +1,61 @@ + +Ordered ManyToMany fields +========================= + +When re-editing a previously saved model that has a ManyToMany field, the order of the recalled ids can be somewhat random. + +The user sees Arnold, Bosco, Cooly in the interface; saves; comes back later to edit it and he sees Bosco, Cooly, Arnold. So he files a bug report. + + +Problem +------- + +Given these models:: + + class Agent(models.Model): + name = models.CharField(blank=True, max_length=100) + + class Apartment(models.Model): + agents = models.ManyToManyField(Agent) + +When the AutoCompleteSelectMultipleField saves it does so by saving each relationship in the order they were added in the interface. + +But when Django ORM retrieves them, the order is not guaranteed:: + + # This query does not have a guaranteed order (especially on postgres) + # and certainly not the order that we added them. + apartment.agents.all() + + # This retrieves the joined objects in the order of the join table pk + # and thus gets them in the order they were added. + apartment.agents.through.objects.filter(apt=self).select_related('agent').order_by('id') + + +Solution +-------- + +A proper solution would be to use a separate Through model, an order field and the ability to drag the items in the interface to rearrange. But a proper Through model would also introduce extra fields and that would be out of the scope of ajax_selects. + +However this method will also work. + +Make a custom ManyToManyField:: + + from django.db import models + + class AgentOrderedManyToManyField(models.ManyToManyField): + + """This fetches from the join table, then fetches the Agents in the fixed id order.""" + + def value_from_object(self, object): + rel = getattr(object, self.attname) + qry = {self.related.var_name: object} + qs = rel.through.objects.filter(**qry).order_by('id') + aids = qs.values_list('agent_id', flat=True) + agents = dict((a.pk, a) for a in Agent.objects.filter(pk__in=aids)) + return [agents[aid] for aid in aids if aid in agents] + + class Agent(models.Model): + name = models.CharField(blank=True, max_length=100) + + class Apartment(models.Model): + agents = AgentOrderedManyToManyField() diff --git a/docs/source/Outside-of-Admin.rst b/docs/source/Outside-of-Admin.rst new file mode 100644 index 0000000000..04b78c3a97 --- /dev/null +++ b/docs/source/Outside-of-Admin.rst @@ -0,0 +1,12 @@ +Outside of the Admin +==================== + +ajax_selects does not need to be in a Django 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. + +In your view, after creating your ModelForm object:: + + autoselect_fields_check_can_add(form, model, request.user) + +This will check each widget and enable the green + for them iff the User has permission. diff --git a/docs/source/Upgrading.rst b/docs/source/Upgrading.rst new file mode 100644 index 0000000000..59ee6ed810 --- /dev/null +++ b/docs/source/Upgrading.rst @@ -0,0 +1,41 @@ +Upgrading from previous versions +================================ + +1.4 + +Custom Templates +---------------- + +Move your custom templates from:: + + yourapp/templates/channel_autocomplete.html + yourapp/templates/channel_autocompleteselect.html + yourapp/templates/channel_autocompleteselectmultiple.html + +to:: + + yourapp/templates/ajax_select/channel_autocomplete.html + yourapp/templates/ajax_select/channel_autocompleteselect.html + yourapp/templates/ajax_select/channel_autocompleteselectmultiple.html + +And change your extends from:: + + {% extends "autocompleteselect.html" %} + +to:: + + {% extends "ajax_select/autocompleteselect.html" %} + + +Removed options +--------------- + +make_ajax_field: + show_m2m_help -> show_help_text + +settings +-------- + +LookupChannels are still loaded from `settings.AJAX_LOOKUP_CHANNELS` as previously. + +If you are on Django >= 1.7 you may switch to using the @register decorator and you can remove that setting. diff --git a/docs/source/_static/add-song.png b/docs/source/_static/add-song.png new file mode 100644 index 0000000000000000000000000000000000000000..f533dfe96993c8b98d3094c96a1fdd27b7750cd1 GIT binary patch literal 10613 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 TJR0-wwnI)z8CWgxJ@EenBMcMW literal 0 HcmV?d00001 diff --git a/docs/source/_static/add.png b/docs/source/_static/add.png new file mode 100644 index 0000000000000000000000000000000000000000..d80c259c766e09f38b9648d7e13b552f47646073 GIT binary patch literal 7381 zcmZWu1yEc~wjSKwCBfa@eXyXxg1g(`Hdt^-2oNA>aCd^c>yNv;4(<+*e|O*RzIt7C zyZe6Ue8;M*tNY$aRb^Q;WMX6h0DvYh2UL3>8Qvc}MELi9d0~JY03hVHk&;rCmy#k^ zbpcu0*joSq)?ZIjtyDF-Qp27Uf1q;hP!v+^r@M{`FbND$lpxYaVB=Ed1VSZ}BS!=w z<{;9`P$L#)1w{lFW&OmB6@jdQJ?%X0?A{z7G~I8&?T3)lX+PUF{}N&V!+7BRy)`To zfTLPo(F(m+B2 z35caCOX-S~;HWwct8-Ts=0XZI;)2O3mcQtzWh`4vIfpfIcu)v`nQZTH+aUY!))HM> zgH+}U=!}DgPBsBxqIY{|CuFfVl1ah<2zBk9fXslhbM2K!iA|_3fL!(l?;j2h=Z;#+ zG8&jSDKV3`;_=RaTOv%%Ta}l61Al~u&#H*(gEBf&i&LXogVf}Ll(9syxY*AD`WbH` zraAF%9D9`p&9Y!Lm&Z#M=ykw<9%1ncIEJ73T5@!JSH-67Vr^{XIRFb_J0lRAR zP!l*I@vLvd4~f!biyU28k|%p0nBM@jr!4W!o14 z1W|v7j7joL;2prbA@~4pq}Z~MtnnX_U&P6=s9}YI$hySo$+@v)5J^Iqx-qcA0tFwZza9=K zn*OTNX2#HhFAaC^gg1__YFfZqP*_-AfI7zYz?1Do+DdTV;|J+8JMvW!dSZE^@CByz zmv8D^Yp1Qh?l@@?yhy-rOFN@_!zPkKVSoi3*gUx;VLZG~k;wa>nf!RpUSRe@O{Tp?Hi zRe|Sd=qPoHdFp%Wb4qbaCM+pzDg0SD+^gcMH7ECA%8ky4@dfvV^@WN}s1hB72@(ZS zohgXIW^!k8t)sWQ`o=u`x`(@Gx!-z}>)Q$Bn?{XLM39){0e9HDZ@MFWPYE z!jL4Dl#!g2q>)^eMCtbJ{?$FyZ50X*od;3_SAZVhX1=vz(?r-tg++bDJHa);#KEn> zQ6MPA12g;}n8(E+5My1VYVVFU_F})~tl+YwZlo!ru2-1(^j-cZ4H`8LH4}BFyh$E+ zeoo$S{$n22lrmC9@gXxnL@)t=Te)4!iWh$uRtSU3aD0MxU z<(g$uZ>d489^SqU5_5ZZ-f__JaRXBos|wc|PaD4x(;VAP*qU{L{oeZAKEiNtGxkum zZK5Ze%f!b-w%f7?t@~S#m`pM-{rf$(4UxB?3(YV^38O975>>M(Ji#`ZE7dQ261?RP zbeKu_^JHu|nIvKvS-+i_O}^UnlqrT_zhLYATri>XPiIbPrSqng7R%?h;F%SHM_MKIV9i^fkRKeMkf!Z0oAylF~#4cE8P73tu9ruH;jpIRmbZKW{vT!$raM z!5xK4gksz=ovlYWw3%`fP%75n? zWEhM$|854KEv>W#j6WNGiTON!^5k;|5}ojO+RonVIf|Jf;}O#WuUrF9=kD&3^&FuO z^Bdk#>DIh&CDWQ*TQS)eGEgS_Z?EUM-ID|JiJg^}Ue6^uZw4YwS3I5+BxRzu_SduaP)Y+kI2ekT5l&${=~`+BoE5Yzpi+fzro$-8xl z&zc#tkh9cvu)o)HvY~>(Kt+IuTO8Y?l zBHskbbV_vwP4DneVYc;4A8&l4VN%2EqUuy2=~E)5Uy1`Jv)0a2KbKyx#5+!hTl(#d zv-GoAmF<-$Iq)81-V!3TQVm6JDY|Dj|7n_BvH8fYqQw@)w-~Kaij5?{V{VHKX7nUJ7>-zlT?bMU)@X*Ia1N?X9JIo06}P2!1j(Xr7d<8N`FDv+56~CjX*? z3?DHOmsHH~^6EP@&n>yNG(9~A|DxlA*OguBcT}!bS$%YRavf+8V)|`okmeb8S0&Q> z#EfB$AwV`sb|r>&iWk7RNVi#6ru{nL!z*tye>-=baLc95&0lwY zWK4EqbMt(>{;w-5A$J`FE|S_*Sc4uKB+USv=+&s2o+C`$2w!SsB6&m0ltN!qKb7A( z(KT%*WV?jhzC%32BEwhduVHsQ>-=vao!~B;Y@JQ-IJimaYMKkO?@ID=$PCfHx`wcK zwRd#z7H}668xy=W4oaSFpCOXOl&M>~s1EWUYz%Zny!~ikG^O0B9Lx-Gq}0{96kNe5 zUlc%00Y{i=h|AV3{7+utu(Ac}L9VR|t#-9~3E)QA2&=4ld=MF#r;(M|6N{GK=rbD3 zH8fAKMlhb#j%0pMUN;RTVcgbd)5$H4t?KXmGOfTMYIVWSyz;C%qS73i0!kKXOCHf5 zQV*Ae+&SGi-D})D4^QVt=Y}TNa#Kp%zqn_rW%#z+e|5{=@$ZMapsWtg2?wHoBgGPB z^i^!szHgL^JV2U^H_hL&`0g^Ku2z^oKA4eMsVZFPYj_jnlW&=*TxS@)DZQ;*qwp)K z{OB0+fh4`Mk;ljIrl~%kAxq%iM_XYhI(hD%cW-p(c1CX*ROo5GG6Y#!O`QG>_FQj! z&gg!6BEuCCzCIY-HdBcLD`aFGyl>rP{32d^i%r=mJ&9Da<90^+{)_BO{Bzfk$D=vE zlkE>Us~|8fSsPf8=icAk$8B%)xCMjDl}wD)SNFM9u%rI`ifN0)2df3BOmUdP2HK@f%jC&KTm7~CqbCE>xfS6lDd-?r+34&3+ZB8yy2pXo;+N!Es*($eUQb~eXuyBi zhc}0%Md*VM4}}f!32zD&l34)i0#kq_y$8Lv{l)!EboA^NI5hEuLpcnR#n3j_4*JUi z9;(wrLKj2_iuA4`b{6yVtFDXc0+n~)cWzJosw%lEMLL?Yva37-cE55-TOh=c3Hsi5 z#y#gY4R{b(k*8zTP<@p$d!Hxvn_Q=+fsV)SkzN%Ao^hH86pJNa^HOD z*>qZcH*u=|G4|sYnaRi83~?3z+X}IlwT7t<{&RPy*Tur@rp?8;!+mu&S|jD}KVsUv zZys+-`OLP>aB{1bzqf-0ZZ2Q`Iwas?v&;-2Od)yYzdl^t?|OI*Ej0QV;CEkcRc&{8 z>$3dm3X*tM2YBARH|i=!7f+-BoL1EjL2u|I0JhG#pejCWF3n<+YiPXc#1@IYUSN2H zZP39U3(FA#Cjl^30E8NPtXKewVxC4PJU@VL1HLto3=!Id9G*}dd9X9D99F}meh%J; z9h3%*l3e#8kV_a1l&k?GK_3C_OpcZJLZBsr!5ZW<0lOrQpjZ;q1@J;QeA}C;G{&=+ zOkPhIMu`+xi=`YXf{%qW$7qt$%Ti5NRnJniX48O8eBPUuDMEvUWr$Nv@GBOsXSu_| zcSU8rUlcFd(^AQgF*72>fvA*dacJ>}NWI=bksLH<430pr==(tJus1QCST|~z;vjDE zCCWBqMkSyn#T}IjWoew!Agj69CE6v93tFhGb2cHpW&+YM(r!#taHN}938_1hJI)Ov zPgJT@d3L0=VY00IWWBYR8?zvcL5S!#N1$hRX7+mS7Y;8}HW_rYK<9o~Gc|VaWX@#i zGvhFJu4Eo&6fM}&u)6L?6PzlrF?M;6hpB9pD}Ug2r~EdmlVhWtKK?0T1->T&U(i#C z?AD=|bSvB%MrH_G=a&u8#?u8CtoQ~cJti}v9#U}#r6f;w@OjXAs2q6|wtM7hFaMUP zDO`0lKQNtcQ$B~Ioux}frQp?`pWGRA1MP&C~D+tl51eQ zpLP0Of-RDFNkS7TWvdH9)Lj_%={@N|^dx1iWx8byg1$|-Z|XW$wrwhKZv~Md=z;UX2YmN0-=JCKy+txqO783?S$>v?T+o<{)Yap z{1Hrx{mJYiv6DjoNkfyHaBs9FN5K@8!?U{0l*P1W%gveO;`f&O);20}PeNRGk!5QF zxjdm9wOo1r zvH0@G;LrLl?yltS@Gkr=(xuF$&HH+rMutW{MMl6q?J6s-AS~3*=db9^-6_+^nklc% zF3V2O(k~Uxr!3macLC;q!K<__NiAL~Y0fLi!_U)4z z$O!&u-Du4Sn0XMt0zV%g#FEdv%~Gl7sGFkqp^CU3zY4vsqQS75qju418l=#G_NUFh z#oo#|$%@Z<97JXr(C5F}wXzx8L#UXdIN(8-9$z|bQz&4Cwr{^*$lA)f$*NQlTiRw< zeDpo7S+8ENqa_er3ob<%>*?7J8ptWE-lrIMW`nC#tTY5coe`dSko}3tRQWxwVuY|h z-Hzff;~)M)+fd{%w0h}VdOdet-u5c_8u{u8(+HCT?Fgp^6AYvGzB=@RE8xw7{enm& zRVfT9lBBS*KV~QOV48e1N$QE)G~B$wIzU{H=$G@&3ov)ZRnU-|8MVGWI5?;vTuaVN z9;i007BUOp#@)K@Q|h%FAd7xWFphKLHHkhPvzMc$4Wss;zM##gMx*|VW}ikSpD;h6 z&{xG>!9g)MPda~fy?^0Xn}cu5>+PQtF%Pd(Yvp}86-Baz;@Yc?u?N(r@6C!XmWq4v z%uQu5I+>mV$0h5d&1p?wc0HaJelX8~(Ei@XuOcnCihHU1bH_9%(aS=ODt_xjeqvY9 zS3Z{(ml%6&_+EG`thh`gOzG=q^&KaH=U>|pMDd#N>ptKLBzrn-#_ne?htr@Fd@vOF zb2Ipo8zrkkDn$ZBZ?o5s zXGmS@L2C6k4+79E<6K5tuLr~a)aCB^Uu^G(C&ziFc_Zr$M|$gfZ<@_vjodHW@uf27 z3xnen zfQ-&m4~m+p+zRvJmr~Y4k3XI1LVg@&G?93D9%nqYwSP>@Xt_f>YY_GI9Pqcz2QDOlv)4LKKr`!3W7m`xeDe8b{(h897PR9Ya>G^^5?@A zzC87wxEdmmU|VZL^%vkF78Df07v+6ogv=JsJ6CqJ%KO%EXYjW3#$Dk1q;W;2G5ZcY zo*v-dX*_`A?h>c}OnHj4={*ctZ6XODi19)W*~)ddYdr ze<|&oZwIZd7vOK>2Qcq4tN&8?LdXv(;ee|vj;GkSYFdJA}Y`~TqE*^3y5hV;WYQXnQgAhorri60t3OA;6j zPD?w2q@|G<0mL7noI1|OS3B1|XOo>y6XQ%X|5A@Zc-vK8>wxk(#vlzyHS_}jpwetK z-oFkhDGHi_99T`vL8cb0UJg$0{}%uNVK2e=se^@^3AvYpy`!t3mk8xQGz8!0f5U8) z)FssUyGMSp)2p|o~$a}s1@^Yrv& z_2gm&xmdAr2nYzUv2(I)b5;hK2cD8@pey0lmjS8yTcv;wM18p2E z99`dih;nlX|3m%%3I5~ozlb{jA#(m7;(rPLMHFWHJN*Aj=byFu2l`$tQDkAZe-~a9 znb6_E2mpYtlLty@$f2Dit}CcJ6ZVUZjs8+5jmWB38#c}viTEiOqE^I;{0wt!o8&$j z?Y^mG04qU&b)W=mu+Z0v7$hSXnZV8MUPX>&ku2F{Yp9sEyjTeVkByD(c|1!8!M9hr zuAM!#o`Ew&&cOWA0U_1nqgCS%4A-wHKl?`$T4&a)(fwV`%vZhX!eht3eJRPm(pNIn zi6XkAzSvW*ok_c@x^`Be(OcGy3wX1ITE`w24R>1t5?;X*8T~jkKAiYP%~!TUp=!%( z%n3mo18Q8#obo2BWRu-YC8s}+K zIJOPufG_K~r+5+-3v!fjNt$wJTH5ZZAGfQOLDQ#<(7Gxj?nJ~qirVrF^9PoeVQ${b?m)%M^CZ~3uMkRWq599FKG4|TNq}j{#aPkB~Hx{3?<_)K7fay zxo28-OS>e^ErOKaxRF!36JjN)kbw6c8IGWX*}A%{rn?j&7u7Vou=MLvYz$GT9P~vd zeci|50?Sr&MGA9lp=JXn@{~oMrmwBRpR-LRix~7+3t+p&h_IRVt?hF9&?}NCby;e- z)vdUyIE7-wG!#ZJN&RXd%EV00hsN7(G30s-hC$L|Frp;$mAq4@nR}<(4V0^$91eKHhX*B=2YsH7YXN9meZ>>R5FzO_ zzZTbZ%cfRw`8q4%XB1}ByIyaldy8%ry{Nj_1tkSIng=e0Pn3KTDLnC|zYbP-X^P`J y=(0~hXBiU;V%LE?j*$UbJ70gcB1TM+F4 literal 0 HcmV?d00001 diff --git a/docs/source/_static/kiss-all.png b/docs/source/_static/kiss-all.png new file mode 100644 index 0000000000000000000000000000000000000000..b9522c8cc46b2bf7c69424302782c66295d5a880 GIT binary patch literal 26415 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

OF}bkb&;657)zD(3t>+?t$D;(yU|&md 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 Px)%yED$?M0W}p5G0@E-4 literal 0 HcmV?d00001 diff --git a/docs/source/_static/kiss.png b/docs/source/_static/kiss.png new file mode 100644 index 0000000000000000000000000000000000000000..cddc02f2f80653422c3c2f1f1cd7308ff1e0b18b GIT binary patch literal 28714 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 literal 0 HcmV?d00001 diff --git a/docs/source/ajax_select.rst b/docs/source/ajax_select.rst new file mode 100644 index 0000000000..5ee8a103ad --- /dev/null +++ b/docs/source/ajax_select.rst @@ -0,0 +1,86 @@ +ajax_select package +=================== + +Submodules +---------- + +ajax_select.admin module +------------------------ + +.. automodule:: ajax_select.admin + :members: + :undoc-members: + :show-inheritance: + +ajax_select.apps module +----------------------- + +.. automodule:: ajax_select.apps + :members: + :undoc-members: + :show-inheritance: + +ajax_select.fields module +------------------------- + +.. automodule:: ajax_select.fields + :members: + :undoc-members: + :show-inheritance: + +ajax_select.helpers module +-------------------------- + +.. automodule:: ajax_select.helpers + :members: + :undoc-members: + :show-inheritance: + +ajax_select.lookup_channel module +--------------------------------- + +.. automodule:: ajax_select.lookup_channel + :members: + :undoc-members: + :show-inheritance: + +ajax_select.models module +------------------------- + +.. automodule:: ajax_select.models + :members: + :undoc-members: + :show-inheritance: + +ajax_select.registry module +--------------------------- + +.. automodule:: ajax_select.registry + :members: + :undoc-members: + :show-inheritance: + +ajax_select.urls module +----------------------- + +.. automodule:: ajax_select.urls + :members: + :undoc-members: + :show-inheritance: + +ajax_select.views module +------------------------ + +.. automodule:: ajax_select.views + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: ajax_select + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/conf.py b/docs/source/conf.py similarity index 91% rename from docs/conf.py rename to docs/source/conf.py index d812087209..75b55a1934 100644 --- a/docs/conf.py +++ b/docs/source/conf.py @@ -18,12 +18,14 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +sys.path.insert(0, os.path.abspath('../../')) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.example.settings') + # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom @@ -31,6 +33,7 @@ extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.coverage', + 'sphinx.ext.napoleon' ] # Add any paths that contain templates here, relative to this directory. @@ -40,7 +43,7 @@ source_suffix = '.rst' # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' @@ -60,13 +63,13 @@ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -74,27 +77,27 @@ # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # -- Options for HTML output ---------------------------------------------- @@ -113,26 +116,26 @@ # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -142,28 +145,28 @@ # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -#html_extra_path = [] +# html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. #html_split_index = False diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000000..9364f094c5 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,94 @@ +.. django-ajax-selects documentation master file, created by + sphinx-quickstart on Tue Nov 3 15:23:14 2015. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +django-ajax-selects +=================== + +Edit `ForeignKey`, `ManyToManyField` and `CharField` in Django Admin using jQuery UI AutoComplete. + +.. image:: _static/kiss.png + +.. image:: _static/kiss-all.png + + +Quick Usage +----------- + +Define a lookup channel:: + + # yourapp/lookups.py + from ajax_select import register, LookupChannel + from .models import Tag + + @register('tags') + class TagsLookup(LookupChannel): + + model = Tag + + def get_query(self, q, request): + return self.model.objects.filter(name__icontains=q).order_by('name')[:50] + + def format_item_display(self, item): + return u"%s" % item.name + +Add field to a form:: + + # yourapp/forms.py + class DocumentForm(ModelForm): + + class Meta: + model = Document + + tags = AutoCompleteSelectMultipleField('tags') + + +Fully customizable +------------------ + +- search query +- query other resources besides Django ORM +- format results with HTML +- custom styling +- customize security policy +- customize UI +- integrate with other UI elements on page using the javascript API + +Assets included by default +------------------- + +- ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js +- code.jquery.com/ui/1.10.3/jquery-ui.js +- code.jquery.com/ui/1.10.3/themes/smoothness/jquery-ui.css + +Compatibility +------------- + +- Django >=1.5, <=1.9 +- Python >=2.7, <=3.4 + +Index +----- + +.. toctree:: + :maxdepth: 2 + + Install + LookupChannel + Admin + Forms + Admin-add-popup + Media-assets + Custom-Templates + jQuery-plugin-options + jQuery-events + Ordered-ManyToMany + Outside-of-Admin + Example-app + Upgrading + Contributing + modules + +* :ref:`genindex` +* :ref:`modindex` diff --git a/docs/source/jQuery-events.rst b/docs/source/jQuery-events.rst new file mode 100644 index 0000000000..75342023d0 --- /dev/null +++ b/docs/source/jQuery-events.rst @@ -0,0 +1,71 @@ +jQuery events +============= + +Triggers are a great way to keep code clean and untangled. + +see: http://docs.jquery.com/Events/trigger + +If you need integrate with other javascript on your page, you can write a custom template for your channel and hook into the 'added' and 'killed' events. + +'killed' means 'removed' (silly name, sorry) + + +Two triggers/signals are sent: 'added' and 'killed'. These are sent to the `$("#{{html_id}}_on_deck")` element. That is the area that surrounds the currently selected items. + +Extend the template, implement the extra_script block and bind functions that will respond to the trigger: + +AutoCompleteSelectMultipleField:: + + // yourapp/templates/ajax_select/autocompleteselectmultiple_{channel}.html + + {% block extra_script %} + + {% endblock %} + +AutoCompleteSelectField:: + + // yourapp/templates/ajax_select/autocompleteselect_{channel}.html + + {% block extra_script %} + + {% endblock %} + +AutoCompleteField (simple text field):: + + // yourapp/templates/ajax_select/autocomplete_{channel}.html + + {% block extra_script %} + + {% endblock %} + +There is no remove with this one as there is no kill/delete button in a simple text auto-complete. +The user may clear the text themselves but there is no javascript involved. Its just a text field. + +Re-initializing +--------------- + +If you are dynamically adding forms to the page (eg. by loading a page using Ajax) then you can trigger the newly added ajax selects widgets to be activated:: + + $(window).trigger('init-autocomplete'); diff --git a/docs/source/jQuery-plugin-options.rst b/docs/source/jQuery-plugin-options.rst new file mode 100644 index 0000000000..4fa5cf76d1 --- /dev/null +++ b/docs/source/jQuery-plugin-options.rst @@ -0,0 +1,31 @@ +jQuery Plugin Options +===================== + + +https://jqueryui.com/autocomplete/ + +- minLength + The minimum number of characters a user must type before a search is performed. + min_length is also an attribute of the LookupChannel so you need to also set it there. +- autoFocus +- delay +- disabled +- position +- source - By default this is the ajax_select view. + Setting this would overide the normal url used for lookups (`ajax_select.views.ajax_lookup`). This could be used to add URL custom query params. + +See http://docs.jquery.com/UI/Autocomplete#options + + +Setting plugin options:: + + from ajax_select.fields import AutoCompleteSelectField + + class DocumentForm(ModelForm): + + category = AutoCompleteSelectField('category', + required=False, + help_text=None, + plugin_options={'autoFocus': True, 'minLength': 4}) + +This Python dict will be passed as JavaScript to the plugin. diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000000..2af01dc880 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,7 @@ +ajax_select +=========== + +.. toctree:: + :maxdepth: 4 + + ajax_select From ec7579fc7f8c4e60705aee00eddce6226f866a73 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Fri, 6 Nov 2015 18:26:27 +0100 Subject: [PATCH 72/85] pretty docstrings --- ajax_select/fields.py | 37 ++++++----- ajax_select/helpers.py | 65 +++++++++++++------ ajax_select/lookup_channel.py | 119 +++++++++++++++++++++++++++------- ajax_select/registry.py | 62 ++++++++++-------- ajax_select/views.py | 29 ++++++--- 5 files changed, 215 insertions(+), 97 deletions(-) diff --git a/ajax_select/fields.py b/ajax_select/fields.py index 9a97284e56..fd3e663ce2 100644 --- a/ajax_select/fields.py +++ b/ajax_select/fields.py @@ -39,7 +39,7 @@ def _media(self): class AutoCompleteSelectWidget(forms.widgets.TextInput): - """ widget to select a model and return it as text """ + """Widget to search for a model and return it as text for use in a CharField.""" media = property(_media) @@ -59,7 +59,6 @@ def __init__(self, self.show_help_text = show_help_text def render(self, name, value, attrs=None): - value = value or '' final_attrs = self.build_attrs(attrs) self.html_id = final_attrs.pop('id', name) @@ -104,7 +103,7 @@ def id_for_label(self, id_): class AutoCompleteSelectField(forms.fields.CharField): - """ form field to select a model for a ForeignKey db field """ + """Form field to select a Model for a ForeignKey db field.""" channel = None @@ -149,7 +148,7 @@ def has_changed(self, initial_value, data_value): class AutoCompleteSelectMultipleWidget(forms.widgets.SelectMultiple): - """ widget to select multiple models """ + """Widget to select multiple models for a ManyToMany db field.""" media = property(_media) @@ -350,8 +349,7 @@ def render(self, name, value, attrs=None): class AutoCompleteField(forms.CharField): """ - Field uses an AutoCompleteWidget to lookup possible completions - using a channel and stores raw text (not a foreign key) + A CharField that uses an AutoCompleteWidget to lookup matching and stores the result as plain text. """ channel = None @@ -376,30 +374,33 @@ def __init__(self, channel, *args, **kwargs): #################################################################################### -def _check_can_add(self, user, model): +def _check_can_add(self, user, related_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 + Check if the User can create a related_model. + + If the LookupChannel implements check_can_add() then use this. + + Else uses Django's default permission system. + + If it can add, then enable the widget to show the green + link """ lookup = registry.get(self.channel) if hasattr(lookup, 'can_add'): - can_add = lookup.can_add(user, model) + can_add = lookup.can_add(user, related_model) else: - ctype = ContentType.objects.get_for_model(model) + ctype = ContentType.objects.get_for_model(related_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() + 'app_label': related_model._meta.app_label, + 'model': related_model._meta.object_name.lower() }) 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 + Check the form's fields for any autoselect fields and enable their + widgets with green + button if permissions allow then to create the related_model. """ for name, form_field in form.declared_fields.items(): if isinstance(form_field, (AutoCompleteSelectMultipleField, AutoCompleteSelectField)): @@ -408,7 +409,7 @@ def autoselect_fields_check_can_add(form, model, user): def plugin_options(lookup, channel_name, widget_plugin_options, initial): - """ Make a JSON dumped dict of all options for the jquery ui plugin itself """ + """ Make a JSON dumped dict of all options for the jQuery ui plugin.""" po = {} if initial: po['initial'] = initial diff --git a/ajax_select/helpers.py b/ajax_select/helpers.py index ac7a79f5bb..30157dd96e 100644 --- a/ajax_select/helpers.py +++ b/ajax_select/helpers.py @@ -6,17 +6,34 @@ def make_ajax_form(model, fieldlist, superclass=ModelForm, show_help_text=False, **kwargs): - """ Creates a ModelForm subclass with autocomplete fields + """Creates a ModelForm subclass with AutoComplete fields. - usage: - class YourModelAdmin(Admin): - ... - form = make_ajax_form(YourModel,{'contacts':'contact','author':'contact'}) + Args: + model (type): Model class for which you are making the ModelForm + fieldlist (dict): {field_name -> channel_name, ...} + superclass (type): optional ModelForm superclass + show_help_text (bool): suppress or show the widget help text + + Returns: + ModelForm: a ModelForm suitable for use in an Admin + + Usage:: + + from django.contrib import admin + from ajax_select import make_ajax_form + from yourapp.models import YourModel + + @admin.register(YourModel) + class YourModelAdmin(Admin): + + form = make_ajax_form(YourModel, { + 'contacts': 'contact', # ManyToManyField + 'author':'contact' # ForeignKeyField + }) + + Where 'contacts' is a ManyToManyField specifying to use the lookup channel 'contact' + and 'author' is a ForeignKeyField specifying here to also use the same lookup channel 'contact' - where - 'contacts' is a ManyToManyField specifying to use the lookup channel 'contact' - and - 'author' is a ForeignKeyField specifying here to also use the lookup channel 'contact' """ # will support previous arg name for several versions before deprecating # TODO: time to go @@ -46,29 +63,35 @@ class Meta: return TheForm -def make_ajax_field(model, model_fieldname, channel, show_help_text=False, **kwargs): - """ Makes a single autocomplete field for use in a Form +def make_ajax_field(related_model, fieldname_on_model, channel, show_help_text=False, **kwargs): + """Makes an AutoComplete field for use in a Form. - optional args: - help_text - default is the model db field's help_text. - None will disable all help text - label - default is the model db field's verbose name - required - default is the model db field's (not) blank + Args: + related_model (Model): model of the related object + fieldname_on_model (str): field name on the model being edited + channel (str): channel name of a registered LookupChannel + show_help_text (bool): show or supress help text below the widget + Django admin will show help text below the widget, but not for ManyToMany inside of admin inlines + This setting will show the help text inside the widget itself. + kwargs: optional args - show_help_text - - Django will show help text below the widget, but not for ManyToMany inside of admin inlines - This setting will show the help text inside the widget itself. - """ # will support previous arg name for several versions before deprecating # TODO remove this now if 'show_m2m_help' in kwargs: show_help_text = kwargs.pop('show_m2m_help') + - help_text: default is the model db field's help_text. + None will disable all help text + - label: default is the model db field's verbose name + - required: default is the model db field's (not) blank + Returns: + (AutoCompleteField, AutoCompleteSelectField, AutoCompleteSelectMultipleField): field + """ from ajax_select.fields import AutoCompleteField, \ AutoCompleteSelectMultipleField, \ AutoCompleteSelectField - field = model._meta.get_field(model_fieldname) + field = related_model._meta.get_field(fieldname_on_model) if 'label' not in kwargs: kwargs['label'] = _(capfirst(force_text(field.verbose_name))) diff --git a/ajax_select/lookup_channel.py b/ajax_select/lookup_channel.py index 7c886fb0bc..11adc72e65 100644 --- a/ajax_select/lookup_channel.py +++ b/ajax_select/lookup_channel.py @@ -5,57 +5,130 @@ class LookupChannel(object): - """Subclass this, setting model and overiding the methods below to taste""" + """ + Subclass this, setting the model and implementing methods to taste. + + Attributes: + model (Model): The Django Model that this lookup channel will search for. + plugin_options (dict): Options passed to jQuery UI plugin that are specific to this channel. + min_length (int): Minimum number of characters user types before a search is initiated. + + This is passed to the jQuery plugin_options. + It is used in jQuery's UI when filtering results from its own cache. + + It is also used in the django view to prevent expensive database queries. + Large datasets can choke if they search too often with small queries. + Better to demand at least 2 or 3 characters. + """ model = None plugin_options = {} min_length = 1 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 + """ + Return a QuerySet searching for the query string `q`. + + Note that you may return any iterable so you can return a list or even use yield and turn this + method into a generator. + + Args: + q (str, unicode): The query string to search for. + request (Request): This can be used to customize the search by User or to use additional GET variables. + + Returns: + (QuerySet, list, generator): iterable of related_models """ kwargs = {"%s__icontains" % self.search_field: q} return self.model.objects.filter(**kwargs).order_by(self.search_field) def get_result(self, obj): - """ The text result of autocompleting the entered query """ + """The text result of autocompleting the entered query. + + For a partial string that the user typed in, each matched result is here converted to the fully completed text. + + This is currently displayed only for a moment in the text field after the user has selected the item. + Then the item is displayed in the item_display deck and the text field is cleared. + + Args: + obj (Model): + Returns: + str: The object as string + """ return escape(force_text(obj)) def format_match(self, obj): - """ (HTML) formatted item for displaying item in the dropdown """ + """(HTML) Format item for displaying in the dropdown. + + Args: + obj (Model): + Returns: + str: formatted string, may contain HTML. + """ return escape(force_text(obj)) def format_item_display(self, obj): - """ (HTML) formatted item for displaying item in the selected deck area """ + """ (HTML) format item for displaying item in the selected deck area. + + Args: + obj (Model): + Returns: + str: formatted string, may contain HTML. + """ return escape(force_text(obj)) 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 - # which is not guaranteed to be the same order they were in when you last edited - # see OrdredManyToMany.md + """This is used to retrieve the currently selected objects for either ManyToMany or ForeignKey. + + Note that the order of the ids supplied for ManyToMany fields is dependent on how the + objects manager fetches it. + ie. what is returned by `YourModel.{fieldname}_set.all()` + + In most situations (especially postgres) this order is indeterminate -- not the order that you originally + added them in the interface. + See :doc:`/Ordered-ManyToMany` for a solution to this. + + Args: + ids (list): list of primary keys + Returns: + list: list of Model objects + """ + # return objects in the same order as passed in here pk_type = self.model._meta.pk.to_python - ids = [pk_type(id) for id in ids] + ids = [pk_type(pk) for pk in ids] things = self.model.objects.in_bulk(ids) return [things[aid] for aid in ids if aid in things] - 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 + def can_add(self, user, other_model): + """Check if the user has permission to add a ForeignKey or M2M model. + + This enables the green popup + on the widget. + Default implentation is the standard django permission check. + + Args: + user (User) + other_model (Model): the ForeignKey or M2M model to check if the User can add. + Returns: + bool """ from django.contrib.contenttypes.models import ContentType - ctype = ContentType.objects.get_for_model(argmodel) + ctype = ContentType.objects.get_for_model(other_model) return user.has_perm("%s.add_%s" % (ctype.app_label, ctype.model)) 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?") - instead of raising PermissionDenied (401 response) - """ + """By default only request.user.is_staff have access. + + This ensures that nobody can get your data by simply knowing the lookup URL. + + This is called from the ajax_lookup view. + + Public facing forms (outside of the Admin) should implement this to allow + non-staff to use this LookupChannel. + + Args: + request (Request) + Raises: + PermissionDenied + """ if not request.user.is_staff: raise PermissionDenied diff --git a/ajax_select/registry.py b/ajax_select/registry.py index c15e56f749..d036f8c682 100644 --- a/ajax_select/registry.py +++ b/ajax_select/registry.py @@ -5,10 +5,10 @@ class LookupChannelRegistry(object): """ - Registry for lookup channels activated for your django project ("site"). + Registry for LookupChannels activated for your django project. This includes any installed apps that contain lookup.py modules (django 1.7+) - and any lookups that are explicitly declared in settings.AJAX_LOOKUP_CHANNELS + and any lookups that are explicitly declared in `settings.AJAX_LOOKUP_CHANNELS` """ _registry = {} @@ -25,10 +25,13 @@ def load_channels(self): self.register(settings.AJAX_LOOKUP_CHANNELS) def register(self, lookup_specs): - """ - lookup_specs is a dict with one or more LookupChannel specifications - {'channel': ('module.of.lookups', 'MyLookupClass')} - {'channel': {'model': 'MyModelToBeLookedUp', 'search_field': 'field_to_search'}} + """Register a set of lookup definitions. + + Args: + lookup_specs (dict): One or more LookupChannel specifications + - `{'channel': LookupChannelSubclass}` + - `{'channel': ('module.of.lookups', 'MyLookupClass')}` + - `{'channel': {'model': 'MyModelToBeLookedUp', 'search_field': 'field_to_search'}}` """ for channel, spec in lookup_specs.items(): if spec is None: # unset @@ -38,10 +41,15 @@ def register(self, lookup_specs): self._registry[channel] = spec def get(self, channel): - """ - Find the LookupChannel for the named channel. - - @param channel {string} - name that the lookup channel was registered at + """Find the LookupChannel class for the named channel and instantiate it. + + Args: + channel (string): - name that the lookup channel was registered at + Returns: + LookupChannel + Raises: + ImproperlyConfigured - if channel is not found. + Exception - invalid lookup_spec was stored in registery """ from ajax_select import LookupChannel @@ -76,9 +84,13 @@ def is_registered(self, channel): return channel in self._registry def make_channel(self, app_model, arg_search_field): - """ - app_model: app_name.ModelName - arg_search_field: the field to search against and to display in search results + """Automatically make a LookupChannel. + + Args: + app_model (str): app_name.ModelName + arg_search_field (str): the field to search against and to display in search results + Returns: + LookupChannel """ from ajax_select import LookupChannel app_label, model_name = app_model.split(".") @@ -95,9 +107,7 @@ class MadeLookupChannel(LookupChannel): def get_model(app_label, model_name): - """ - Get the model from 'app_label' 'ModelName' - """ + """Loads the model given an 'app_label' 'ModelName'""" try: # django >= 1.7 from django.apps import apps @@ -118,21 +128,19 @@ def can_autodiscover(): def register(channel): - """ - Decorator to register a LookupClass + """Decorator to register a LookupClass. + Example:: + from ajax_select import LookupChannel, register - ``` - from ajax_select import LookupChannel, register + @register('agent') + class AgentLookup(LookupClass): - @register('agent') - class AgentLookup(LookupClass): + def get_query(self): + ... + def format_item(self): + ... - def get_query(self): - ... - def format_item(self): - ... - ``` """ def _wrapper(lookup_class): diff --git a/ajax_select/views.py b/ajax_select/views.py index 5bfe90a3ba..65c1667c12 100644 --- a/ajax_select/views.py +++ b/ajax_select/views.py @@ -10,7 +10,15 @@ def ajax_lookup(request, channel): - """ this view supplies results for foreign keys and many to many fields """ + """Load the named lookup channel and lookup matching models. + + GET or POST should contain 'term' + + Returns: + HttpResponse - JSON: `[{pk: value: match: repr:}, ...]` + Raises: + PermissionDenied - depending on the LookupChannel's implementation of check_auth + """ # it should come in as GET unless global $.ajaxSetup({type:"POST"}) has been set # in which case we'll support POST @@ -46,15 +54,20 @@ def ajax_lookup(request, channel): def add_popup(request, app_label, model): - """ this presents the admin site popup add view (when you click the green +) + """Presents the admin site popup add view (when you click the green +). + + It serves the admin.add_view under a different URL and does some magic fiddling + to close the popup window after saving and call back to the opening window. + + make sure that you have added ajax_select.urls to your urls.py:: + (r'^ajax_select/', include('ajax_select.urls')), - make sure that you have added ajax_select.urls to your urls.py: - (r'^ajax_select/', include('ajax_select.urls')), - this URL is expected in the code below, so it won't work under a different path + this URL is expected in the code below, so it won't work under a different path + TODO - check if this is still true. - this view then hijacks the result that the django admin returns - and instead of calling django's dismissAddAnontherPopup(win,newId,newRepr) - it calls didAddPopup(win,newId,newRepr) which was added inline with bootstrap.html + This view then hijacks the result that the django admin returns + and instead of calling django's dismissAddAnontherPopup(win,newId,newRepr) + it calls didAddPopup(win,newId,newRepr) which was added inline with bootstrap.html """ themodel = get_model(app_label, model) From c6c4d3a46ff0bf9328d127fbc358d83db1d5a2e6 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Fri, 6 Nov 2015 18:26:50 +0100 Subject: [PATCH 73/85] ignore docs/_build --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 86c5e6fb82..2fff34657d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ example/AJAXSELECTS/* .tox htmlcov .coverage +docs/_build From 378026e39e7eaca414e829dd43af3728c7253d94 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Fri, 6 Nov 2015 18:27:23 +0100 Subject: [PATCH 74/85] moved to docs/ --- OrderedManyToMany.md | 69 -------------------------------------------- 1 file changed, 69 deletions(-) delete mode 100644 OrderedManyToMany.md diff --git a/OrderedManyToMany.md b/OrderedManyToMany.md deleted file mode 100644 index e10e559d20..0000000000 --- a/OrderedManyToMany.md +++ /dev/null @@ -1,69 +0,0 @@ - -Ordered ManyToMany fields without a full Through model -====================================================== - -When re-editing a previously saved model that has a ManyToMany field, the order of the recalled ids can be somewhat random. -The user sees Arnold, Bosco, Cooly in the interface, saves, comes back later to edit it and he sees Bosco, Cooly, Arnold. So he files a bug report. - -A proper solution would be to use a separate Through model, an order field and the ability to drag the items in the interface to rearrange. But a proper Through model would also introduce extra fields and that would be out of the scope of ajax_selects. Maybe some future version. - -It is possible however to offer an intuitive experience for the user: save them in the order they added them and the next time they edit it they should see them in same order. - -Problem -------- - - class Agent(models.Model): - name = models.CharField(blank=True, max_length=100) - - class Apartment(models.Model): - agents = models.ManyToManyField(Agent) - -When the AutoCompleteSelectMultipleField saves it does so by saving each relationship in the order they were added in the interface. - - # this query does not have a guaranteed order (especially on postgres) - # and certainly not the order that we added them - apartment.agents.all() - - - # this retrieves the joined objects in the order of their id (the join table id) - # and thus gets them in the order they were added - apartment.agents.through.objects.filter(apt=self).select_related('agent').order_by('id') - - -Temporary Solution ------------------- - - class AgentOrderedManyToManyField(models.ManyToManyField): - """ regardless of using a through class, - only the Manager of the related field is used for fetching the objects for many to many interfaces. - with postgres especially this means that the sort order is not determinable other than by the related field's manager. - - this fetches from the join table, then fetches the Agents in the fixed id order - the admin ensures that the agents are always saved in the fixed id order that the form was filled out with - """ - def value_from_object(self,object): - from company.models import Agent - rel = getattr(object, self.attname) - qry = {self.related.var_name:object} - qs = rel.through.objects.filter(**qry).order_by('id') - aids = qs.values_list('agent_id',flat=True) - agents = dict( (a.pk,a) for a in Agent.objects.filter(pk__in=aids) ) - try: - return [agents[aid] for aid in aids ] - except KeyError: - raise Exception("Agent is missing: %s > %s" % (aids,agents)) - - class Apartment(models.Model): - agents = AgentOrderedManyToManyField() - - - class AgentLookup(object): - - def get_objects(self,ids): - # now that we have a dependable ordering - # we know the ids are in the order they were originally added - # return models in original ordering - ids = [int(id) for id in ids] - agents = dict( (a.pk,a) for a in Agent.objects.filter(pk__in=ids) ) - return [agents[aid] for aid in ids] - From 4b214bcfd14459829d168513f0f5baba6dee1af1 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Fri, 6 Nov 2015 18:42:55 +0100 Subject: [PATCH 75/85] link to RTD and contributors graph --- README.md | 606 +++++------------------------------------------------- 1 file changed, 47 insertions(+), 559 deletions(-) diff --git a/README.md b/README.md index 1a7ec9519f..86098249ce 100644 --- a/README.md +++ b/README.md @@ -1,599 +1,87 @@ -Enables editing of `ForeignKey`, `ManyToMany` and `CharField` using jQuery UI Autocomplete. +Edit `ForeignKey`, `ManyToManyField` and `CharField` in Django Admin using jQuery UI AutoComplete. -User experience -=============== + -selecting: + - +Documentation +------------------ -selected: +http://django-ajax-selects.readthedocs.org/en/latest/ - -[Note: screen shots are from the older version. Styling has changed slightly] -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 - -Features -======== - -+ 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 -+ Django 1.6+ - - -Quick Installation -================== - -Get it - - pip install django-ajax-selects -or - download or checkout the distribution - - -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 = { - # simple: search Person.objects.filter(name__icontains=q) - 'person' : {'model': 'example.person', 'search_field': 'name'}, - # define a custom lookup channel - 'song' : ('example.lookups', 'SongLookup') - } - - -In your urls.py: - - from django.conf.urls import patterns, include - - from django.contrib import admin - from ajax_select import urls as ajax_select_urls - - admin.autodiscover() - - urlpatterns = patterns('', - # include the lookup urls - (r'^admin/lookups/', include(ajax_select_urls)), - (r'^admin/', include(admin.site.urls)), - ) - - -In your admin.py: - - from django.contrib import admin - from ajax_select import make_ajax_form - from ajax_select.admin import AjaxSelectAdmin - from example.models import Person, Label - - class PersonAdmin(admin.ModelAdmin): - pass - admin.site.register(Person, PersonAdmin) - - class LabelAdmin(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 register, LookupChannel - - @register('songs') - class SongLookup(LookupChannel): - - model = Song - - def get_query(self, q, request): - return Song.objects.filter(title__icontains=q).order_by('title') - - -NOT SO QUICK INSTALLATION -========================= - -Things that can be customized: - -+ 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) -+ 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'. - -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: - - - - - - -
Database fieldForm fieldForm widget
models.CharFieldAutoCompleteFieldAutoCompleteWidget
models.ForeignKeyAutoCompleteSelectFieldAutoCompleteSelectWidget
models.ManyToManyFieldAutoCompleteSelectMultipleFieldAutoCompleteSelectMultipleWidget
- -Generally the helper functions documented below can be used to generate a complete form or an individual field (with widget) for a form. In rare cases you might need to specify the ajax form field explicitly in your Form. - -Example App -=========== - -See the example app for a full working admin site with many variations and comments. It installs quickly using virtualenv and sqllite and comes fully configured. - - -settings.py +Quick Usage ----------- -#### AJAX_LOOKUP_CHANNELS - -Defines the available lookup channels. - -+ channel_name : {'model': 'app.modelname', 'search_field': 'name_of_field_to_search'} -> This will create a channel automatically - - channel_name : ( 'app.lookups', 'YourLookup' ) - This points to a custom Lookup channel name YourLookup in app/lookups.py - - AJAX_LOOKUP_CHANNELS = { - # channel : dict with settings to create a channel - 'person': {'model': 'example.person', 'search_field': 'name'}, - - # channel: ( module.where_lookup_is, ClassNameOfLookup ) - 'song': ('example.lookups', 'SongLookup'), - } - -#### AJAX_SELECT_BOOTSTRAP - -By default it will include bootstrap.js in the widget media which will locate or load jQuery and jQuery-UI. - -In other words, by default it will just work. - -If you don't want it do that, in settings.py: - AJAX_SELECT_BOOTSTRAP = False - -First one wins: - -* window.jQuery - if you already included jQuery on the page -* or loads: //ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js - -Likewise for jQuery-UI: - -* window.jQuery.ui -* or loads: //ajax.googleapis.com/ajax/libs/jqueryui/1.8.24/jquery-ui.min.js - with theme: //ajax.googleapis.com/ajax/libs/jqueryui/1.8.24/themes/smoothness/jquery-ui.css - -If you want your own custom theme then load jquery ui and your css first. - -Warning: the latest jQueryUI seems to have issues with the autocomplete. I would rather switch to the much nicer select2 than try to get the latest jQuery UI to work. Its a lot of js and css to load just for a dropdown. - - -urls.py -------- - -Simply include the ajax_select urls in your site's urlpatterns: - - from django.conf.urls.defaults import patterns, include - - from django.contrib import admin - from ajax_select import urls as ajax_select_urls - - admin.autodiscover() - - urlpatterns = patterns('', - (r'^admin/lookups/', include(ajax_select_urls)), - (r'^admin/', include(admin.site.urls)), - ) - - -lookups.py ----------- - -By convention this is where you would define custom lookup channels - -If you are using Django >= 1.7 then all `lookups.py` in all of your apps will be automatically imported on startup. - -Use the @register decorator to register your LookupChannels - -`example/lookups.py`: +Define a lookup channel: ```python +# yourapp/lookups.py from ajax_select import register, LookupChannel +from .models import Tag -@register('things') -class ThingsLookupChannel(LookupChannel): +@register('tags') +class TagsLookup(LookupChannel): - model = Things + model = Tag def get_query(self, q, request): - return self.model.objects.filter(title__icontains=q).order_by('title') + return self.model.objects.filter(name__icontains=q).order_by('name')[:50] + def format_item_display(self, item): + return u"%s" % item.name ``` +Add field to a form: -Subclass `LookupChannel` and override any method you wish to customize. - -1.1x Upgrade note: previous versions did not have a parent class. The methods format_result and format_item have been renamed to format_match and format_item_display respectively. -Those old lookup channels will still work and the previous methods will be used. It is still better to adjust your lookup channels to inherit from the new base class. - - from ajax_select import LookupChannel - from django.utils.html import escape - from django.db.models import Q - from example.models import Person - - class PersonLookup(LookupChannel): - - model = Person - - 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): - u""" result is the simple text that is the completion of what the person typed """ - return obj.name - - def format_match(self, obj): - """ (HTML) formatted item for display in the dropdown """ - return self.format_item_display(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)) - - Note that raw strings should always be escaped with the escape() function - -#### Methods you can override in your `LookupChannel` - - -###### model [property] - -The model class this channel searches - -###### plugin_options [property, default={}] - -Set any options for the jQuery plugin. This includes: - -+ minLength -+ autoFocus -+ disabled -+ position -+ source - setting this would overide the normal ajax URL. could be used to add URL query params - -See http://docs.jquery.com/UI/Autocomplete#options - -The field or widget may also specify plugin_options that will overwrite those specified by the channel. - -###### min_length [property, default=1] - -This is a jQuery plugin option. It is preferred to set this in the plugin_options dict, but this older style attribute will still be honored. - -Minimum query length to return a result. Large datasets can choke if they search too often with small queries. -Better to demand at least 2 or 3 characters. -This param is also used in jQuery's UI when filtering results from its own cache. - -###### search_field [property, optional] - -Name of the field for the query to search with icontains. This is used only in the default get_query implementation. -Usually better to just implement your own get_query - -###### get_query(self,q,request) - -return a query set searching for the query string q, ordering as appropriate. -Either implement this method yourself or set the search_field property. -Note that you may return any iterable so you can even use yield and turn this method into a generator, -or return an generator or list comprehension. - -###### get_result(self,obj): - -The text result of autocompleting the entered query. This is currently displayed only for a moment in the text field -after the user has selected the item. Then the item is displayed in the item_display deck and the text field is cleared. -Future versions may offer different handlers for how to display the selected item(s). In the current version you may -add extra script and use triggers to customize. - -###### format_match(self,obj): - -(HTML) formatted item for displaying item in the result dropdown - -###### format_item_display(self,obj): - -(HTML) formatted item for displaying item in the selected deck area (directly below the text field). -Note that we use jQuery .position() to correctly place the deck area below the text field regardless of -whether the widget is in the admin, and admin inline or an outside form. ie. it does not depend on django's -admin css to correctly place the selected display area. - -###### get_objects(self,ids): - -Get the currently selected objects when editing an existing model - -Note that the order of the ids supplied for ManyToMany fields is dependent on how the objects manager fetches it. -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): - -Check if the user has permission to add one of these models. -This enables the green popup + -Default is the standard django permission check - -###### check_auth(self,request): - -To ensure that nobody can get your data via json simply by knowing the URL. -The default is to limit it to request.user.is_staff and raise a PermissionDenied exception. -By default this is an error with a 401 response, but your middleware may intercept and choose to do other things. - -Public facing forms should write a custom `LookupChannel` to implement as needed. -Also you could choose to return HttpResponseForbidden("who are you?") instead of raising PermissionDenied - - -admin.py --------- - -#### 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. - -+ *model*: your model -+ *fieldlist*: a dict of {fieldname : channel_name, ... } -+ *superclass*: [default ModelForm] Substitute a different superclass for the constructed Form class. -+ *show_help_text*: [default False] - Leave blank [False] if using this form in a standard Admin. - Set it True for InlineAdmin classes or if making a form for use outside of the Admin. - -######Example - - from ajax_select import make_ajax_form - from ajax_select.admin import AjaxSelectAdmin - from yourapp.models import YourModel - - class YourModelAdmin(AjaxSelectAdmin): - # create an ajax form class using the factory function - # 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) - return form - -If you are using ajax select fields on an Inline form in Django Admin, you should override get_formset to check permission and include the add button when appropriate: - - def get_formset(self, request, obj=None, **kwargs): - formset = super(YourAdminClass, self).get_form(request, obj, **kwargs) - autoselect_fields_check_can_add(formset.form, self.model, request.user) - return formset - -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. - - -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) - -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. - -+ *model*: the model that this ModelForm is for -+ *model_fieldname*: the field on the model that is being edited (ForeignKey, ManyToManyField or CharField) -+ *channel*: the lookup channel to use for searches -+ *show_help_text*: [default False] Whether to show the help text inside the widget itself. - When using in AdminInline or outside of the admin then set it to True. -+ *kwargs*: Additional kwargs are passed on to the form field. - Of interest: - help_text="Custom help text" - or: - # do not show any help at all - help_text=None - - plugin_options - directly specify jQuery plugin options. see Lookup plugin_options above - - -#####Example - - from ajax_select import make_ajax_field - - class ReleaseForm(ModelForm): - - class Meta: - model = Release - exclude = [] - - group = make_ajax_field(Release, 'group', 'group', help_text=None) - -#### Without using the helper - - - from ajax_select.fields import AutoCompleteSelectField - - class ReleaseForm(ModelForm): - - group = AutoCompleteSelectField('group', required=False, help_text=None) - -#### Setting plugin options - - from ajax_select.fields import AutoCompleteSelectField - - class ReleaseForm(ModelForm): - - group = AutoCompleteSelectField('group', required=False, help_text=None, plugin_options = {'autoFocus': True, 'minLength': 4}) - -#### Using ajax selects in a `FormSet` - -There is possibly a better way to do this, but here is an initial example: - -`forms.py` - - from django.forms.models import modelformset_factory - from django.forms.models import BaseModelFormSet - from ajax_select.fields import AutoCompleteSelectMultipleField, AutoCompleteSelectField - - from models import Task - - # create a superclass - class BaseTaskFormSet(BaseModelFormSet): - - # that adds the field in, overwriting the previous default field - def add_fields(self, form, index): - super(BaseTaskFormSet, self).add_fields(form, index) - 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) - - - -templates/ ----------- - -Each form field widget is rendered using a template. You may write a custom template per channel and extend the base template in order to implement these blocks: - - {% block extra_script %}{% endblock %} - {% block help %}{% endblock %} - - - - - -
form Fieldtries this firstdefault template
AutoCompleteFieldtemplates/autocomplete_{{CHANNELNAME}}.htmltemplates/autocomplete.html
AutoCompleteSelectFieldtemplates/autocompleteselect_{{CHANNELNAME}}.htmltemplates/autocompleteselect.html
AutoCompleteSelectMultipleFieldtemplates/autocompleteselectmultiple_{{CHANNELNAME}}.htmltemplates/autocompleteselectmultiple.html
- -See ajax_select/static/js/ajax_select.js below for the use of jQuery trigger events - - -ajax_select/static/css/ajax_select.css --------------------------------------- - -If you are using `django.contrib.staticfiles` then you can implement `ajax_select.css` and put your app ahead of ajax_select to cause it to be collected by the management command `collectfiles`. - -If you are doing your own compress stack then of course you can include whatever version you want. - -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 trashcan icon comes from the jQueryUI theme by the css classes: - - "ui-icon ui-icon-trash" - -The css declaration: - - .results_on_deck .ui-icon.ui-icon-trash { } - -would be "stronger" than jQuery's style declaration and thus you could make trash look less trashy. - - -ajax_select/static/js/ajax_select.js ------------------------------------- - -You probably don't want to mess with this one. But by using the extra_script block as detailed in templates/ above you can add extra javascript, particularily to respond to event Triggers. - -Triggers are a great way to keep code clean and untangled. see: http://docs.jquery.com/Events/trigger - -Two triggers/signals are sent: 'added' and 'killed'. These are sent to the $("#{{html_id}}_on_deck") element. That is the area that surrounds the currently selected items. - -Extend the template, implement the extra_script block and bind functions that will respond to the trigger: - -##### multi select: - - {% block extra_script %} - $("#{{html_id}}_on_deck").bind('added', function() { - id = $("#{{html_id}}").val(); - alert('added id:' + id ); - }); - $("#{{html_id}}_on_deck").bind('killed', function() { - current = $("#{{html_id}}").val() - alert('removed, current is:' + current); - }); - {% endblock %} - -##### select: - - {% block extra_script %} - $("#{{html_id}}_on_deck").bind('added', function() { - id = $("#{{html_id}}").val(); - alert('added id:' + id ); - }); - $("#{{html_id}}_on_deck").bind('killed', function() { - alert('removed'); - }); - {% endblock %} - -##### auto-complete text field: - - {% block extra_script %} - $('#{{ html_id }}').bind('added', function() { - entered = $('#{{ html_id }}').val(); - alert(entered); - }); - {% endblock %} - -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. +```python +# yourapp/forms.py +class DocumentForm(ModelForm): + class Meta: + model = Document -Testing ------------- - -For any pull requests you should run the unit tests first. Travis CI will also run all tests across all supported versions against your pull request and github will show you the failures. + tags = AutoCompleteSelectMultipleField('tags') +``` -Its much faster to run them yourself locally. - pip install -r requirements-test.txt -This will run tox: +Fully customizable +------------------ - make test +- search query +- query other resources besides Django ORM +- format results with HTML +- custom styling +- customize security policy +- customize UI +- integrate with other UI elements on page using the javascript API +- works in Admin as well as in normal views - # or just - tox -With all supported combinations of Django and Python. +Assets included by default +------------------- -You will need to have different Python interpreters installed which you can do with: +- ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js +- code.jquery.com/ui/1.10.3/jquery-ui.js +- code.jquery.com/ui/1.10.3/themes/smoothness/jquery-ui.css -https://github.com/yyuu/pyenv +Compatibility +------------- +- Django >=1.5, <=1.9 +- Python >=2.7, <=3.4 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. +Many thanks to all contributors and pull requesters ! -In particular thanks for help in the 1.2 version: @sjrd (Sébastien Doeraene) -And much thanks to @brianmay for assistance over many releases. +https://github.com/crucialfelix/django-ajax-selects/graphs/contributors License From e0a0b6a53f758e943bb7e8a1eeef83a33666bf6f Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Fri, 6 Nov 2015 18:43:34 +0100 Subject: [PATCH 76/85] remove 'show_m2m_help' option - long dead --- ajax_select/helpers.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ajax_select/helpers.py b/ajax_select/helpers.py index 30157dd96e..55ed512d82 100644 --- a/ajax_select/helpers.py +++ b/ajax_select/helpers.py @@ -75,10 +75,6 @@ def make_ajax_field(related_model, fieldname_on_model, channel, show_help_text=F This setting will show the help text inside the widget itself. kwargs: optional args - # will support previous arg name for several versions before deprecating - # TODO remove this now - if 'show_m2m_help' in kwargs: - show_help_text = kwargs.pop('show_m2m_help') - help_text: default is the model db field's help_text. None will disable all help text - label: default is the model db field's verbose name From 73beda93253c18d4cc0b4cebc7c7a7aeda782867 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Fri, 6 Nov 2015 18:43:58 +0100 Subject: [PATCH 77/85] fix: bad find and replace in example/lookups.py --- example/example/lookups.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/example/lookups.py b/example/example/lookups.py index 99ae6666b0..0be95967a9 100644 --- a/example/example/lookups.py +++ b/example/example/lookups.py @@ -1,4 +1,4 @@ -from __future__ import text_type_literals +from __future__ import unicode_literals from django.utils.six import text_type from django.db.models import Q from django.utils.html import escape From 7fdc13799dbfa9d4641dc26f2b32ed6883e107af Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Fri, 6 Nov 2015 18:46:38 +0100 Subject: [PATCH 78/85] change: move templates into ajax_select/ this is the one actual breaking change in 1.4 --- ajax_select/fields.py | 15 ++++++++------- .../templates/{ => ajax_select}/autocomplete.html | 0 .../{ => ajax_select}/autocompleteselect.html | 0 .../autocompleteselectmultiple.html | 0 4 files changed, 8 insertions(+), 7 deletions(-) rename ajax_select/templates/{ => ajax_select}/autocomplete.html (100%) rename ajax_select/templates/{ => ajax_select}/autocompleteselect.html (100%) rename ajax_select/templates/{ => ajax_select}/autocompleteselectmultiple.html (100%) diff --git a/ajax_select/fields.py b/ajax_select/fields.py index fd3e663ce2..a50317ec77 100644 --- a/ajax_select/fields.py +++ b/ajax_select/fields.py @@ -91,7 +91,9 @@ def render(self, name, value, attrs=None): 'add_link': self.add_link, } context.update(plugin_options(lookup, self.channel, self.plugin_options, initial)) - out = render_to_string(('autocompleteselect_%s.html' % self.channel, 'autocompleteselect.html'), context) + templates = ('ajax_select/autocompleteselect_%s.html' % self.channel, + 'ajax_select/autocompleteselect.html') + out = render_to_string(templates, context) return mark_safe(out) def value_from_datadict(self, data, files, name): @@ -210,9 +212,9 @@ def render(self, name, value, attrs=None): 'add_link': self.add_link, } context.update(plugin_options(lookup, self.channel, self.plugin_options, initial)) - out = render_to_string( - ('autocompleteselectmultiple_%s.html' % self.channel, 'autocompleteselectmultiple.html'), - context) + templates = ('ajax_select/autocompleteselectmultiple_%s.html' % self.channel, + 'ajax_select/autocompleteselectmultiple.html') + out = render_to_string(templates, context) return mark_safe(out) def value_from_datadict(self, data, files, name): @@ -341,9 +343,8 @@ def render(self, name, value, attrs=None): 'func_slug': self.html_id.replace("-", ""), } context.update(plugin_options(lookup, self.channel, self.plugin_options, initial)) - - templates = ('autocomplete_%s.html' % self.channel, - 'autocomplete.html') + templates = ('ajax_select/autocomplete_%s.html' % self.channel, + 'ajax_select/autocomplete.html') return mark_safe(render_to_string(templates, context)) diff --git a/ajax_select/templates/autocomplete.html b/ajax_select/templates/ajax_select/autocomplete.html similarity index 100% rename from ajax_select/templates/autocomplete.html rename to ajax_select/templates/ajax_select/autocomplete.html diff --git a/ajax_select/templates/autocompleteselect.html b/ajax_select/templates/ajax_select/autocompleteselect.html similarity index 100% rename from ajax_select/templates/autocompleteselect.html rename to ajax_select/templates/ajax_select/autocompleteselect.html diff --git a/ajax_select/templates/autocompleteselectmultiple.html b/ajax_select/templates/ajax_select/autocompleteselectmultiple.html similarity index 100% rename from ajax_select/templates/autocompleteselectmultiple.html rename to ajax_select/templates/ajax_select/autocompleteselectmultiple.html From 73f1ded0537a469ee393ac78d848fb17e31db152 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Fri, 6 Nov 2015 18:46:45 +0100 Subject: [PATCH 79/85] format css --- .../static/ajax_select/css/ajax_select.css | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/ajax_select/static/ajax_select/css/ajax_select.css b/ajax_select/static/ajax_select/css/ajax_select.css index 43336e92e6..7050b9f6cd 100644 --- a/ajax_select/static/ajax_select/css/ajax_select.css +++ b/ajax_select/static/ajax_select/css/ajax_select.css @@ -1,46 +1,45 @@ .results_on_deck .ui-icon-trash { - float: left; - cursor: pointer; + float: left; + cursor: pointer; } .results_on_deck { - padding: 0.25em 0; + padding: 0.25em 0; } form .aligned .results_on_deck { - padding-left: 38px; - margin-left: 7em; + padding-left: 38px; + margin-left: 7em; } .results_on_deck > div { - margin-bottom: 0.5em; + margin-bottom: 0.5em; } .ui-autocomplete-loading { - background: url('../images/loading-indicator.gif') no-repeat; - background-origin: content-box; - background-position: right; + background: url('../images/loading-indicator.gif') no-repeat; + background-origin: content-box; + background-position: right; } ul.ui-autocomplete { -/* - this is the dropdown menu. + /* + 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). + 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. + 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; + 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; + list-style-type: none; + padding: 0; } ul.ui-autocomplete li a { - display: block; - padding: 2px 3px; - cursor: pointer; + display: block; + padding: 2px 3px; + cursor: pointer; } From 4f4ba39600a577fc282a3b2fc99b289143a390a1 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Fri, 6 Nov 2015 18:52:44 +0100 Subject: [PATCH 80/85] javascript style cleanup --- .../static/ajax_select/js/ajax_select.js | 77 ++++++++++--------- 1 file changed, 39 insertions(+), 38 deletions(-) diff --git a/ajax_select/static/ajax_select/js/ajax_select.js b/ajax_select/static/ajax_select/js/ajax_select.js index 003a511098..8909263163 100644 --- a/ajax_select/static/ajax_select/js/ajax_select.js +++ b/ajax_select/static/ajax_select/js/ajax_select.js @@ -1,9 +1,9 @@ 'use strict'; -(function ($) { +(function($) { - $.fn.autocompleteselect = function (options) { - return this.each(function () { + $.fn.autocompleteselect = function(options) { + return this.each(function() { var id = this.id, $this = $(this), $text = $('#' + id + '_text'), @@ -23,15 +23,15 @@ } function addKiller(repr, pk) { - var killer_id = 'kill_' + pk + id, - killButton = 'X '; + var killId = 'kill_' + pk + id, + killButton = 'X '; if (repr) { $deck.empty(); $deck.append('
' + killButton + repr + '
'); } else { - $('#' + id+'_on_deck > div').prepend(killButton); + $('#' + id + '_on_deck > div').prepend(killButton); } - $('#' + killer_id).click(function () { + $('#' + killId).click(function() { kill(); $deck.trigger('killed', [pk]); }); @@ -45,36 +45,36 @@ options.select = receiveResult; $text.autocomplete(options); - function reset(){ + function reset() { if (options.initial) { addKiller(options.initial[0], options.initial[1]); $this.val(options.initial[1]); } else { kill(); } - }; + } reset(); $this.closest('form').on('reset', reset); - $this.bind('didAddPopup', function (event, pk, repr) { + $this.bind('didAddPopup', function(event, pk, repr) { receiveResult(null, {item: {pk: pk, repr: repr}}); }); }); }; - $.fn.autocompleteselectmultiple = function (options) { - return this.each(function () { + $.fn.autocompleteselectmultiple = function(options) { + return this.each(function() { var id = this.id, $this = $(this), - $text = $('#' + id+'_text'), - $deck = $('#' + id+'_on_deck'); + $text = $('#' + id + '_text'), + $deck = $('#' + id + '_on_deck'); function receiveResult(event, ui) { var pk = ui.item.pk, prev = $this.val(); - if (prev.indexOf('|'+pk+'|') === -1) { + if (prev.indexOf('|' + pk + '|') === -1) { $this.val((prev ? prev : '|') + pk + '|'); addKiller(ui.item.repr, pk); $text.val(''); @@ -85,11 +85,11 @@ } function addKiller(repr, pk) { - var killer_id = 'kill_' + pk + id, - killButton = 'X '; + var killId = 'kill_' + pk + id, + killButton = 'X '; $deck.append('
' + killButton + repr + '
'); - $('#' + killer_id).click(function () { + $('#' + killId).click(function() { kill(pk); $deck.trigger('killed', [pk]); }); @@ -97,29 +97,29 @@ function kill(pk) { $this.val($this.val().replace('|' + pk + '|', '|')); - $('#' + id+'_on_deck_'+pk).fadeOut().remove(); + $('#' + id + '_on_deck_' + pk).fadeOut().remove(); } options.select = receiveResult; $text.autocomplete(options); - function reset(){ + function reset() { $deck.empty(); - var query = "|"; + var query = '|'; if (options.initial) { - $.each(options.initial, function (i, its) { + $.each(options.initial, function(i, its) { addKiller(its[0], its[1]); - query += its[1] + "|"; + query += its[1] + '|'; }); } $this.val(query); - }; + } reset(); $this.closest('form').on('reset', reset); - $this.bind('didAddPopup', function (event, pk, repr) { - receiveResult(null, {item: {pk: pk, repr: repr }}); + $this.bind('didAddPopup', function(event, pk, repr) { + receiveResult(null, {item: {pk: pk, repr: repr}}); }); }); }; @@ -189,12 +189,12 @@ }; // activate any on page - $(window).bind('init-autocomplete', function () { + $(window).bind('init-autocomplete', function() { - $('input[data-ajax-select=autocomplete]').each(function (i, inp) { - addAutoComplete(inp, function ($inp, opts) { + $('input[data-ajax-select=autocomplete]').each(function(i, inp) { + addAutoComplete(inp, function($inp, opts) { opts.select = - function (event, ui) { + function(event, ui) { $inp.val(ui.item.value).trigger('added', [ui.item.pk, ui.item]); return false; }; @@ -202,27 +202,28 @@ }); }); - $('input[data-ajax-select=autocompleteselect]').each(function (i, inp) { - addAutoComplete(inp, function ($inp, 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) { + $('input[data-ajax-select=autocompleteselectmultiple]').each(function(i, inp) { + addAutoComplete(inp, function($inp, opts) { $inp.autocompleteselectmultiple(opts); }); }); }); - $(document).ready(function () { + $(document).ready(function() { // if dynamically injecting forms onto a page // you can trigger them to be ajax-selects-ified: $(window).trigger('init-autocomplete'); - $('.inline-group ul.tools a.add, .inline-group div.add-row a, .inline-group .tabular tr.add-row td a').on('click', function() { - $(window).trigger('init-autocomplete'); - }); + $('.inline-group ul.tools a.add, .inline-group div.add-row a, .inline-group .tabular tr.add-row td a') + .on('click', function() { + $(window).trigger('init-autocomplete'); + }); }); })(window.jQuery); From 7a462e5b4b84ef1f04131de63201e90378803563 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Fri, 6 Nov 2015 18:57:17 +0100 Subject: [PATCH 81/85] notice: this is the development version readme --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 86098249ce..8181fdcc5a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,15 @@ Edit `ForeignKey`, `ManyToManyField` and `CharField` in Django Admin using jQuer + +Development version +------------------- + +NOTE: this is the README for the current development version. + +The latest current stable release is visible on the master branch: https://github.com/crucialfelix/django-ajax-selects/tree/master + + Documentation ------------------ From 5443804f44a98bbe4d3ac4bbefadd4c73a3d7f11 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Fri, 6 Nov 2015 19:06:33 +0100 Subject: [PATCH 82/85] fix image link in readme --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8181fdcc5a..38aff1395f 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,10 @@ Edit `ForeignKey`, `ManyToManyField` and `CharField` in Django Admin using jQuery UI AutoComplete. - - +![selecting](/docs/source/_static/kiss.png?raw=true) +![selected](/docs/source/_static/kiss-all.png?raw=true) Development version ------------------- From 3d08b305d2d152c311ac27e9c48840b10a5b1a24 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Sat, 7 Nov 2015 15:25:21 +0100 Subject: [PATCH 83/85] gitignore example db, dist/, MANIFEST --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 2fff34657d..1019c4fa58 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ example/AJAXSELECTS/* htmlcov .coverage docs/_build +example/ajax_select +example/ajax_selects_example +dist +MANIFEST From e3dee274c2dec2fdfdca320f0fe803fbf1dc9361 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Sat, 7 Nov 2015 15:25:42 +0100 Subject: [PATCH 84/85] update readme: add travis build status, pypi version --- README.md | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 38aff1395f..4ba7d640d8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ Edit `ForeignKey`, `ManyToManyField` and `CharField` in Django Admin using jQuery UI AutoComplete. +[![Build Status](https://travis-ci.org/crucialfelix/django-ajax-selects.svg?branch=master)](https://travis-ci.org/crucialfelix/django-ajax-selects) [![PyPI version](https://badge.fury.io/py/django-ajax-selects.svg)](https://badge.fury.io/py/django-ajax-selects) + +--- ![selecting](/docs/source/_static/kiss.png?raw=true) @@ -60,22 +63,22 @@ class DocumentForm(ModelForm): Fully customizable ------------------ -- search query -- query other resources besides Django ORM -- format results with HTML -- custom styling -- customize security policy -- customize UI -- integrate with other UI elements on page using the javascript API -- works in Admin as well as in normal views +- Customize search query +- Query other resources besides Django ORM +- Format results with HTML +- Customize styling +- Customize security policy +- Add additional custom UI alongside widget +- Integrate with other UI elements elsewhere on the page using the javascript API +- Works in Admin as well as in normal views Assets included by default ------------------- -- ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js -- code.jquery.com/ui/1.10.3/jquery-ui.js -- code.jquery.com/ui/1.10.3/themes/smoothness/jquery-ui.css +- //ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js +- //code.jquery.com/ui/1.10.3/jquery-ui.js +- //code.jquery.com/ui/1.10.3/themes/smoothness/jquery-ui.css Compatibility ------------- @@ -95,5 +98,5 @@ License ------- Dual licensed under the MIT and GPL licenses: - http://www.opensource.org/licenses/mit-license.php - http://www.gnu.org/licenses/gpl.html +- http://www.opensource.org/licenses/mit-license.php +- http://www.gnu.org/licenses/gpl.html From 36f28042e3b693e0899d49ed74e284f0613d4ac5 Mon Sep 17 00:00:00 2001 From: crucialfelix Date: Sat, 7 Nov 2015 16:00:59 +0100 Subject: [PATCH 85/85] update release notes, setup.py release notes, bump version --- MANIFEST.in | 4 +-- Release-notes.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 36 +++++++++++---------------- 3 files changed, 80 insertions(+), 25 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 73fb66447f..394fcf7eaa 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,2 @@ recursive-include tests *.py -recursive-include example *.py *.sh *.txt -prune example/AJAXSELECTS -prune example/ajax_select +include README.md diff --git a/Release-notes.md b/Release-notes.md index 121c65ab03..6337988e1d 100644 --- a/Release-notes.md +++ b/Release-notes.md @@ -1,4 +1,69 @@ +Version 1.4.0 +============= + +- Autodiscover of lookups.py for Django 1.7+ +- Simpler configuration +- Fix support for Django 1.8, Python 3 +- Support for Django 1.9b1 +- Add full testing matrix +- New clearer multi-page documentation +- Extensive docstrings +- Support form reset button + +fix: changed_data always includes AutoComplete fields + +Breaking Changes +---------------- + +### Custom templates + +Move your custom templates from: + + yourapp/templates/channel_autocomplete.html + yourapp/templates/channel_autocompleteselect.html + yourapp/templates/channel_autocompleteselectmultiple.html + +to: + + yourapp/templates/ajax_select/channel_autocomplete.html + yourapp/templates/ajax_select/channel_autocompleteselect.html + yourapp/templates/ajax_select/channel_autocompleteselectmultiple.html + +And change your extends from: + + {% extends "autocompleteselect.html" %} + +to: + + {% extends "ajax_select/autocompleteselect.html" %} + +### No more conversion of values by Widget + +Previous releases would try to convert the primary key submitted from the Widget into either an integer or string, +depending on what it looked like. This was to support databases with non-integer primary keys. + +Its best that the Widget does not involve itself with the database types, it should only process its input. + +Django's ORM converts strings to integers. If for some reason your database is getting the wrong type for a PK, +then you should handle this conversion in your Form's clean_fieldname method. + +### Removed deprecated options + +`make_ajax_field`: dropped backward compat support for `show_m2m_help` option. +Use `show_help_text`. + +remove deprecated `min_length` template var - use `plugin_options['min_length']` +remove deprecated `lookup_url` template var - use `plugin_options['source']` + + +### settings + +LookupChannels are still loaded from `settings.AJAX_LOOKUP_CHANNELS` as previously. + +If you are on Django >= 1.7 you may switch to using the @register decorator and you can probably remove `AJAX_LOOKUP_CHANNELS` entirely. + + Version 1.3.6 ============= diff --git a/setup.py b/setup.py index 9d62d066b4..a313429022 100644 --- a/setup.py +++ b/setup.py @@ -9,9 +9,9 @@ setup( name='django-ajax-selects', - version='1.3.6', - description='jQuery-UI powered auto-complete fields for editing ForeignKey, ManyToManyField and CharField', - author='crucialfelix', + version='1.4.0', + description='Edit ForeignKey, ManyToManyField and CharField in Django Admin using jQuery UI AutoComplete.', + author='Chris Sattinger', author_email='crucialfelix@gmail.com', url='https://github.com/crucialfelix/django-ajax-selects/', packages=['ajax_select'], @@ -43,26 +43,18 @@ "Framework :: Django", ], long_description="""\ -Enables editing of `ForeignKey`, `ManyToManyField` and `CharField` using jQuery UI AutoComplete. +Edit ForeignKey, ManyToManyField and CharField in Django Admin using jQuery UI AutoComplete. -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. -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 - -+ Django 1.6+ -+ 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 -+ Default (but customizable) security prevents griefers from pilfering your data via JSON requests +- Customize search query +- Query other resources besides Django ORM +- Format results with HTML +- Customize styling +- Customize security policy +- Add additional custom UI alongside widget +- Integrate with other UI elements elsewhere on the page using the javascript API +- Works in Admin as well as in normal views +- Django >=1.5, <=1.9 +- Python >=2.7, <=3.4 """ )