More django-mptt goodness: FilteredSelectMultiple m2m widget

If you are using django-mptt to manage content (eg heirarchical categories) then it needs a bit of help to make a nice admin interface. For many-to-many fields, Django provides the quite nice FilteredSelectMultiple widget (a two-pane selection list with search box) but it only renders ‘flat’ lists… if you have a big category tree it’s going to be confusing to know what belongs to what. Also, list items are sorted alphabetically by the js, which won’t be what you want.

In this post I’ll be extending FilteredSelectMultiple to show the tree structure in the list:

mptt FilteredSelectMultiple widget screenshot

The code is pretty clean – no patches to hack! – and you can get it here on Django snippets.

You’ll also need to put this javascript file on your media path: mptt_m2m_selectbox.js

Once you’ve got those, you just need to use it in your admin form:

class ProductForm(forms.ModelForm):
    categories = MPTTModelMultipleChoiceField(
                    ProductCategory.objects.all(),
                    widget = MPTTFilteredSelectMultiple("Categories",False,attrs={'rows':'10'})
                )
    class Meta:
        model= ProductCategory

class ProductAdmin(admin.ModelAdmin):
    form = ProductForm

And that’s about it. You could go further of course, for example by rendering the select list in a fixed-width font you could draw the tree branches in ascii. Probably it would also be useful to show a different representation in the right-hand list (eg to represent the parents of selected categories somehow).

If you make any enhancements or find any bugs then please comment back!

9 thoughts on “More django-mptt goodness: FilteredSelectMultiple m2m widget”

  1. Awesome! still works in Django==1.4, after a little adjustment in the js Media of widgets.py:

    class Media:
    extend = False
    js = (settings.STATIC_URL + “admin/js/core.js”,
    settings.STATIC_URL + “js/mptt_m2m_selectbox.js”,
    settings.STATIC_URL + “admin/js/SelectFilter2.js”,
    )

  2. I really like this! I’ve adopted it to work in a form outside of admin by calling jquery.init. But I’ve noticed in my html, the optgroup label doesn’t get injected. I believe we need optgroup to make the tree collapsable? Is that possible in the filtered select multiple widget? Thanks!

  3. If a parent category is selected, does clicking the right arrow automatically select all the children categories into the chosen categories box on the right?

    1. No it doesn’t, for my case that was not needed as the selection on the right represents the value I want saved in the db (the id of the parent) and the sub-categories are implicitly included by the heirarchical structure.

  4. Hi! It’s 2019 and this solution is still the best I’ve been able to find.
    It won’t work with Django 1.11 however. To fix it I added an overridden optgroups method.
    I also added couple minor things I gathered while investigating for a fix (a default level_indicator of “+–” and changed paths for the JS files.

    “`
    from itertools import chain
    from django import forms
    from django.contrib.admin import widgets
    from django.utils.encoding import smart_unicode, force_unicode, force_text
    from django.utils.html import escape, conditional_escape

    class MPTTModelChoiceIterator(forms.models.ModelChoiceIterator):
    “””MPTT version of ModelChoiceIterator”””
    def choice(self, obj):
    “””Overriding choice method”””
    tree_id = getattr(obj, self.queryset.model._mptt_meta.tree_id_attr, 0)
    left = getattr(obj, self.queryset.model._mptt_meta.left_attr, 0)
    return super(MPTTModelChoiceIterator,
    self).choice(obj) + ((tree_id, left),)

    class MPTTModelMultipleChoiceField(forms.ModelMultipleChoiceField):
    “””MPTT version of ModelMultipleChoiceField”””
    def __init__(self, *args, **kwargs):
    self.level_indicator = kwargs.pop(‘level_indicator’, ‘+–‘)
    super(MPTTModelMultipleChoiceField, self).__init__(*args, **kwargs)

    def label_from_instance(self, obj):
    “””Creates labels which represent the tree level of each node
    when generating option labels.”””
    return u’%s %s’ % (self.level_indicator * getattr(
    obj, obj._mptt_meta.level_attr), smart_unicode(obj))

    def _get_choices(self):
    “””Overriding _get_choices”””
    if hasattr(self, ‘_choices’):
    return self._choices
    return MPTTModelChoiceIterator(self)

    choices = property(_get_choices, forms.ChoiceField._set_choices)

    class MPTTFilteredSelectMultiple(widgets.FilteredSelectMultiple):
    “””MPTT version of FilteredSelectMultiple”””
    def render_options(self, choices, selected_choices):
    “””
    This is copy’n’pasted from django.forms.widgets Select(Widget)
    change to the for loop and render_option so they will unpack
    and use our extra tuple of mptt sort fields (if you pass in
    some default choices for this field, make sure they have the
    extra tuple too!)
    “””
    def render_option(option_value, option_label, sort_fields):
    “””Inner scope render_option”””
    option_value = force_unicode(option_value)
    selected_html = (option_value in selected_choices) \
    and u’ selected=”selected”‘ or ”
    return u’%s’ % (
    escape(option_value),
    sort_fields[0],
    sort_fields[1],
    selected_html,
    conditional_escape(force_unicode(option_label)),
    )
    # Normalize to strings.
    selected_choices = set([force_unicode(v) for v in selected_choices])
    output = []
    for option_value, option_label, sort_fields in chain(
    self.choices, choices):
    if isinstance(option_label, (list, tuple)):
    output.append(u” % escape(
    force_unicode(option_value)))
    for option in option_label:
    output.append(render_option(*option))
    output.append(u”)
    else:
    output.append(render_option(option_value, option_label,
    sort_fields))
    return u’\n’.join(output)

    def optgroups(self, name, value, attrs=None):
    “””Return a list of optgroups for this widget.

    Adapted from django.forms.widgets.ChoiceWidget.optgroups method, the only change was
    adding `(t, p)` in the for loop”””
    groups = []
    has_selected = False

    for index, (option_value, option_label, (t, l)) in enumerate(chain(self.choices)):
    if option_value is None:
    option_value = ”

    subgroup = []
    if isinstance(option_label, (list, tuple)):
    group_name = option_value
    subindex = 0
    choices = option_label
    else:
    group_name = None
    subindex = None
    choices = [(option_value, option_label)]
    groups.append((group_name, subgroup, index))

    for subvalue, sublabel in choices:
    selected = (
    force_text(subvalue) in value and
    (has_selected is False or self.allow_multiple_selected)
    )
    if selected is True and has_selected is False:
    has_selected = True
    subgroup.append(self.create_option(
    name, subvalue, sublabel, selected, index,
    subindex=subindex, attrs=attrs,
    ))
    if subindex is not None:
    subindex += 1
    return groups

    class Media:
    # extend = False
    js = (“admin/js/core.js”,
    “js/mptt_m2m_selectbox.js”,
    “admin/js/SelectFilter2.js”,)
    “`

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s