diff --git a/CHANGELOG b/CHANGELOG
index 47c18713a..55f937e41 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,262 +1,8 @@
-3.5.0
+2.3.6 #914 unpin six and fix metaclass
- BACKWARD COMPATIBILITY BREAKS:
- - jquery.js has been **removed** from widget.media, this means that you are
- now responsible for ensuring that jquery is loaded in your page prior to
- displaying the form (form.media).
- - trailing dash was replaced by underscore in forward conf, ie.
- dal-forward-conf-for-id_test becomes dal-forward-conf-for_id_test
+2.3.5 #1001 Missing statics in previous release.
- #1115: Compatibility with Django 3.0 by Alexandr Artemyev @mogost
- #1079: Fixed access to $.fn.select2 by David @dwasyl
- #1118: Highlight select field with error to match Django style by @tchatow
- #1099: django-nested-admin forwarded field fix by @akshenc
-
- Also, tests now run fine on Travis thanks to @jorrit-wehelp
-
-3.4.1 Fix #1108: i18n path (thanks @philgyford for report)
-
-3.4.0 Python 2 and Django < 2.0 support
-
-3.3.4 re-release without dirty source
-
-3.3.3 django < 2.0 support with dal_static contributed by @andybak
-
-3.3.2 django.contrib.admin fix static files by @coredumperror
-
-3.3.1
-
- - Fixed a bug in the way jquery.init.js was being used by @coredumperror
- - Set select2 container CSS class to :all: @hbielenia
- - Added missing renderer parameter to render method for django 2.1 @monim67
- - Fix ImportError with SELECT2_TRANSLATIONS in Django 1.x @hugorodgerbrown
- - Forward argument should always be a tuple @jihoon796
- - Fixed exception thrown from Select2QuerySEtView when paginate_by is set
- @coredumperror
-
-3.3.0
-
- - use admin statics
- - #981: create option behaviour
- - #995: automatically generated views for generic foreign key fields
- - Getting placeholder and minimumInputLength from dal select
- - #1017: Initial migrations and database
- - Turkish translation
- - Added support for forwarded fields to Select2GenericForeignKeyModelField
-
-3.3.0-rc6
-
- #959
-
-3.3.0-rc5
-
- #895: Self() and JavaScript() forward features
-
-3.3.0-rc4
-
- #843: Forward logic refactored. Specifications for types of forwarded values.
-
-3.3.0-rc3
-
- #957 remove reference to deleted script (rebase issue introduced in
- 3.3.0-rc1)
-
-3.3.0-rc2
-
- Revert 5b37f8661, fixes tests.
-
-3.3.0-rc1
-
-This version supports Django 2.0 and Python 3.6, perhaps more but I have not
-tested, please submit compatibility patches for older versions if needed.
-Please test them with tox -e base-py36-django20-sqlite before pushing.
-
-To install 3.3.0-rc1, use `pip install django-autocomplete-light==3.3.0-rc1`.
-
-New features:
-
- #953: Select2 update to 3.4.0.6-rc.1 by @jpic
- #917: django-nested-admin support by @loicteixeira
- #815: Simplify customization of autocomplete views by @EvaSDK
- #746: Select2 Language and dynamic Media by @luzfcb
- #883: Allow overwriting the results by @eayin2
-
-Bug fixes:
-
- #874: Fix Django 1.11.3 error by @ikcam
- #933: Python 3.6 and Django 2.0 support by @jpic
- #930: QuerySetSequence querysets order is not preserved by @melvyn-sopacua
- #909: Prevent initilization of other selects by @loicteixeira
- #904: Fix KeyError when id is not in attrs by @dwheaton
- #885: Prevent rendering of empty option on multi select by @johandc
- #892: Enable different item label for selected item by @maximpetrov
- #926: Atomic create_object by @jpic
- #718: Remove temp hack for select2 by @FuzzAU
- #860: dal: widgets: use the name if we don't have the id by @xrmx
- #849: Don't create a new option if an iexact-matching one already exists by @liwenyip
-
-Also thanks to the many documentation contributors.
-
- #874: Fix Django 1.11.3 error by @ikcam
- #937: Update tutorial.rst to fix XSS in the example by @hangtwenty
- #919: Better create new object example by @davideghz
- #928: Add note about slim jqueries by @melvyn-sopacua
-
-Test notes:
-
- I have not tested this release with other Python and Django versions,
- and also tests don't pass on travis despite the effort. It's working
- on all browsers here and i've chased many seleniumish race conditions
- but it's not enough for travis.
- So, there's no docker image available with python and selenium that
- looks good i'm probably going to make one at some point but it's not
- today's priority as far as I'm concerned.
- So, tests are run locally which means manual action, but i've left the QA
- checks on travis as mandatory because i've fixed so many PEP8 mistakes
- during this release ...
-
-Congratulations for this release my friends, because a lot of great work has
-been contributed by the community since last release 4 months ago.
-
-3.2.10
-
- #877: Return proper content type for json by @mpasternak
- #819: Fixed JS autocomplete initializer by @apinsard
- #871: Add many translations by @dima-kov
- #879: Add classifiers in setup.py by @ad-m
- #868: Fix selector used to allow clearing non-required fields.
- #861: prefer pristine jQuery to the django one by @xrmx
-
-3.2.9
-
- HEAD is now at a44a2ed Fixed JS autocomplete initializer (#819) (#820) by
- @apinsard
-
-3.2.8
-
- #823: Optgroup list support with Select2GroupListView by @jsurloppe
- #841: Allow boolean "data-html" attribute for widget on python side by @gagarski
- #839: Support for forwarding radio buttons properly by @gagarski
- #839: Add checkbox handling in forwards by @marekjedrzejewski
- #833: Add functional tests for rename_forward app by @gagarski
-
-3.2.7
-
- Remove forward.js from Select2WidgetMixin.Media by @gagarski
- #838: Use namespaced jQuery in `get_forwards` by @ryan-copperleaf
- #836: Queryset sequence view to display actual model name by @jsoa
- 3.2.5 and 3.2.6 were removed from PyPi but are the same.
-
-3.2.4
-
- #813: Return 400 on invalid input by @EvaSDK
-
-3.2.3 Two seriously good community contributed bugfixes
-
- #799: Support serializing UUIDs and add tests for models with UUIDs as PKs
- by @blag
- #826: Prevent rendering of empty option on multi select
- by @beruic
-
-3.2.2 Test fixes, Django 1.10 and 1.11
-
-3.2.1
-
- #737: TaggitSelect2: insure there's a comma when there's only one tag, or
- tag "Multi word" would end up as "Multi" and "word" by
- @Ixxy-Open-Source
- #743: Fix placeholder not working when URL is not given to autocomplete
- widget (#743) by @thauk-copperleaf
- #756: Forward Capabilities Outside Admin by @gagarski
-
-3.2.0
-
- #745: Add list autocomplete by @dmosberger, @jpic and @thecardcheat
- #754: dal_queryset_sequence documentation update by @chubz
- #734: Move create_option functionality into it's own method @andybak
- #741: Fix initial value not set when selected choices aren't strings
- @thauk-copperleaf
- #733: Advanced forward features @gagarski
- #752: Add doc test to CI by @jpic
- #730: Resolve SystemCheckError fields.E304 @thecardcheat
- #721: Note about placement of DAL before grappelli by @chubz
- #748: Update docs example about loading jquery outside the admin @jpic
-
- Mid version number bumped as a tribute to the new major features that were
- added, however, there is no know backward compatibility break from 3.1 to
- 3.2.
-
-3.1.8 #727 Add missing static files
-
-3.1.7
-
- #714: Update select2 by @meesterguyman
- #705: Improve compat with dj1.9 by @9nix00
- #706: Fix extra require by @blueyed
- #710: Added note for static files not checked by @geekashu
- #708: Provide path with dal_select2.E001 by @blueyed
- #700: Enable HTML markup in select2 item labels by @njoyard
-
-3.1.6
-
- #671: Create_field support with querysetsequence
- #679: Allow create message translation by @maisim
- #682: Extend 'forward' attribute to creating objects as well by @toudi
- #666: Improved missing 'created_field' error by @guettli
- #670: Javascript loading documentation
- #678: Added example to update autocompletes in JS
-
-3.1.5
-
- #661: Field forwarding: support form prefix, and formsets at the same time.
- #628: Improve option rename hook used until the patch is merged upstream in
- select2.
- #656: Use http.JsonResponse() to return json by @guettli
- #638: System check for dal_select2 to raise an error if select2 is not
- available.
- #662: Examples for setting placeholder and minimum input length options in
- select2.
- #619: From django-sbo-selenium to pytest+splinter.
-
-3.1.4 #646: Released way too much node_modules in select2, sorry !
-
-3.1.3 #640: Bugfix: Taggit select2 widget in invalid forms
-
-3.1.2
-
- - #634: Select2 Upgrade
- - #628: Do not rely on ids to fix container text
- - #631: Initializing GFKs with values from forms
- - #623: Fix setup.py extras by @ticosax
- - #610: Cancel out Django's style on lists
-
-3.1.1 Forgot vendor files in 3.1.0
-
-3.1.0
-
- - ! BC BREAK ! CreateModelField is gone, it didn't work when another field
- of the form didn't validate. There isn't a really sane way to create the
- option in the form field field. Instead, this is done in the view, like
- in DAL v2.
- - ! BC BREAK ! dal_tagulous was removed in favor of django-tagging, because
- tagulous already provides its implementation of a select2 widget.
- - #610 Fixed a rendering issue with multiple selects. Upstream patch
- proposed but not merged yet: select2/select2#4226
-
- For details about the current status and plan, please consult the
- announcement:
-
- http://blog.yourlabs.org/post/140477620808/django-autocomplete-light-v3-whats-going-on
-
-3.0.4 #586 Prune select2 docs by @eraldo
-
-3.0.3 #586 Include all in MANIFEST by @eraldo
-
-3.0.2 #575 Prevent Django admin from setting up jQuery with noConflict
-
-3.0.1 #573 Updated MANIFEST to include static files.
-
-3.0.0 Rewrite: easier to use, test, maintain and support.
+2.3.4 #914 pin six to 1.10
2.3.3
diff --git a/autocomplete_light/__init__.py b/autocomplete_light/__init__.py
new file mode 100644
index 000000000..fbb2e9775
--- /dev/null
+++ b/autocomplete_light/__init__.py
@@ -0,0 +1,11 @@
+"""
+Provide tools to enable nice autocompletes in your Django project.
+"""
+import django
+
+if django.VERSION < (1, 9):
+ from .shortcuts import * # noqa
+
+default_app_config = 'autocomplete_light.apps.AutocompleteLightConfig'
+
+__version__ = (2, 3, 4)
diff --git a/autocomplete_light/autocomplete/base.py b/autocomplete_light/autocomplete/base.py
new file mode 100644
index 000000000..219b47426
--- /dev/null
+++ b/autocomplete_light/autocomplete/base.py
@@ -0,0 +1,211 @@
+from __future__ import unicode_literals
+
+import six
+from django.urls import reverse, NoReverseMatch
+from django.core.exceptions import ImproperlyConfigured
+from django.utils.encoding import force_text
+from django.utils.html import escape
+from django.utils.translation import ugettext_lazy as _
+
+__all__ = ('AutocompleteInterface', 'AutocompleteBase')
+
+
+class AutocompleteInterface(object):
+ """
+ An autocomplete proposes "choices". A choice has a "value". When the user
+ selects a "choice", then it is converted to a "value".
+
+ AutocompleteInterface is the minimum to implement in a custom Autocomplete
+ class usable by the widget and the view. It has two attributes:
+
+ .. py:attribute:: values
+
+ A list of values which
+ :py:meth:`~.base.AutocompleteInterface.validate_values` and
+ :py:meth:`~.base.AutocompleteInterface.choices_for_values` should use.
+
+ .. py:attribute:: request
+
+ A request object which
+ :py:meth:`~.base.AutocompleteInterface.autocomplete_html()` should use.
+
+ It is recommended that you inherit from :py:class:`~.base.AutocompleteBase`
+ instead when making your own classes because it has taken some design
+ decisions favorising a DRY implementation of
+ :py:class:`~.base.AutocompleteInterface`.
+
+ Instanciate an Autocomplete with a given ``request`` and ``values``
+ arguments. ``values`` will be casted to list if necessary and both will
+ be assigned to instance attributes
+ :py:attr:`~AutocompleteInterface.request` and
+ :py:attr:`~AutocompleteInterface.values` respectively.
+ """
+
+ def __init__(self, request=None, values=None):
+ self.request = request
+
+ if values is None:
+ self.values = []
+ elif (isinstance(values, six.string_types) or
+ not hasattr(values, '__iter__')):
+ self.values = [values]
+ else:
+ self.values = values
+
+ def autocomplete_html(self):
+ """
+ Return the HTML autocomplete that should be displayed under the text
+ input. :py:attr:`request` can be used, if set.
+ """
+ raise NotImplemented()
+
+ def validate_values(self):
+ """
+ Return True if :py:attr:`values` are all valid.
+ """
+ raise NotImplemented()
+
+ def choices_for_values(self):
+ """
+ Return the list of choices corresponding to :py:attr:`values`.
+ """
+ raise NotImplemented()
+
+ def get_absolute_url(self):
+ """
+ Return the absolute url for this autocomplete, using
+ autocomplete_light_autocomplete url.
+ """
+ try:
+ return reverse('autocomplete_light_autocomplete',
+ args=(self.__class__.__name__,))
+ except NoReverseMatch:
+ # Such error will ruin form rendering. It would be automatically
+ # silenced because of e.silent_variable_failure=True, which is
+ # something we don't want. Let's give the user a hint:
+ raise ImproperlyConfigured("URL lookup for autocomplete '%s' "
+ "failed. Have you included autocomplete_light.urls in "
+ "your urls.py?" % (self.__class__.__name__,))
+
+
+class AutocompleteBase(AutocompleteInterface):
+ """
+ A basic implementation of AutocompleteInterface that renders HTML and
+ should fit most cases. It only needs overload of
+ :py:meth:`~.base.AutocompleteBase.choices_for_request` and
+ :py:meth:`~.base.AutocompleteInterface.choices_for_values` which is the
+ business-logic.
+
+ .. py:attribute:: choice_html_format
+
+ HTML string used to format a python choice in HTML by
+ :py:meth:`~.base.AutocompleteBase.choice_html`. It is formated with two
+ positionnal parameters: the value and the html representation,
+ respectively generated by
+ :py:meth:`~.base.AutocompleteBase.choice_value` and
+ :py:meth:`~.base.AutocompleteBase.choice_label`. Default is::
+
+ %s
+
+ .. py:attribute:: empty_html_format
+
+ HTML string used to format the message "no matches found" if no choices
+ match the current request. It takes a parameter for the translated
+ message. Default is::
+
+ %s
+
+ .. py:attribute:: autocomplete_html_format
+
+ HTML string used to format the list of HTML choices. It takes a
+ positionnal parameter which contains the list of HTML choices which
+ come from :py:meth:`~.base.AutocompleteBase.choice_html`. Default is::
+
+ %s
+
+ .. py:attribute:: add_another_url_name
+
+ Name of the url to add another choice via a javascript popup. If empty
+ then no "add another" link will appear.
+
+ .. py:attribute:: add_another_url_kwargs
+
+ Keyword arguments to use when reversing the add another url.
+
+ .. py:attribute:: widget_template
+
+ A special attribute used only by the widget. If it is set, the widget
+ will use that instead of the default
+ ``autocomplete_light/widget.html``.
+ """
+ choice_html_format = '%s'
+ empty_html_format = '%s'
+ autocomplete_html_format = '%s'
+ add_another_url_name = None
+ add_another_url_kwargs = None
+
+ def get_add_another_url(self):
+ """
+ Return the url to use when adding another element
+ """
+ if self.add_another_url_name:
+ url = reverse(self.add_another_url_name,
+ kwargs=self.add_another_url_kwargs)
+ return url + '?_popup=1'
+ else:
+ return None
+
+ def choices_for_request(self):
+ """
+ Return the list of choices that are available. Uses :py:attr:`request`
+ if set, this method is used by
+ :py:meth:`~.base.AutocompleteBase.autocomplete_html`.
+ """
+ raise NotImplemented()
+
+ def validate_values(self):
+ """
+ This basic implementation returns True if all
+ :py:attr:`~AutocompleteInterface.values` are in
+ :py:meth:`~.base.AutocompleteInterface.choices_for_values`.
+ """
+ return len(self.choices_for_values()) == len(self.values)
+
+ def autocomplete_html(self):
+ """
+ Simple rendering of the autocomplete.
+
+ It will append the result of
+ :py:meth:`~.base.AutocompleteBase.choice_html` for each choice returned
+ by :py:meth:`~.base.AutocompleteBase.choices_for_request`, and
+ wrap that in :py:attr:`autocomplete_html_format`.
+ """
+ html = ''.join(
+ [self.choice_html(c) for c in self.choices_for_request()])
+
+ if not html:
+ html = self.empty_html_format % _('No matches found')
+
+ return self.autocomplete_html_format % html
+
+ def choice_html(self, choice):
+ """
+ Format a choice using :py:attr:`choice_html_format`.
+ """
+ return self.choice_html_format % (
+ escape(self.choice_value(choice)),
+ escape(self.choice_label(choice)))
+
+ def choice_value(self, choice):
+ """
+ Return the value of a choice. This simple implementation returns the
+ textual representation.
+ """
+ return force_text(choice)
+
+ def choice_label(self, choice):
+ """
+ Return the human-readable representation of a choice. This simple
+ implementation returns the textual representation.
+ """
+ return force_text(choice)
diff --git a/autocomplete_light/forms.py b/autocomplete_light/forms.py
new file mode 100644
index 000000000..26cd96f6e
--- /dev/null
+++ b/autocomplete_light/forms.py
@@ -0,0 +1,501 @@
+"""
+High-level API for django-autocomplete-light.
+
+Before, django-autocomplete-light was just a container for a loosely coupled
+set of tools. You had to go for a treasure hunt in the docs and source to find
+just what you need and add it to your project.
+
+While you can still do that, this module adds a high-level API which couples
+all the little pieces together. Basically you could just inherit from ModelForm
+or use modelform_factory() and expect everything to work out of the box, from
+simple autocompletes to generic many to many autocompletes including a bug fix
+for django bug #9321 or even added security.
+"""
+from __future__ import unicode_literals
+
+import six
+from django import forms
+from django.conf import settings
+from django.contrib.admin.widgets import RelatedFieldWidgetWrapper
+from django.db.models import ForeignKey, ManyToManyField, OneToOneField
+from django.forms.models import modelform_factory as django_modelform_factory
+from django.forms.models import ModelFormMetaclass as DjangoModelFormMetaclass
+from django.utils.encoding import force_text
+from django.utils.translation import ugettext_lazy as _
+
+from .contrib.taggit_field import TaggitField
+from .fields import (GenericModelChoiceField, GenericModelMultipleChoiceField,
+ ModelChoiceField, ModelMultipleChoiceField)
+from .widgets import MultipleChoiceWidget
+
+if 'genericm2m' in settings.INSTALLED_APPS:
+ from genericm2m.models import RelatedObjectsDescriptor
+else:
+ RelatedObjectsDescriptor = None
+
+__all__ = ['modelform_factory', 'FormfieldCallback', 'ModelForm',
+'SelectMultipleHelpTextRemovalMixin', 'VirtualFieldHandlingMixin',
+'GenericM2MRelatedObjectDescriptorHandlingMixin']
+
+# OMG #9321 why do we have to hard-code this ?
+M = _('Hold down "Control", or "Command" on a Mac, to select more than one.')
+
+
+class SelectMultipleHelpTextRemovalMixin(forms.BaseModelForm):
+ """
+ This mixin that removes the 'Hold down "Control" ...' message that is
+ enforced in select multiple fields.
+
+ See https://code.djangoproject.com/ticket/9321
+ """
+
+ def __init__(self, *args, **kwargs):
+ super(SelectMultipleHelpTextRemovalMixin, self).__init__(*args,
+ **kwargs)
+ msg = force_text(M)
+
+ for name, field in self.fields.items():
+ widget = field.widget
+
+ if isinstance(widget, RelatedFieldWidgetWrapper):
+ widget = widget.widget
+
+ if not isinstance(widget, MultipleChoiceWidget):
+ continue
+
+ field.help_text = field.help_text.replace(msg, '')
+
+
+class VirtualFieldHandlingMixin(forms.BaseModelForm):
+ """
+ Enable virtual field (generic foreign key) handling in django's ModelForm.
+
+ - treat virtual fields like GenericForeignKey as normal fields,
+ - when setting a GenericForeignKey value, also set the object id and
+ content type id fields.
+
+ Probably, django doesn't do that for legacy reasons: virtual fields were
+ added after ModelForm and simply nobody asked django to add virtual field
+ support in ModelForm.
+ """
+ def __init__(self, *args, **kwargs):
+ """
+ The constructor adds virtual field values to
+ :py:attr:`django:django.forms.Form.initial`
+ """
+ super(VirtualFieldHandlingMixin, self).__init__(*args, **kwargs)
+
+ # do what model_to_dict doesn't
+ for field in self._meta.model._meta.private_fields:
+ try:
+ self.initial[field.name] = getattr(self.instance, field.name,
+ None)
+ except:
+ continue
+
+ def _post_clean(self):
+ """
+ What ModelForm does, but also set virtual field values from
+ cleaned_data.
+ """
+ super(VirtualFieldHandlingMixin, self)._post_clean()
+ from django.contrib.contenttypes.models import ContentType
+
+ # take care of virtual fields since django doesn't
+ for field in self._meta.model._meta.private_fields:
+ value = self.cleaned_data.get(field.name, None)
+
+ if value:
+ setattr(self.instance, field.name, value)
+
+ self.cleaned_data[field.ct_field] = \
+ ContentType.objects.get_for_model(value)
+ self.cleaned_data[field.fk_field] = value.pk
+
+
+class GenericM2MRelatedObjectDescriptorHandlingMixin(forms.BaseModelForm):
+ """
+ Extension of autocomplete_light.GenericModelForm, that handles
+ genericm2m's RelatedObjectsDescriptor.
+ """
+
+ def __init__(self, *args, **kwargs):
+ """
+ Add related objects to initial for each generic m2m field.
+ """
+ super(GenericM2MRelatedObjectDescriptorHandlingMixin, self).__init__(
+ *args, **kwargs)
+
+ for name, field in self.generic_m2m_fields():
+ related_objects = getattr(self.instance, name).all()
+ self.initial[name] = [x.object for x in related_objects]
+
+ def generic_m2m_fields(self):
+ """
+ Yield name, field for each RelatedObjectsDescriptor of the model of
+ this ModelForm.
+ """
+ for name, field in self.fields.items():
+ if not isinstance(field, GenericModelMultipleChoiceField):
+ continue
+
+ model_class_attr = getattr(self._meta.model, name, None)
+ if not isinstance(model_class_attr, RelatedObjectsDescriptor):
+ continue
+
+ yield name, field
+
+ def save(self, commit=True):
+ """
+ Save the form and the generic many to many relations in particular.
+ """
+ instance = super(GenericM2MRelatedObjectDescriptorHandlingMixin,
+ self).save(commit=commit)
+
+ def save_m2m():
+ for name, field in self.generic_m2m_fields():
+ model_attr = getattr(instance, name)
+ selected_relations = self.cleaned_data.get(name, [])
+
+ for related in model_attr.all():
+ if related.object not in selected_relations:
+ model_attr.remove(related)
+
+ for related in selected_relations:
+ model_attr.connect(related)
+
+ if hasattr(self, 'save_m2m'):
+ old_m2m = self.save_m2m
+
+ def _():
+ save_m2m()
+ old_m2m()
+ self.save_m2m = _
+ else:
+ save_m2m()
+
+ return instance
+
+
+class FormfieldCallback(object):
+ """
+ Decorate `model_field.formfield()` to use a
+ `autocomplete_light.ModelChoiceField` for `OneToOneField` and
+ `ForeignKey` or a `autocomplete_light.ModelMultipleChoiceField` for a
+ `ManyToManyField`.
+
+ It is the very purpose of our `ModelFormMetaclass` !
+ """
+
+ def __init__(self, default=None, meta=None):
+ self.autocomplete_exclude = getattr(meta, 'autocomplete_exclude', None)
+ self.autocomplete_fields = getattr(meta, 'autocomplete_fields', None)
+ self.autocomplete_names = getattr(meta, 'autocomplete_names', {})
+ self.autocomplete_registry = getattr(meta, 'autocomplete_registry',
+ None)
+
+ def _default(model_field, **kwargs):
+ return model_field.formfield(**kwargs)
+
+ self.default = default or _default
+
+ def __call__(self, model_field, **kwargs):
+ try:
+ from taggit.managers import TaggableManager
+ except ImportError:
+ class TaggableManager(object):
+ pass
+
+ if (self.autocomplete_exclude and
+ model_field.name in self.autocomplete_exclude):
+ pass
+
+ elif (self.autocomplete_fields and
+ model_field.name not in self.autocomplete_fields):
+ pass
+
+ elif hasattr(model_field, 'remote_field') and hasattr(model_field.remote_field, 'model'):
+ if model_field.name in self.autocomplete_names:
+ autocomplete = self.autocomplete_registry.get(
+ self.autocomplete_names[model_field.name])
+ else:
+ autocomplete = \
+ self.autocomplete_registry.autocomplete_for_model(
+ model_field.remote_field.model)
+
+ if autocomplete is not None:
+ kwargs['autocomplete'] = autocomplete
+
+ if isinstance(model_field, (OneToOneField, ForeignKey)):
+ kwargs['form_class'] = ModelChoiceField
+ elif isinstance(model_field, ManyToManyField):
+ kwargs['form_class'] = ModelMultipleChoiceField
+ elif isinstance(model_field, TaggableManager):
+ kwargs['form_class'] = TaggitField
+ else:
+ # none of our concern
+ kwargs.pop('form_class')
+
+ return self.default(model_field, **kwargs)
+
+
+class ModelFormMetaclass(DjangoModelFormMetaclass):
+ """
+ Wrap around django's ModelFormMetaclass to add autocompletes.
+ """
+ def __new__(cls, name, bases, attrs):
+ """
+ Add autocompletes in three steps:
+
+ - use our formfield_callback for basic field autocompletes: one to one,
+ foreign key, many to many
+ - exclude generic foreign key content type foreign key and object id
+ field,
+ - add autocompletes for generic foreign key and generic many to many.
+ """
+ meta = attrs.get('Meta', None)
+
+ # Maybe the parent has a meta ?
+ if meta is None:
+ for parent in bases + type(cls).__mro__:
+ meta = getattr(parent, 'Meta', None)
+
+ if meta is not None:
+ break
+
+ # use our formfield_callback to add autocompletes if not already used
+ formfield_callback = attrs.get('formfield_callback', None)
+
+ if meta is not None:
+ if getattr(meta, 'autocomplete_registry', None) is None:
+ from autocomplete_light.registry import registry
+ meta.autocomplete_registry = registry
+
+ if getattr(meta, 'model', None):
+ cls.clean_meta(meta)
+ cls.pre_new(meta)
+
+ if not isinstance(formfield_callback, FormfieldCallback):
+ attrs['formfield_callback'] = FormfieldCallback(
+ formfield_callback, meta)
+
+ new_class = super(ModelFormMetaclass, cls).__new__(cls, name, bases,
+ attrs)
+
+ if meta is not None and getattr(meta, 'model', None):
+ cls.post_new(new_class, meta)
+
+ return new_class
+
+ @classmethod
+ def skip_field(cls, meta, field):
+ try:
+ from django.contrib.contenttypes.fields import GenericRelation
+ except ImportError:
+ from django.contrib.contenttypes.generic import GenericRelation
+
+ if isinstance(field, GenericRelation):
+ # skip reverse generic foreign key
+ return True
+
+ all_fields = set(getattr(meta, 'fields', []) or []) | set(
+ getattr(meta, 'autocomplete_fields', []))
+ all_exclude = set(getattr(meta, 'exclude', []) or []) | set(
+ getattr(meta, 'autocomplete_exclude', []))
+
+ if getattr(meta, 'fields', None) == '__all__':
+ return field.name in all_exclude
+
+ if len(all_fields) and field.name not in all_fields:
+ return True
+
+ if len(all_exclude) and field.name in all_exclude:
+ return True
+
+ @classmethod
+ def clean_meta(cls, meta):
+ try:
+ from django.contrib.contenttypes.fields import GenericForeignKey
+ except ImportError:
+ from django.contrib.contenttypes.generic import GenericForeignKey
+
+ # All virtual fields/excludes must be move to
+ # autocomplete_fields/exclude
+ fields = getattr(meta, 'fields', [])
+
+ # Using or [] because fields might be None in some django versions.
+ for field in fields or []:
+ model_field = getattr(meta.model._meta.private_fields, field, None)
+
+ if model_field is None:
+ model_field = getattr(meta.model, field, None)
+
+ if model_field is None:
+ continue
+
+ if ((RelatedObjectsDescriptor and isinstance(model_field,
+ (RelatedObjectsDescriptor, GenericForeignKey))) or
+ isinstance(model_field, GenericForeignKey)):
+
+ meta.fields.remove(field)
+
+ if not hasattr(meta, 'autocomplete_fields'):
+ meta.autocomplete_fields = tuple()
+
+ meta.autocomplete_fields += (field,)
+
+ @classmethod
+ def pre_new(cls, meta):
+ try:
+ from django.contrib.contenttypes.fields import GenericForeignKey
+ except ImportError:
+ from django.contrib.contenttypes.generic import GenericForeignKey
+
+ exclude = tuple(getattr(meta, 'exclude', []))
+ add_exclude = []
+
+ # exclude gfk content type and object id fields
+ for field in meta.model._meta.private_fields:
+ if cls.skip_field(meta, field):
+ continue
+
+ if isinstance(field, GenericForeignKey):
+ add_exclude += [field.ct_field, field.fk_field]
+
+ if exclude:
+ # safe concatenation of list/tuple
+ # thanks lvh from #python@freenode
+ meta.exclude = set(add_exclude) | set(exclude)
+
+ @classmethod
+ def post_new(cls, new_class, meta):
+ cls.add_generic_fk_fields(new_class, meta)
+
+ if 'genericm2m' in settings.INSTALLED_APPS:
+ cls.add_generic_m2m_fields(new_class, meta)
+
+ @classmethod
+ def add_generic_fk_fields(cls, new_class, meta):
+ widgets = getattr(meta, 'widgets', {})
+
+ # Add generic fk and m2m autocompletes
+ for field in meta.model._meta.private_fields:
+ if cls.skip_field(meta, field):
+ continue
+
+ if hasattr(meta.model._meta, 'get_field'):
+ _field = meta.model._meta.get_field(field.fk_field)
+ else:
+ # Pre django 1.9 support
+ _field = meta.model._meta.get_field_by_name(field.fk_field)
+
+ new_class.base_fields[field.name] = GenericModelChoiceField(
+ widget=widgets.get(field.name, None),
+ autocomplete=cls.get_generic_autocomplete(meta, field.name),
+ required=not _field
+ )
+
+ @classmethod
+ def add_generic_m2m_fields(cls, new_class, meta):
+ if 'genericm2m' not in settings.INSTALLED_APPS:
+ return
+
+ widgets = getattr(meta, 'widgets', {})
+
+ for field in meta.model.__dict__.values():
+ if not isinstance(field, RelatedObjectsDescriptor):
+ continue
+
+ if cls.skip_field(meta, field):
+ continue
+
+ new_class.base_fields[field.name] = \
+ GenericModelMultipleChoiceField(
+ widget=widgets.get(field.name, None),
+ autocomplete=cls.get_generic_autocomplete(
+ meta, field.name))
+
+ @classmethod
+ def get_generic_autocomplete(self, meta, name):
+ autocomplete_name = getattr(meta, 'autocomplete_names', {}).get(
+ name, None)
+
+ if autocomplete_name:
+ return meta.autocomplete_registry[autocomplete_name]
+ else:
+ return meta.autocomplete_registry.default_generic
+
+
+bases = (ModelFormMetaclass,
+ SelectMultipleHelpTextRemovalMixin, VirtualFieldHandlingMixin)
+
+if 'genericm2m' in settings.INSTALLED_APPS:
+ bases += GenericM2MRelatedObjectDescriptorHandlingMixin,
+
+bases += forms.ModelForm,
+
+
+class ModelForm(six.with_metaclass(*bases)):
+ """
+ ModelForm override using our metaclass that adds our various mixins.
+
+ .. py:attribute:: autocomplete_fields
+
+ A list of field names on which you want automatic autocomplete fields.
+
+ .. py:attribute:: autocomplete_exclude
+
+ A list of field names on which you do not want automatic autocomplete
+ fields.
+
+ .. py:attribute:: autocomplete_names
+
+ A dict of ``field_name: AutocompleteName`` to override the default
+ autocomplete that would be used for a field.
+
+ Note: all of ``autocomplete_fields``, ``autocomplete_exclude`` and
+ ``autocomplete_names`` understand generic foreign key and generic many to
+ many descriptor names.
+ """
+
+
+def modelform_factory(model, autocomplete_fields=None,
+ autocomplete_exclude=None, autocomplete_names=None,
+ registry=None, **kwargs):
+ """
+ Wrap around Django's django_modelform_factory, using our ModelForm and
+ setting autocomplete_fields and autocomplete_exclude.
+ """
+ if 'form' not in kwargs.keys():
+ kwargs['form'] = ModelForm
+
+ attrs = {'model': model}
+
+ if autocomplete_fields is not None:
+ attrs['autocomplete_fields'] = autocomplete_fields
+ if autocomplete_exclude is not None:
+ attrs['autocomplete_exclude'] = autocomplete_exclude
+ if autocomplete_names is not None:
+ attrs['autocomplete_names'] = autocomplete_names
+
+ # If parent form class already has an inner Meta, the Meta we're
+ # creating needs to inherit from the parent's inner meta.
+ parent = (object,)
+ if hasattr(kwargs['form'], 'Meta'):
+ parent = (kwargs['form'].Meta, object)
+ Meta = type(str('Meta'), parent, attrs)
+
+ # We have to handle Meta.fields/Meta.exclude here because else Django will
+ # raise a warning.
+ if 'fields' in kwargs:
+ Meta.fields = kwargs.pop('fields')
+ if 'exclude' in kwargs:
+ Meta.exclude = kwargs.pop('exclude')
+
+ kwargs['form'] = type(kwargs['form'].__name__, (kwargs['form'],),
+ {'Meta': Meta})
+
+ if not issubclass(kwargs['form'], ModelForm):
+ raise Exception('form kwarg must be an autocomplete_light ModelForm')
+
+ return django_modelform_factory(model, **kwargs)
diff --git a/autocomplete_light/widgets.py b/autocomplete_light/widgets.py
new file mode 100644
index 000000000..7629f3341
--- /dev/null
+++ b/autocomplete_light/widgets.py
@@ -0,0 +1,331 @@
+from __future__ import unicode_literals
+
+from django import forms
+from django.template.loader import render_to_string
+from django.utils import safestring
+from django.utils.translation import ugettext_lazy as _
+
+"""
+The provided widgets are meant to rely on an Autocomplete class.
+
+- :py:class:`ChoiceWidget` :py:class:`django:django.forms.Select`
+
+ChoiceWidget is intended to work as a replacement for django's Select widget,
+and MultipleChoiceWidget for django's SelectMultiple,
+
+Constructing a widget needs an Autocomplete class or registered autocomplete
+name.
+
+The choice autocomplete widget renders from autocomplete_light/widget.html
+template.
+"""
+
+
+try:
+ from django.forms.utils import flatatt
+except ImportError:
+ from django.forms.util import flatatt
+
+
+__all__ = ['WidgetBase', 'ChoiceWidget', 'MultipleChoiceWidget', 'TextWidget']
+
+
+class WidgetBase(object):
+ """
+ Base widget for autocompletes.
+
+ .. py:attribute:: attrs
+
+ HTML ```` attributes, such as class, placeholder, etc ... Note
+ that any ``data-autocomplete-*`` attribute will be parsed as an option
+ for ``yourlabs.Autocomplete`` js object. For example::
+
+ attrs={
+ 'placeholder': 'foo',
+ 'data-autocomplete-minimum-characters': 0
+ 'class': 'bar',
+ }
+
+ Will render like::
+
+
+ Which will set by the way ``yourlabs.Autocomplete.minimumCharacters``
+ option - the naming conversion is handled by jQuery.
+
+ .. py:attribute:: widget_attrs
+
+ HTML widget container attributes. Note that any ``data-widget-*``
+ attribute will be parsed as an option for ``yourlabs.Widget`` js
+ object. For example::
+
+ widget_attrs={
+ 'data-widget-maximum-values': 6,
+ 'class': 'country-autocomplete',
+ }
+
+ Will render like::
+
+
+
+ Which will set by the way ``yourlabs.Widget.maximumValues`` - note that
+ the naming conversion is handled by jQuery.
+
+ .. py:attribute:: widget_js_attributes
+
+ **DEPRECATED** in favor of :py:attr::`widget_attrs`.
+
+ A dict of options that will override the default widget options. For
+ example::
+
+ widget_js_attributes = {'max_values': 8}
+
+ The above code will set this HTML attribute::
+
+ data-max-values="8"
+
+ Which will override the default javascript widget maxValues option
+ (which is 0).
+
+ It is important to understand naming conventions which are sparse
+ unfortunately:
+
+ - python: lower case with underscores ie. ``max_values``,
+ - HTML attributes: lower case with dashes ie. ``data-max-values``,
+ - javascript: camel case, ie. ``maxValues``.
+
+ The python to HTML name conversion is done by the
+ autocomplete_light_data_attributes template filter.
+
+ The HTML to javascript name conversion is done by the jquery plugin.
+
+ .. py:attribute:: autocomplete_js_attributes
+
+ **DEPRECATED** in favor of :py:attr::`attrs`.
+
+ A dict of options like for :py:attr:`widget_js_attributes`. However,
+ note that HTML attributes will be prefixed by ``data-autocomplete-``
+ instead of just ``data-``. This allows the jQuery plugins to make the
+ distinction between attributes for the autocomplete instance and
+ attributes for the widget instance.
+
+ .. py:attribute:: extra_context
+
+ Extra context dict to pass to the template.
+
+ .. py:attribute:: widget_template
+
+ Template to use to render the widget. Default is
+ ``autocomplete_light/widget.html``.
+ """
+
+ def __init__(self, autocomplete=None, widget_js_attributes=None,
+ autocomplete_js_attributes=None, extra_context=None,
+ registry=None, widget_template=None, widget_attrs=None):
+ self._registry = registry
+ self._autocomplete = None
+ self.autocomplete_arg = autocomplete
+
+ self.widget_js_attributes = widget_js_attributes or {}
+ self.autocomplete_js_attributes = autocomplete_js_attributes or {}
+ self.extra_context = extra_context or {}
+ self.widget_template = (widget_template or
+ 'autocomplete_light/widget.html')
+ self.widget_attrs = widget_attrs or {}
+
+ if autocomplete_js_attributes is not None:
+ raise PendingDeprecationWarning('autocomplete_js_attributes are'
+ 'deprecated in favor of attrs')
+
+ if widget_js_attributes is not None:
+ raise PendingDeprecationWarning('widget_js_attributes are'
+ 'deprecated in favor of widget_attrs')
+
+ @property
+ def registry(self):
+ if self._registry is None:
+ from autocomplete_light.registry import registry
+ self._registry = registry
+ return self._registry
+
+ def render(self, name, value, attrs=None):
+ widget_attrs = self.build_widget_attrs(name)
+
+ autocomplete = self.autocomplete(values=value)
+
+ attrs = self.build_attrs(self.attrs, attrs, autocomplete=autocomplete)
+
+ self.html_id = attrs.pop('id', name)
+
+ choices = autocomplete.choices_for_values()
+ values = [autocomplete.choice_value(c) for c in choices]
+
+ context = {
+ 'name': name,
+ 'values': values,
+ 'choices': choices,
+ 'widget': self,
+ 'attrs': safestring.mark_safe(flatatt(attrs)),
+ 'widget_attrs': safestring.mark_safe(flatatt(widget_attrs)),
+ 'autocomplete': autocomplete,
+ }
+ context.update(self.extra_context)
+
+ template = getattr(autocomplete, 'widget_template',
+ self.widget_template)
+ return safestring.mark_safe(render_to_string(template, context))
+
+ def build_attrs(self, attrs, extra_attrs=None,
+ autocomplete=None, **kwargs):
+ attrs.copy()
+ attrs.update(getattr(autocomplete, 'attrs', {}))
+ attrs = super(WidgetBase, self).build_attrs(
+ attrs, extra_attrs, **kwargs)
+
+ if 'class' not in attrs.keys():
+ attrs['class'] = ''
+
+ attrs['class'] += ' autocomplete vTextField'
+
+ attrs.setdefault('data-autocomplete-choice-selector', '[data-value]')
+ attrs.setdefault('data-autocomplete-url',
+ self.autocomplete().get_absolute_url())
+ attrs.setdefault('placeholder', _(
+ 'type some text to search in this autocomplete'))
+
+ # for backward compatibility
+ for key, value in self.autocomplete_js_attributes.items():
+ attrs['data-autocomplete-%s' % key.replace('_', '-')] = value
+
+ return attrs
+
+ def build_widget_attrs(self, name=None):
+ attrs = getattr(self.autocomplete, 'widget_attrs', {}).copy()
+ attrs.update(self.widget_attrs)
+
+ if 'class' not in attrs:
+ attrs['class'] = ''
+
+ attrs.setdefault('data-widget-bootstrap', 'normal')
+
+ # for backward compatibility
+ for key, value in self.autocomplete_js_attributes.items():
+ attrs['data-widget-%s' % key.replace('_', '-')] = value
+
+ attrs['class'] += ' autocomplete-light-widget '
+
+ if name:
+ attrs['class'] += name
+
+ if attrs.get('data-widget-maximum-values', 0) == 1:
+ attrs['class'] += ' single'
+ else:
+ attrs['class'] += ' multiple'
+
+ return attrs
+
+ def autocomplete():
+ def fget(self):
+ if not self._autocomplete:
+ self._autocomplete = self.registry.get_autocomplete_from_arg(
+ self.autocomplete_arg)
+
+ return self._autocomplete
+
+ def fset(self, value):
+ self._autocomplete = value
+ self.autocomplete_name = value.__class__.__name__
+
+ return {'fget': fget, 'fset': fset}
+ autocomplete = property(**autocomplete())
+
+
+class ChoiceWidget(WidgetBase, forms.Select):
+ """
+ Widget that provides an autocomplete for zero to one choice.
+ """
+
+ def __init__(self, autocomplete=None, widget_js_attributes=None,
+ autocomplete_js_attributes=None, extra_context=None, registry=None,
+ widget_template=None, widget_attrs=None, *args,
+ **kwargs):
+
+ forms.Select.__init__(self, *args, **kwargs)
+
+ WidgetBase.__init__(self, autocomplete, widget_js_attributes,
+ autocomplete_js_attributes, extra_context, registry,
+ widget_template, widget_attrs)
+
+ self.widget_attrs.setdefault('data-widget-maximum-values', 1)
+
+
+class MultipleChoiceWidget(WidgetBase, forms.SelectMultiple):
+ """
+ Widget that provides an autocomplete for zero to n choices.
+ """
+ def __init__(self, autocomplete=None, widget_js_attributes=None,
+ autocomplete_js_attributes=None, extra_context=None, registry=None,
+ widget_template=None, widget_attrs=None, *args,
+ **kwargs):
+
+ forms.SelectMultiple.__init__(self, *args, **kwargs)
+
+ WidgetBase.__init__(self, autocomplete,
+ widget_js_attributes, autocomplete_js_attributes, extra_context,
+ registry, widget_template, widget_attrs)
+
+
+class TextWidget(WidgetBase, forms.TextInput):
+ """
+ Widget that just adds an autocomplete to fill a text input.
+
+ Note that it only renders an ````, so attrs and widget_attrs are
+ merged together.
+ """
+
+ def __init__(self, autocomplete=None, widget_js_attributes=None,
+ autocomplete_js_attributes=None, extra_context=None, registry=None,
+ widget_template=None, widget_attrs=None, *args,
+ **kwargs):
+
+ forms.TextInput.__init__(self, *args, **kwargs)
+
+ WidgetBase.__init__(self, autocomplete, widget_js_attributes,
+ autocomplete_js_attributes, extra_context, registry,
+ widget_template, widget_attrs)
+
+ def render(self, name, value, attrs=None):
+ """ Proxy Django's TextInput.render() """
+
+ autocomplete = self.autocomplete(values=value)
+ attrs = self.build_attrs(self.attrs, attrs, autocomplete=autocomplete)
+
+ return forms.TextInput.render(self, name, value, attrs)
+
+ def build_attrs(self, attrs, extra_attrs=None,
+ autocomplete=None, **kwargs):
+ attrs.copy()
+ attrs.update(super(TextWidget, self).build_widget_attrs())
+ attrs.update(getattr(autocomplete, 'attrs', {}))
+ attrs.update(super(TextWidget, self).build_attrs(
+ self.attrs, extra_attrs, **kwargs))
+
+ def update_attrs(source, prefix=''):
+ for key, value in source.items():
+ key = 'data-%s%s' % (prefix, key.replace('_', '-'))
+ attrs[key] = value
+
+ update_attrs(self.widget_js_attributes, 'widget-')
+ update_attrs(self.autocomplete_js_attributes, 'autocomplete-')
+
+ attrs['data-widget-bootstrap'] = 'text'
+ attrs['class'] += ' autocomplete-light-text-widget'
+
+ return attrs
diff --git a/setup.py b/setup.py
index ce6d19ac5..eb5e3da4a 100644
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,7 @@ def read(fname):
setup(
name='django-autocomplete-light',
- version='3.5.0',
+ version='2.3.6',
description='Fresh autocompletes for Django',
author='James Pic',
author_email='jamespic@gmail.com',
@@ -24,7 +24,10 @@ def read(fname):
long_description=read('README'),
license='MIT',
keywords='django autocomplete',
- install_requires=['six'],
+ cmdclass={'test': RunTests},
+ install_requires=[
+ 'six',
+ ],
extras_require={
'nested': ['django-nested-admin>=3.0.21'],
'tags': ['django-taggit'],