Source code for autocomplete_light.forms

"""
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.')


[docs]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, '')
[docs]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.virtual_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.virtual_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
[docs]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]
[docs] 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
[docs] 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
[docs]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, 'rel') and hasattr(model_field.rel, 'to'): 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.rel.to) 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)
[docs]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.virtual_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.virtual_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.virtual_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,
[docs]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. """ __metaclass__ = ModelFormMetaclass
[docs]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)