diff --git a/.gitignore b/.gitignore index 1e5e8846a3..03f73e9834 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ example/ajax_select example/ajax_selects_example_db dist MANIFEST +build diff --git a/.travis.yml b/.travis.yml index 2dc0480f7c..77f6543dcd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,18 +3,16 @@ sudo: false env: - TOX_ENV=py27-flake8 - TOX_ENV=py34-flake8 - - TOX_ENV=py27-dj16 - - TOX_ENV=py33-dj16 - TOX_ENV=py27-dj17 - TOX_ENV=py27-dj18 - TOX_ENV=py27-dj19 - TOX_ENV=py27-dj110 + - TOX_ENV=py27-dj111 - TOX_ENV=py34-dj17 - TOX_ENV=py34-dj18 - TOX_ENV=py34-dj19 - TOX_ENV=py34-dj110 - # - TOX_ENV=py35-dj18 - # - TOX_ENV=py35-dj19 + - TOX_ENV=py34-dj111 install: - pip install -r requirements-test.txt script: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d6fd7688a..40e0a0b996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Change Log +## [1.6.0](https://github.com/crucialfelix/django-ajax-selects/tree/1.6.0) (2017-05-17) +[Full Changelog](https://github.com/crucialfelix/django-ajax-selects/compare/1.5.2...1.6.0) + +Add support for Django 1.11 +Drop support for Django 1.6 + +**Closed issues:** + +- LookupChannel.get\_objects fails for inherited models [\#153](https://github.com/crucialfelix/django-ajax-selects/issues/153) + +**Merged pull requests:** + +- Changed the build\_attrs to work with Django==1.11. [\#202](https://github.com/crucialfelix/django-ajax-selects/pull/202) ([xbello](https://github.com/xbello)) + ## [1.5.2](https://github.com/crucialfelix/django-ajax-selects/tree/1.5.2) (2016-10-19) [Full Changelog](https://github.com/crucialfelix/django-ajax-selects/compare/1.5.1...1.5.2) diff --git a/Makefile b/Makefile index 0c19385584..22821b78ff 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ clean-pyc: find . -name '*~' -exec rm -f {} + lint: - flake8 . + flake8 ajax_select tests example test: tox diff --git a/ajax_select/__init__.py b/ajax_select/__init__.py index 77bd56ac62..a4381dc9e5 100644 --- a/ajax_select/__init__.py +++ b/ajax_select/__init__.py @@ -1,5 +1,5 @@ """JQuery-Ajax Autocomplete fields for Django Forms.""" -__version__ = "1.5.2" +__version__ = "1.6.0" __author__ = "crucialfelix" __contact__ = "crucialfelix@gmail.com" __homepage__ = "https://github.com/crucialfelix/django-ajax-selects/" diff --git a/ajax_select/fields.py b/ajax_select/fields.py index 4047ba80a4..2692c45b8f 100644 --- a/ajax_select/fields.py +++ b/ajax_select/fields.py @@ -4,19 +4,19 @@ from django import forms from django.conf import settings from django.contrib.contenttypes.models import ContentType -from django.core.urlresolvers import reverse from django.db.models.query import QuerySet -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.forms.utils import flatatt from django.template.defaultfilters import force_escape +from django.template.loader import render_to_string 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: + from django.urls import reverse +except ImportError: + # < django 1.10 + from django.core.urlresolvers import reverse as_default_help = 'Enter text to search.' @@ -35,12 +35,14 @@ def _media(self): return forms.Media(css={'all': ('ajax_select/css/ajax_select.css',)}, js=js) -#################################################################################### +############################################################################### class AutoCompleteSelectWidget(forms.widgets.TextInput): - """Widget to search for a model and return it as text for use in a CharField.""" + """ + Widget to search for a model and return it as text for use in a CharField. + """ media = property(_media) @@ -61,7 +63,10 @@ def __init__(self, def render(self, name, value, attrs=None): value = value or '' - final_attrs = self.build_attrs(attrs) + + final_attrs = self.build_attrs(self.attrs) + final_attrs.update(attrs or {}) + final_attrs.pop('required', None) self.html_id = final_attrs.pop('id', name) current_repr = '' @@ -131,7 +136,8 @@ def clean(self, value): if len(objs) != 1: # someone else might have deleted it while you were editing # or your channel is faulty - # out of the scope of this field to do anything more than tell you it doesn't exist + # out of the scope of this field to do anything more than + # tell you it doesn't exist raise forms.ValidationError("%s cannot find object: %s" % (lookup, value)) return objs[0] else: @@ -149,12 +155,14 @@ def has_changed(self, initial, data): return text_type(initial_value) != text_type(data_value) -#################################################################################### +############################################################################### class AutoCompleteSelectMultipleWidget(forms.widgets.SelectMultiple): - """Widget to select multiple models for a ManyToMany db field.""" + """ + Widget to select multiple models for a ManyToMany db field. + """ media = property(_media) @@ -179,7 +187,9 @@ def render(self, name, value, attrs=None): if value is None: value = [] - final_attrs = self.build_attrs(attrs) + final_attrs = self.build_attrs(self.attrs) + final_attrs.update(attrs or {}) + final_attrs.pop('required', None) self.html_id = final_attrs.pop('id', name) lookup = registry.get(self.channel) @@ -229,7 +239,9 @@ def id_for_label(self, id_): class AutoCompleteSelectMultipleField(forms.fields.CharField): - """ form field to select multiple models for a ManyToMany db field """ + """ + Form field to select multiple models for a ManyToMany db field. + """ channel = None @@ -245,8 +257,8 @@ def __init__(self, channel, *args, **kwargs): 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. + # 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 isinstance(help_text, text_type): @@ -298,14 +310,15 @@ def has_changed(self, initial_value, data_value): dvs = [text_type(v) for v in (data_value or [])] return ivs != dvs -#################################################################################### +############################################################################### class AutoCompleteWidget(forms.TextInput): """ - Widget to select a search result and enter the result as raw text in the text input field. - the user may also simply enter text and ignore any auto complete suggestions. + Widget to select a search result and enter the result as raw text in the + text input field. The user may also simply enter text and ignore any + auto complete suggestions. """ media = property(_media) @@ -325,9 +338,10 @@ def __init__(self, channel, *args, **kwargs): def render(self, name, value, attrs=None): initial = value or '' - - final_attrs = self.build_attrs(attrs) + final_attrs = self.build_attrs(self.attrs) + final_attrs.update(attrs or {}) self.html_id = final_attrs.pop('id', name) + final_attrs.pop('required', None) lookup = registry.get(self.channel) if self.show_help_text: @@ -352,7 +366,8 @@ def render(self, name, value, attrs=None): class AutoCompleteField(forms.CharField): """ - A CharField that uses an AutoCompleteWidget to lookup matching and stores the result as plain text. + A CharField that uses an AutoCompleteWidget to lookup matching + and stores the result as plain text. """ channel = None @@ -375,7 +390,7 @@ def __init__(self, channel, *args, **kwargs): super(AutoCompleteField, self).__init__(*args, **defaults) -#################################################################################### +############################################################################### def _check_can_add(self, user, related_model): """ @@ -402,7 +417,8 @@ def _check_can_add(self, user, related_model): def autoselect_fields_check_can_add(form, model, user): """ 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. + 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)): diff --git a/ajax_select/lookup_channel.py b/ajax_select/lookup_channel.py index 11adc72e65..ebed70801c 100644 --- a/ajax_select/lookup_channel.py +++ b/ajax_select/lookup_channel.py @@ -8,7 +8,8 @@ class LookupChannel(object): """ Subclass this, setting the model and implementing methods to taste. - Attributes: + 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. @@ -29,12 +30,13 @@ def get_query(self, q, request): """ 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. + 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. + 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 @@ -43,12 +45,16 @@ def get_query(self, q, request): 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. + 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. + 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): @@ -58,7 +64,8 @@ def get_result(self, obj): return escape(force_text(obj)) def format_match(self, obj): - """(HTML) Format item for displaying in the dropdown. + """ + (HTML) Format item for displaying in the dropdown. Args: obj (Model): @@ -68,7 +75,8 @@ def format_match(self, obj): return escape(force_text(obj)) def format_item_display(self, obj): - """ (HTML) format item for displaying item in the selected deck area. + """ + (HTML) format item for displaying item in the selected deck area. Args: obj (Model): @@ -78,29 +86,28 @@ def format_item_display(self, obj): return escape(force_text(obj)) def get_objects(self, ids): - """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. + """ + This is used to retrieve the currently selected objects for either ManyToMany or ForeignKey. 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 + if self.model._meta.pk.rel is not None: + # Use the type of the field being referenced + pk_type = self.model._meta.pk.target_field.to_python + else: + pk_type = self.model._meta.pk.to_python + + # Return objects in the same order as passed in here 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, other_model): - """Check if the user has permission to add a ForeignKey or M2M 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. @@ -116,14 +123,15 @@ def can_add(self, user, other_model): return user.has_perm("%s.add_%s" % (ctype.app_label, ctype.model)) def check_auth(self, request): - """By default only request.user.is_staff have access. + """ + 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. + Public facing forms (outside of the Admin) should implement this to + allow non-staff to use this LookupChannel. Args: request (Request) diff --git a/docs/source/Release-notes.rst b/docs/source/Release-notes.rst index e5089a3b4b..bfeb73b00b 100644 --- a/docs/source/Release-notes.rst +++ b/docs/source/Release-notes.rst @@ -3,6 +3,12 @@ Release Notes See also CHANGELOG.md for github issues and bugfixes +1.6.0 +===== + +- Added Support for Django 1.11 +- Dropped Django 1.6 + 1.5.0 ===== diff --git a/example/Makefile b/example/Makefile index 0d2eb15541..0ffeae7a3e 100644 --- a/example/Makefile +++ b/example/Makefile @@ -19,7 +19,7 @@ cleandb: help: @echo make install @echo or: - @echo make clean install DJANGO=1.4.2 + @echo make clean install DJANGO=1.11 .PHONY: install clean cleandb help diff --git a/example/example/admin.py b/example/example/admin.py index c130e58bfa..39b4a76d36 100644 --- a/example/example/admin.py +++ b/example/example/admin.py @@ -6,13 +6,12 @@ from example.models import Person, Label, Group, Song, Release, Book, Author +@admin.register(Person) class PersonAdmin(AjaxSelectAdmin): - pass -admin.site.register(Person, PersonAdmin) - +@admin.register(Label) class LabelAdmin(AjaxSelectAdmin): """ to get + popup buttons, subclass AjaxSelectAdmin @@ -28,8 +27,6 @@ class PersonAdmin(YourAdminSuperclass, AjaxSelectAdmin): # model, fieldlist, [form superclass] form = make_ajax_form(Label, {'owner': 'person'}) -admin.site.register(Label, LabelAdmin) - class ReleaseInline(AjaxSelectAdminStackedInline): @@ -44,6 +41,7 @@ class ReleaseInline(AjaxSelectAdminStackedInline): extra = 1 +@admin.register(Group) class GroupAdmin(AjaxSelectAdmin): # this shows a ManyToMany field @@ -52,9 +50,8 @@ class GroupAdmin(AjaxSelectAdmin): ReleaseInline ] -admin.site.register(Group, GroupAdmin) - +@admin.register(Song) class SongAdmin(AjaxSelectAdmin): form = make_ajax_form(Song, {'group': 'group', 'title': 'cliche'}) @@ -64,17 +61,14 @@ class SongAdmin(AjaxSelectAdmin): # and throws a validation error on save # but doesn't show any error message to the user -admin.site.register(Song, SongAdmin) - +@admin.register(Release) class ReleaseAdmin(AjaxSelectAdmin): # specify a form class manually (normal django way) # see forms.py form = ReleaseForm -admin.site.register(Release, ReleaseAdmin) - class BookInline(AjaxSelectAdminTabularInline): @@ -98,10 +92,9 @@ class BookInline(AjaxSelectAdminTabularInline): # return fs +@admin.register(Author) class AuthorAdmin(AjaxSelectAdmin): inlines = [ BookInline, ] - -admin.site.register(Author, AuthorAdmin) diff --git a/example/example/forms.py b/example/example/forms.py index 89b11f54f8..e641453ae1 100644 --- a/example/example/forms.py +++ b/example/example/forms.py @@ -13,7 +13,7 @@ class Meta: exclude = [] # args: this model, fieldname on this model, lookup_channel_name - group = make_ajax_field(Release, 'group', 'group', show_help_text=True) + group = make_ajax_field(Release, 'group', 'group', show_help_text=True, required=True) label = make_ajax_field(Release, 'label', 'label', help_text="Search for label by name") diff --git a/requirements-test.txt b/requirements-test.txt index 70d2a9df3f..19ea40d88b 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,6 +1,6 @@ -coverage -coveralls -flake8>=2.1.0 -tox>=1.7.0 -sphinx>=1.3.5 +coverage>=4.4.1 +coveralls>=1.1 +flake8>=3.3.0 +tox>=2.7.0 +sphinx>=1.6.1 sphinx_rtd_theme diff --git a/requirements.txt b/requirements.txt index 3cb29db69e..f90c442d0a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -django>=1.6.1, <=1.11 -wheel==0.24.0 +django>=1.7, <2 +wheel==0.29.0 diff --git a/setup.py b/setup.py index f47290140b..f9b1902bd1 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name='django-ajax-selects', - version='1.5.2', + version='1.6.0', description='Edit ForeignKey, ManyToManyField and CharField in Django Admin using jQuery UI AutoComplete.', author='Chris Sattinger', author_email='crucialfelix@gmail.com', @@ -54,7 +54,7 @@ - Integrate with other UI elements elsewhere on the page using the javascript API - Works in Admin as well as in normal views -- Django >=1.6, <=1.10 -- Python >=2.7, <=3.5 +- Django >=1.7, <=2 +- Python >=2.7, <=3.7 """ ) diff --git a/tests/admin.py b/tests/admin.py index b36a2f0dad..bd22e5a63e 100644 --- a/tests/admin.py +++ b/tests/admin.py @@ -5,9 +5,9 @@ from tests.test_integration import BookForm +@admin.register(Book) class BookAdmin(AjaxSelectAdmin): form = BookForm -admin.site.register(Book, BookAdmin) class BookInline(AjaxSelectAdminTabularInline): @@ -17,15 +17,14 @@ class BookInline(AjaxSelectAdminTabularInline): extra = 2 +@admin.register(Author) class AuthorAdmin(AjaxSelectAdmin): inlines = [ BookInline ] -admin.site.register(Author, AuthorAdmin) - +@admin.register(Person) class PersonAdmin(admin.ModelAdmin): pass -admin.site.register(Person, PersonAdmin) diff --git a/tests/test_fields.py b/tests/test_fields.py index d89161c2a1..412401abb3 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -18,6 +18,15 @@ def test_render_with_value(self): out = widget.render('book', book.pk) self.assertTrue('autocompleteselect' in out) + def test_render_required_field(self): + field = fields.AutoCompleteSelectField('book', required=True) + widget = field.widget + + book = Book.objects.create(name='book') + out = widget.render('book', book.pk) + self.assertTrue('autocompleteselect' in out) + self.assertTrue('required' not in out) + class TestAutoCompleteSelectMultipleWidget(TestCase): diff --git a/tests/test_lookups.py b/tests/test_lookups.py new file mode 100644 index 0000000000..839392dc4c --- /dev/null +++ b/tests/test_lookups.py @@ -0,0 +1,21 @@ + +from django.test import TestCase +from django.contrib.auth.models import User +from .lookups import UserLookup + + +class TestLookups(TestCase): + + def test_get_objects(self): + user1 = User.objects.create(username='user1', + email='user1@example.com', + password='password') + user2 = User.objects.create(username='user2', + email='user2@example.com', + password='password') + lookup = UserLookup() + users = lookup.get_objects([user2.id, user1.id]) + self.assertEqual(len(users), 2) + u2, u1 = users + self.assertEqual(u1, user1) + self.assertEqual(u2, user2) diff --git a/tox.ini b/tox.ini index 9f07055699..9a4c323849 100644 --- a/tox.ini +++ b/tox.ini @@ -7,8 +7,7 @@ [tox] envlist = {py27,py34}-flake8, - {py27,py34}-dj{16,17,18,19,110} -skip_missing_interpreters = true + {py27,py34}-{dj17,dj18,dj19,dj110,dj111} [testenv] @@ -17,11 +16,11 @@ setenv = PYTHONPATH = {toxinidir}:{toxinidir}/ajax_select:{toxinidir}/tests commands = django-admin.py test tests deps = - dj16: Django>=1.6,<1.7 dj17: Django>=1.7,<1.8 dj18: Django>=1.8,<1.9 dj19: Django>=1.9,<1.10 dj110: Django>=1.10,<1.11 + dj111: Django>=1.11,<1.12 ; djmaster: https://github.com/django/django/zipball/master [testenv:py27-flake8] @@ -29,17 +28,22 @@ deps = flake8 commands = flake8 ajax_select tests example -[testenv:py33-flake8] -deps = - flake8 -commands = flake8 ajax_select tests example - [testenv:py34-flake8] deps = flake8 commands = flake8 ajax_select tests example -[testenv:py35-flake8] -deps = - flake8 -commands = flake8 ajax_select tests example +; [testenv:py35-flake8] +; deps = +; flake8 +; commands = flake8 ajax_select tests example + +; [testenv:py36-flake8] +; deps = +; flake8 +; commands = flake8 ajax_select tests example + +; [testenv:py37-flake8] +; deps = +; flake8 +; commands = flake8 ajax_select tests example