From e154f19f592eb6433337e677154f35475e46e048 Mon Sep 17 00:00:00 2001 From: Justin Clark Date: Thu, 30 Jan 2020 14:37:29 -0700 Subject: [PATCH] merge --- CHANGELOG | 260 +----------- autocomplete_light/__init__.py | 11 + autocomplete_light/autocomplete/base.py | 211 ++++++++++ autocomplete_light/forms.py | 501 ++++++++++++++++++++++++ autocomplete_light/widgets.py | 331 ++++++++++++++++ setup.py | 7 +- 6 files changed, 1062 insertions(+), 259 deletions(-) create mode 100644 autocomplete_light/__init__.py create mode 100644 autocomplete_light/autocomplete/base.py create mode 100644 autocomplete_light/forms.py create mode 100644 autocomplete_light/widgets.py 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'],